Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ jobs:
/tmp/test_beehive_decode
# The HiveTraffic measurement decoder is a two-repo wire contract: it has
# to keep reading fw:2 documents from counters the OTA relay has not
# reached yet, as well as the fw:3 and fw:4 ones newer firmware emits.
# A captured document of each revision is asserted here.
# reached yet, as well as the fw:3, fw:4 and fw:5 ones newer firmware
# emits. A captured document of each revision is asserted here.
- name: Build and run bee counter wire tests
run: |
set -e
Expand Down Expand Up @@ -129,6 +129,7 @@ jobs:
python3 test-data/test_ble_sensor_rules.py
python3 test-data/test_sd_import.py
python3 test-data/test_data_export.py
python3 test-data/test_bee_counter_banks.py
python3 -m pytest -q test-data/test_tempcomp.py test-data/test_hive_tempcomp.py \
test-data/test_hiveheart_fft.py \
test-data/test_mqtt_ha_discovery.py \
Expand Down
32 changes: 31 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,10 @@ Returns the current config for a device. A default config is created if none exi
"beecounter_night_start_minute": 1200,
"beecounter_night_end_minute": 360,
"beecounter_night_max_traffic": 0,
"timezone": ""
"timezone": "",
"beecounter_bank1_enabled": true,
"beecounter_bank2_enabled": true,
"beecounter_bank3_enabled": true
}
```

Expand All @@ -400,6 +403,33 @@ next cycle rather than waiting for an unrelated edit to bump the version.
| `beecounter_night_max_traffic` | Crossings (in + out) in the last upload cycle above which night mode is postponed to the next cycle. `0` disables the check. |
| `timezone` | POSIX TZ string, e.g. `CET-1CEST,M3.5.0,M10.5.0/3`. Empty means UTC. |

The `beecounter_bank*_enabled` fields configure **HiveTraffic emitter banks** —
see [hivetraffic-bee-counter.md](hivetraffic-bee-counter.md#emitter-banks).
Applied on every config fetch for the same reason the night-mode fields are.

| Field | Meaning |
| --- | --- |
| `beecounter_bank1_enabled` | Emitter MOSFET for gates 00–07. `true` by default. |
| `beecounter_bank2_enabled` | Emitter MOSFET for gates 10–17. `true` by default. |
| `beecounter_bank3_enabled` | Emitter MOSFET for gates 20–27. `true` by default. |

A counter's 48 IR emitters sit behind three MOSFETs, one per group of eight
gates, and they dominate its power draw. Measured on the counter's 3.3 V rail:
one bank ~0.14 A, two ~0.22 A, three ~0.30 A — roughly 80 mA per bank on top of
a ~60 mA floor. Switching one off stops its eight gates being counted at all, so
the totals drop in proportion; the counter reports the mask back as
`hives[].bee_counter.banks` so a deliberately dark bank is distinguishable from
a failed one.

A PATCH that would leave **all three** disabled is rejected with `400`. The
counter refuses a mask of zero outright — it keeps the mask it had — so storing
one would only leave the dashboard and the hardware permanently disagreeing. A
counter that should count nothing is unpaired instead.

The device requires HiveTraffic firmware 0.3.0 (wire revision 5) or newer to
apply the mask; on an older counter the write is skipped and all three banks
stay on.

`timezone` is load-bearing rather than cosmetic. The device clock is UTC, so
without it a window entered as 20:00 fires at 21:00 local in summer — discarding
an hour of foraging on exactly the long evenings that have most of it. It is a
Expand Down
122 changes: 109 additions & 13 deletions docs/hivetraffic-bee-counter.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,14 @@ All HiveTraffic devices share one service/characteristic (overridable via
| --- | --- |
| Service | `8e8b0101-7a1c-4b9e-9a2f-1d6e0b9c1a01` |
| Measurement characteristic (READ) | `8e8b0102-7a1c-4b9e-9a2f-1d6e0b9c1a01` |
| Control characteristic (READ/WRITE) | `8e8b0103-7a1c-4b9e-9a2f-1d6e0b9c1a01` |

The characteristic returns a compact JSON document — **totals only**:

```json
{ "fw":4, "ver":"0.2.0", "uptime_s":1234, "status":15, "num_gates":24,
"mcps_healthy":3, "total_in":100, "total_out":95, "glitches":2, "idle_s":0 }
{ "fw":5, "ver":"0.3.0", "uptime_s":1234, "status":15, "num_gates":24,
"mcps_healthy":3, "total_in":100, "total_out":95, "glitches":2, "idle_s":0,
"banks":7 }
```

`fw` is the wire-protocol revision; `ver` is the counter's own image version,
Expand All @@ -130,21 +132,26 @@ HiveHub reads it, fills a totals-only `beecnt::Snapshot`, and disconnects.
The wire format is totals-only by design: no latch/reset command exists over
BLE, so a missed connection can never lose counts.

### Three wire revisions, all supported
### Four wire revisions, all supported

`firmware/include/bee_counter_wire.h` reads `fw` **first** and branches on it.
Every revision parses, and they produce the same record:

| | `fw:2` | `fw:3` | `fw:4` |
| --- | --- | --- | --- |
| Expander health field | `gates_healthy` | `mcps_healthy` | `mcps_healthy` |
| `uptime_s` | 16-bit on the device, clamped at 65535 (18 h 12 min) | 32-bit | 32-bit |
| `glitches` | 16-bit, pinned at 65535 | 32-bit, saturating | 32-bit, saturating |
| `idle_s` + status bit `0x80` | — | — | night-mode countdown |

`fw:4` is purely additive, so the parser read those documents correctly before
it knew `idle_s` existed — it skips unknown keys. What it could not do is tell a
*suspended* counter from a broken one, which is the whole reason for reading it.
| | `fw:2` | `fw:3` | `fw:4` | `fw:5` |
| --- | --- | --- | --- | --- |
| Expander health field | `gates_healthy` | `mcps_healthy` | `mcps_healthy` | `mcps_healthy` |
| `uptime_s` | 16-bit on the device, clamped at 65535 (18 h 12 min) | 32-bit | 32-bit | 32-bit |
| `glitches` | 16-bit, pinned at 65535 | 32-bit, saturating | 32-bit, saturating | 32-bit, saturating |
| `idle_s` + status bit `0x80` | — | — | night-mode countdown | night-mode countdown |
| `banks` | — | — | — | enabled emitter MOSFETs |

`fw:4` and `fw:5` are purely additive, so the parser read those documents
correctly before it knew `idle_s` or `banks` existed — it skips unknown keys.
What it could not do is tell a *suspended* or *narrowed* counter from a broken
one, which is the whole reason for reading them. A counter too old to report
`banks` is running all three, so the field defaults to `7` rather than `0`;
reading its absence as "everything is off" would misrepresent every counter the
OTA relay has not reached yet.

This is not politeness toward old firmware. **A counter keeps reporting `fw:2`
until the OTA relay updates it, and the relay reads this very characteristic
Expand Down Expand Up @@ -250,6 +257,95 @@ Counters running firmware older than `fw:4` have no control characteristic. The
write is skipped and they keep counting; the OTA relay will bring them up to a
firmware that can be suspended in the normal course of things.

## Emitter banks

Night mode decides *when* a counter stops. This decides *how much of it runs at
all*, and it applies around the clock.

The counter's 48 IR emitters sit behind three IRLB8721 MOSFETs, one per
MCP23017, so each third of the entrance is independently switchable:

| Bank | Gates | Expander |
| --- | --- | --- |
| 1 | 00–07 | U2 @ 0x20 |
| 2 | 10–17 | U3 @ 0x21 |
| 3 | 20–27 | U4 @ 0x22 |

Measured on the counter's 3.3 V rail:

| Banks enabled | Gates counted | Draw |
| --- | --- | --- |
| 1 | 8 | ~0.14 A |
| 2 | 16 | ~0.22 A |
| 3 (default) | 24 | ~0.30 A |

Roughly 80 mA per bank on top of a ~60 mA floor. Dropping one saves about as
much current as a quarter of a night of night mode, except it saves it all day,
which makes it the coarsest and most effective power control the counter has.
The two compose rather than compete: a counter can be running one bank *and* be
suspended.

Turn a bank off when the hive entrance is physically narrower than 24 gates,
when part of it is closed for the season, or when an off-grid supply will not
carry the whole board.

### Setup

Dashboard → **HiveTraffic setup** → *Emitter banks*: three checkboxes, all
ticked by default. The setting is per **device** (every counter paired to one
hub shares it) and applies to every paired counter, exactly like the night
window above it.

| Field | Notes |
| --- | --- |
| Bank 1 / 2 / 3 | One checkbox per MOSFET. All three enabled unless you say otherwise |

At least one must stay enabled. The dashboard refuses to save all three off and
the API rejects it with `400`, because the counter refuses a mask of zero
outright — it keeps whatever mask it had — so storing one would leave three
unticked boxes next to a counter cheerfully counting all 24 gates, with nothing
saying why. A counter that should count nothing is unpaired instead.

### How it works

1. `/api/v1/devices/{id}/config` delivers the three booleans; `fetchRemoteConfig`
assembles them into a bitmask (`beeBankMask`) and persists it in NVS, so a
hub that boots without WiFi still narrows its counters.
2. `bee_counter_client.cpp` reads the measurement characteristic as usual, and
compares the `banks` value the counter just **reported** with the configured
mask.
3. If they differ, a 2-byte `SET_BANKS` frame (`0x03` + mask) goes out on the
same connection — no extra scan, no extra connect.
4. The counter applies it, darkens the MOSFETs of the disabled banks, and skips
those gates entirely rather than reading them as "clear".

Comparing against what the counter *reported* — rather than against a
HiveHub-side memory of what it last sent — is the whole self-healing property.
The counter deliberately does not persist the mask, so one that browned out,
watchdogged or rebooted out of an OTA comes back running all 24 gates; the next
cycle's read shows the disagreement and fixes it. A HiveHub that remembered "I
already configured this one" would leave that counter wide open indefinitely.

Counters running firmware older than `fw:5` do not understand the opcode. The
write is gated on the reported revision and simply skipped, so they keep running
all three banks; the OTA relay will bring them up in the normal course of things.

### Reading the data

A switched-off bank's eight gates stop contributing to `total_in` / `total_out`
permanently, which is character for character what a dead emitter FET produces.
`banks` is the only thing that separates them, which is why it is stored on
every reading as `hives[].bee_counter.banks` — including the `7` of a counter
nobody has narrowed. A field that appeared only when it was interesting would
make "all banks on" and "counter too old to say" the same absence.

`num_gates` keeps reporting **24**: it describes what is wired, which has not
changed. Active gates are `popcount(banks) * 8`.

Expect the totals to drop roughly in proportion when a bank is switched off, and
do not compare a narrowed counter's numbers against its own earlier history —
the interval charts will show a step, and it is real.

## Intervals are differenced server-side

The wire format carries only the **monotonic lifetime totals**. The backend
Expand Down
10 changes: 10 additions & 0 deletions firmware/include/bee_counter_client.h
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ struct Snapshot {
// to report it. Non-zero says the flat interval that follows is deliberate
// — we asked for it — rather than a failed emitter bank.
uint32_t idle_s = 0;
// Which of the counter's three emitter MOSFETs are enabled ("banks", wire
// revision 5+): bit 0 = gates 00..07, bit 1 = 10..17, bit 2 = 20..27.
//
// BEECOUNTER_BANK_MASK_ALL for any counter too old to report it, which is
// the truth — a pre-v5 counter has no way to switch a bank off. A cleared
// bit means eight gates are dark and not being counted, so their share of
// the totals is permanently flat; without this field that is
// indistinguishable from the FET having failed, which is the same problem
// idle_s solves for the whole counter.
uint8_t bank_mask = BEECOUNTER_BANK_MASK_ALL;
// 32-bit as of revision 3, saturating on the device rather than wrapping.
uint32_t glitch_count = 0;
};
Expand Down
53 changes: 53 additions & 0 deletions firmware/include/bee_counter_wire.h
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@
// correctly before it knew the field existed — it skips unknown
// keys. What it could not do is tell a suspended counter from a
// broken one, which is the point of reading it.
// fw >= 5 adds "banks": which of the counter's three emitter MOSFETs are
// enabled. Additive in the same way, and needed for the same reason
// — a bank switched off produces a permanently flat third of the
// totals, character for character what a dead FET produces. A
// counter too old to report it is running all three banks, which is
// why bank_mask defaults to ALL rather than to zero: the absence of
// the field means "everything is on", never "nothing is".
//
// Both must keep working, and not as a courtesy: a counter in the field reports
// fw:2 until it is updated over the air, and HiveHub's OTA relay has to read
Expand Down Expand Up @@ -68,6 +75,19 @@ constexpr uint8_t REV_MCPS_HEALTHY = 3;
// First protocol revision carrying "idle_s" and the night-mode status bit.
constexpr uint8_t REV_NIGHT_MODE = 4;

// First protocol revision carrying "banks", and therefore the first that
// understands the SET_BANKS control opcode. bee_counter_client.cpp gates the
// write on it rather than writing hopefully: an older counter ignores the
// opcode and logs it, which would print every cycle forever.
constexpr uint8_t REV_LED_BANKS = 5;

// All three emitter banks enabled — the mask a counter runs unless told
// otherwise, the mask it returns to after any reset, and what this parser
// reports for a counter too old to carry the field. Mirrors
// beecounter_proto::BANK_MASK_ALL in the HiveTraffic repo; duplicated for the
// same reason STATUS_NIGHT_IDLE is, and pinned by the same test.
constexpr uint8_t BANK_MASK_ALL = 0x07;

// Status bit 0x80: the counter is deliberately not sensing. Mirrors
// beecounter_proto::STATUS_NIGHT_IDLE in the HiveTraffic repo; duplicated
// rather than shared because the two firmwares have no common header, and
Expand Down Expand Up @@ -101,8 +121,33 @@ struct Measurement {
// stop, because honey bees do not fly at night. Without it that is
// indistinguishable from failed emitters.
uint32_t idle_s = 0;
// Which of the counter's three emitter MOSFETs are enabled: bit 0 = gates
// 00..07, bit 1 = 10..17, bit 2 = 20..27. Read from "banks" (fw >= 5).
//
// Defaults to ALL, not to 0. A counter too old to report the field is
// running its whole entrance, and a zero default would make every pre-v5
// counter look like it had been switched off entirely — the exact
// misreading that would then be written into stored history.
uint8_t bank_mask = BANK_MASK_ALL;
};

// How many gates this counter is actually watching, given its bank mask. The
// counter keeps reporting num_gates as what is WIRED (24), because that is a
// fact about the board; this is what is being counted right now.
inline uint8_t activeGates(const Measurement& m) {
uint8_t banks = 0;
for (uint8_t bit = 1; bit; bit = static_cast<uint8_t>(bit << 1)) {
if (m.bank_mask & bit) banks++;
}
return static_cast<uint8_t>(banks * 8U);
}

// Is any bank switched off? The flat totals that follow are then deliberate,
// in exactly the way isNightIdle() means for a whole counter.
inline bool hasDisabledBank(const Measurement& m) {
return (m.bank_mask & BANK_MASK_ALL) != BANK_MASK_ALL;
}

// Is this counter currently suspended? Reads the countdown rather than the
// status bit, because the countdown is the value the firmware derives at
// serialization time and therefore the one that cannot lag the other.
Expand Down Expand Up @@ -294,6 +339,14 @@ inline bool parseMeasurement(const char* json, size_t len, Measurement& out) {
out.glitch_count = value;
} else if (klen == 6 && memcmp(key, "idle_s", 6) == 0) {
out.idle_s = value;
} else if (klen == 5 && memcmp(key, "banks", 5) == 0) {
// Masked to the three banks this hardware has, so a counter on a
// future four-FET board cannot report a bank HiveHub would then
// try to switch. A value of 0 is stored as read: it is not a thing
// the counter can be in (it refuses an all-off mask), so seeing one
// means something is wrong and flattening it to ALL would hide it.
detail::assignU8(out.bank_mask,
value & static_cast<uint32_t>(BANK_MASK_ALL));
}
// else: unknown key, already skipped.
}
Expand Down
14 changes: 14 additions & 0 deletions firmware/include/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,20 @@
#ifndef BEECOUNTER_CTRL_OP_SET_IDLE
#define BEECOUNTER_CTRL_OP_SET_IDLE 0x01
#endif
// Emitter-bank enables (HiveTraffic protocol v5 and later). The counter's 48 IR
// LEDs sit behind three MOSFETs, one per MCP23017 — bank 1 = gates 00..07,
// bank 2 = 10..17, bank 3 = 20..27 — and each draws roughly 80 mA at 3.3 V, on
// top of a ~60 mA floor: one bank ~0.14 A, two ~0.22 A, three ~0.30 A. We write
// the configured mask once per cycle, gated on the counter reporting fw >= 5
// (wire::REV_LED_BANKS) so an older one is not sent an opcode it can only log
// as unknown, every cycle, forever.
#ifndef BEECOUNTER_CTRL_OP_SET_BANKS
#define BEECOUNTER_CTRL_OP_SET_BANKS 0x03
#endif
// All three banks on: the default, and what a counter runs after any reset.
#ifndef BEECOUNTER_BANK_MASK_ALL
#define BEECOUNTER_BANK_MASK_ALL 0x07
#endif

// OTA characteristics. Unlike HiveInside these live in the SAME service as the
// measurement characteristic above — HiveTraffic has only one service.
Expand Down
5 changes: 3 additions & 2 deletions firmware/include/device_prefs.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ String wifiPassKey(int index);
void seedPrefsFromSecretsIfNeeded();
void loadConfigFromPrefs();
void saveScaleConfig();
// Persist the HiveTraffic night-mode window from the last /config fetch, so an
// offline boot still honours it. Called from fetchRemoteConfig().
// Persist the HiveTraffic power settings from the last /config fetch — the
// night-mode window and the emitter-bank mask — so an offline boot still
// honours them. Called from fetchRemoteConfig().
void saveNightModePrefs();
// Latch "the server has this device's claim" so the claim code stops riding
// along on every upload.
Expand Down
18 changes: 18 additions & 0 deletions firmware/include/globals.h
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,24 @@ extern uint32_t nightMaxTraffic;
// tzset() so localtime() honours it. Empty means UTC.
extern String nightTimezone;

// ---- HiveTraffic emitter banks -------------------------------------------
// Which of a paired counter's three emitter MOSFETs may light. Bit 0 = bank 1
// (gates 00..07), bit 1 = bank 2 (10..17), bit 2 = bank 3 (20..27). Delivered
// by /api/v1/devices/{id}/config as three booleans and assembled into this mask;
// persisted in NVS alongside the night window, and written to each paired
// counter once per upload cycle.
//
// The counter itself never persists it, so this re-assert is not belt and
// braces — it is the mechanism. A counter that reset comes back running all 24
// gates and stays that way until we tell it otherwise, which costs at most one
// cycle of extra current and never a counter blind on eight gates for reasons
// nobody can reconstruct.
//
// BEECOUNTER_BANK_MASK_ALL (all three on) unless the dashboard says otherwise.
// A mask of 0 is never sent: the dashboard refuses to store it and the counter
// refuses to apply it.
extern uint8_t beeBankMask;

// ---- Scale calibration ----------------------------------------------------
extern long scale1Offset;
extern long scale2Offset;
Expand Down
Loading
Loading