Skip to content

Commit 1f9de2a

Browse files
authored
Merge pull request #170 from MacNite/claude/hivetraffic-mosfet-toggles-4a5yqu
Let a HiveTraffic counter's three emitter banks be switched individually
2 parents 6c8d274 + 0125bb6 commit 1f9de2a

22 files changed

Lines changed: 951 additions & 30 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ jobs:
7272
/tmp/test_beehive_decode
7373
# The HiveTraffic measurement decoder is a two-repo wire contract: it has
7474
# to keep reading fw:2 documents from counters the OTA relay has not
75-
# reached yet, as well as the fw:3 and fw:4 ones newer firmware emits.
76-
# A captured document of each revision is asserted here.
75+
# reached yet, as well as the fw:3, fw:4 and fw:5 ones newer firmware
76+
# emits. A captured document of each revision is asserted here.
7777
- name: Build and run bee counter wire tests
7878
run: |
7979
set -e
@@ -129,6 +129,7 @@ jobs:
129129
python3 test-data/test_ble_sensor_rules.py
130130
python3 test-data/test_sd_import.py
131131
python3 test-data/test_data_export.py
132+
python3 test-data/test_bee_counter_banks.py
132133
python3 -m pytest -q test-data/test_tempcomp.py test-data/test_hive_tempcomp.py \
133134
test-data/test_hiveheart_fft.py \
134135
test-data/test_mqtt_ha_discovery.py \

docs/api.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,10 @@ Returns the current config for a device. A default config is created if none exi
382382
"beecounter_night_start_minute": 1200,
383383
"beecounter_night_end_minute": 360,
384384
"beecounter_night_max_traffic": 0,
385-
"timezone": ""
385+
"timezone": "",
386+
"beecounter_bank1_enabled": true,
387+
"beecounter_bank2_enabled": true,
388+
"beecounter_bank3_enabled": true
386389
}
387390
```
388391

@@ -400,6 +403,33 @@ next cycle rather than waiting for an unrelated edit to bump the version.
400403
| `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. |
401404
| `timezone` | POSIX TZ string, e.g. `CET-1CEST,M3.5.0,M10.5.0/3`. Empty means UTC. |
402405

406+
The `beecounter_bank*_enabled` fields configure **HiveTraffic emitter banks**
407+
see [hivetraffic-bee-counter.md](hivetraffic-bee-counter.md#emitter-banks).
408+
Applied on every config fetch for the same reason the night-mode fields are.
409+
410+
| Field | Meaning |
411+
| --- | --- |
412+
| `beecounter_bank1_enabled` | Emitter MOSFET for gates 00–07. `true` by default. |
413+
| `beecounter_bank2_enabled` | Emitter MOSFET for gates 10–17. `true` by default. |
414+
| `beecounter_bank3_enabled` | Emitter MOSFET for gates 20–27. `true` by default. |
415+
416+
A counter's 48 IR emitters sit behind three MOSFETs, one per group of eight
417+
gates, and they dominate its power draw. Measured on the counter's 3.3 V rail:
418+
one bank ~0.14 A, two ~0.22 A, three ~0.30 A — roughly 80 mA per bank on top of
419+
a ~60 mA floor. Switching one off stops its eight gates being counted at all, so
420+
the totals drop in proportion; the counter reports the mask back as
421+
`hives[].bee_counter.banks` so a deliberately dark bank is distinguishable from
422+
a failed one.
423+
424+
A PATCH that would leave **all three** disabled is rejected with `400`. The
425+
counter refuses a mask of zero outright — it keeps the mask it had — so storing
426+
one would only leave the dashboard and the hardware permanently disagreeing. A
427+
counter that should count nothing is unpaired instead.
428+
429+
The device requires HiveTraffic firmware 0.3.0 (wire revision 5) or newer to
430+
apply the mask; on an older counter the write is skipped and all three banks
431+
stay on.
432+
403433
`timezone` is load-bearing rather than cosmetic. The device clock is UTC, so
404434
without it a window entered as 20:00 fires at 21:00 local in summer — discarding
405435
an hour of foraging on exactly the long evenings that have most of it. It is a

docs/hivetraffic-bee-counter.md

Lines changed: 109 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,14 @@ All HiveTraffic devices share one service/characteristic (overridable via
114114
| --- | --- |
115115
| Service | `8e8b0101-7a1c-4b9e-9a2f-1d6e0b9c1a01` |
116116
| Measurement characteristic (READ) | `8e8b0102-7a1c-4b9e-9a2f-1d6e0b9c1a01` |
117+
| Control characteristic (READ/WRITE) | `8e8b0103-7a1c-4b9e-9a2f-1d6e0b9c1a01` |
117118

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

120121
```json
121-
{ "fw":4, "ver":"0.2.0", "uptime_s":1234, "status":15, "num_gates":24,
122-
"mcps_healthy":3, "total_in":100, "total_out":95, "glitches":2, "idle_s":0 }
122+
{ "fw":5, "ver":"0.3.0", "uptime_s":1234, "status":15, "num_gates":24,
123+
"mcps_healthy":3, "total_in":100, "total_out":95, "glitches":2, "idle_s":0,
124+
"banks":7 }
123125
```
124126

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

133-
### Three wire revisions, all supported
135+
### Four wire revisions, all supported
134136

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

138-
| | `fw:2` | `fw:3` | `fw:4` |
139-
| --- | --- | --- | --- |
140-
| Expander health field | `gates_healthy` | `mcps_healthy` | `mcps_healthy` |
141-
| `uptime_s` | 16-bit on the device, clamped at 65535 (18 h 12 min) | 32-bit | 32-bit |
142-
| `glitches` | 16-bit, pinned at 65535 | 32-bit, saturating | 32-bit, saturating |
143-
| `idle_s` + status bit `0x80` ||| night-mode countdown |
144-
145-
`fw:4` is purely additive, so the parser read those documents correctly before
146-
it knew `idle_s` existed — it skips unknown keys. What it could not do is tell a
147-
*suspended* counter from a broken one, which is the whole reason for reading it.
140+
| | `fw:2` | `fw:3` | `fw:4` | `fw:5` |
141+
| --- | --- | --- | --- | --- |
142+
| Expander health field | `gates_healthy` | `mcps_healthy` | `mcps_healthy` | `mcps_healthy` |
143+
| `uptime_s` | 16-bit on the device, clamped at 65535 (18 h 12 min) | 32-bit | 32-bit | 32-bit |
144+
| `glitches` | 16-bit, pinned at 65535 | 32-bit, saturating | 32-bit, saturating | 32-bit, saturating |
145+
| `idle_s` + status bit `0x80` ||| night-mode countdown | night-mode countdown |
146+
| `banks` |||| enabled emitter MOSFETs |
147+
148+
`fw:4` and `fw:5` are purely additive, so the parser read those documents
149+
correctly before it knew `idle_s` or `banks` existed — it skips unknown keys.
150+
What it could not do is tell a *suspended* or *narrowed* counter from a broken
151+
one, which is the whole reason for reading them. A counter too old to report
152+
`banks` is running all three, so the field defaults to `7` rather than `0`;
153+
reading its absence as "everything is off" would misrepresent every counter the
154+
OTA relay has not reached yet.
148155

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

260+
## Emitter banks
261+
262+
Night mode decides *when* a counter stops. This decides *how much of it runs at
263+
all*, and it applies around the clock.
264+
265+
The counter's 48 IR emitters sit behind three IRLB8721 MOSFETs, one per
266+
MCP23017, so each third of the entrance is independently switchable:
267+
268+
| Bank | Gates | Expander |
269+
| --- | --- | --- |
270+
| 1 | 00–07 | U2 @ 0x20 |
271+
| 2 | 10–17 | U3 @ 0x21 |
272+
| 3 | 20–27 | U4 @ 0x22 |
273+
274+
Measured on the counter's 3.3 V rail:
275+
276+
| Banks enabled | Gates counted | Draw |
277+
| --- | --- | --- |
278+
| 1 | 8 | ~0.14 A |
279+
| 2 | 16 | ~0.22 A |
280+
| 3 (default) | 24 | ~0.30 A |
281+
282+
Roughly 80 mA per bank on top of a ~60 mA floor. Dropping one saves about as
283+
much current as a quarter of a night of night mode, except it saves it all day,
284+
which makes it the coarsest and most effective power control the counter has.
285+
The two compose rather than compete: a counter can be running one bank *and* be
286+
suspended.
287+
288+
Turn a bank off when the hive entrance is physically narrower than 24 gates,
289+
when part of it is closed for the season, or when an off-grid supply will not
290+
carry the whole board.
291+
292+
### Setup
293+
294+
Dashboard → **HiveTraffic setup***Emitter banks*: three checkboxes, all
295+
ticked by default. The setting is per **device** (every counter paired to one
296+
hub shares it) and applies to every paired counter, exactly like the night
297+
window above it.
298+
299+
| Field | Notes |
300+
| --- | --- |
301+
| Bank 1 / 2 / 3 | One checkbox per MOSFET. All three enabled unless you say otherwise |
302+
303+
At least one must stay enabled. The dashboard refuses to save all three off and
304+
the API rejects it with `400`, because the counter refuses a mask of zero
305+
outright — it keeps whatever mask it had — so storing one would leave three
306+
unticked boxes next to a counter cheerfully counting all 24 gates, with nothing
307+
saying why. A counter that should count nothing is unpaired instead.
308+
309+
### How it works
310+
311+
1. `/api/v1/devices/{id}/config` delivers the three booleans; `fetchRemoteConfig`
312+
assembles them into a bitmask (`beeBankMask`) and persists it in NVS, so a
313+
hub that boots without WiFi still narrows its counters.
314+
2. `bee_counter_client.cpp` reads the measurement characteristic as usual, and
315+
compares the `banks` value the counter just **reported** with the configured
316+
mask.
317+
3. If they differ, a 2-byte `SET_BANKS` frame (`0x03` + mask) goes out on the
318+
same connection — no extra scan, no extra connect.
319+
4. The counter applies it, darkens the MOSFETs of the disabled banks, and skips
320+
those gates entirely rather than reading them as "clear".
321+
322+
Comparing against what the counter *reported* — rather than against a
323+
HiveHub-side memory of what it last sent — is the whole self-healing property.
324+
The counter deliberately does not persist the mask, so one that browned out,
325+
watchdogged or rebooted out of an OTA comes back running all 24 gates; the next
326+
cycle's read shows the disagreement and fixes it. A HiveHub that remembered "I
327+
already configured this one" would leave that counter wide open indefinitely.
328+
329+
Counters running firmware older than `fw:5` do not understand the opcode. The
330+
write is gated on the reported revision and simply skipped, so they keep running
331+
all three banks; the OTA relay will bring them up in the normal course of things.
332+
333+
### Reading the data
334+
335+
A switched-off bank's eight gates stop contributing to `total_in` / `total_out`
336+
permanently, which is character for character what a dead emitter FET produces.
337+
`banks` is the only thing that separates them, which is why it is stored on
338+
every reading as `hives[].bee_counter.banks` — including the `7` of a counter
339+
nobody has narrowed. A field that appeared only when it was interesting would
340+
make "all banks on" and "counter too old to say" the same absence.
341+
342+
`num_gates` keeps reporting **24**: it describes what is wired, which has not
343+
changed. Active gates are `popcount(banks) * 8`.
344+
345+
Expect the totals to drop roughly in proportion when a bank is switched off, and
346+
do not compare a narrowed counter's numbers against its own earlier history —
347+
the interval charts will show a step, and it is real.
348+
253349
## Intervals are differenced server-side
254350

255351
The wire format carries only the **monotonic lifetime totals**. The backend

firmware/include/bee_counter_client.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ struct Snapshot {
6262
// to report it. Non-zero says the flat interval that follows is deliberate
6363
// — we asked for it — rather than a failed emitter bank.
6464
uint32_t idle_s = 0;
65+
// Which of the counter's three emitter MOSFETs are enabled ("banks", wire
66+
// revision 5+): bit 0 = gates 00..07, bit 1 = 10..17, bit 2 = 20..27.
67+
//
68+
// BEECOUNTER_BANK_MASK_ALL for any counter too old to report it, which is
69+
// the truth — a pre-v5 counter has no way to switch a bank off. A cleared
70+
// bit means eight gates are dark and not being counted, so their share of
71+
// the totals is permanently flat; without this field that is
72+
// indistinguishable from the FET having failed, which is the same problem
73+
// idle_s solves for the whole counter.
74+
uint8_t bank_mask = BEECOUNTER_BANK_MASK_ALL;
6575
// 32-bit as of revision 3, saturating on the device rather than wrapping.
6676
uint32_t glitch_count = 0;
6777
};

firmware/include/bee_counter_wire.h

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@
2525
// correctly before it knew the field existed — it skips unknown
2626
// keys. What it could not do is tell a suspended counter from a
2727
// broken one, which is the point of reading it.
28+
// fw >= 5 adds "banks": which of the counter's three emitter MOSFETs are
29+
// enabled. Additive in the same way, and needed for the same reason
30+
// — a bank switched off produces a permanently flat third of the
31+
// totals, character for character what a dead FET produces. A
32+
// counter too old to report it is running all three banks, which is
33+
// why bank_mask defaults to ALL rather than to zero: the absence of
34+
// the field means "everything is on", never "nothing is".
2835
//
2936
// Both must keep working, and not as a courtesy: a counter in the field reports
3037
// fw:2 until it is updated over the air, and HiveHub's OTA relay has to read
@@ -68,6 +75,19 @@ constexpr uint8_t REV_MCPS_HEALTHY = 3;
6875
// First protocol revision carrying "idle_s" and the night-mode status bit.
6976
constexpr uint8_t REV_NIGHT_MODE = 4;
7077

78+
// First protocol revision carrying "banks", and therefore the first that
79+
// understands the SET_BANKS control opcode. bee_counter_client.cpp gates the
80+
// write on it rather than writing hopefully: an older counter ignores the
81+
// opcode and logs it, which would print every cycle forever.
82+
constexpr uint8_t REV_LED_BANKS = 5;
83+
84+
// All three emitter banks enabled — the mask a counter runs unless told
85+
// otherwise, the mask it returns to after any reset, and what this parser
86+
// reports for a counter too old to carry the field. Mirrors
87+
// beecounter_proto::BANK_MASK_ALL in the HiveTraffic repo; duplicated for the
88+
// same reason STATUS_NIGHT_IDLE is, and pinned by the same test.
89+
constexpr uint8_t BANK_MASK_ALL = 0x07;
90+
7191
// Status bit 0x80: the counter is deliberately not sensing. Mirrors
7292
// beecounter_proto::STATUS_NIGHT_IDLE in the HiveTraffic repo; duplicated
7393
// rather than shared because the two firmwares have no common header, and
@@ -101,8 +121,33 @@ struct Measurement {
101121
// stop, because honey bees do not fly at night. Without it that is
102122
// indistinguishable from failed emitters.
103123
uint32_t idle_s = 0;
124+
// Which of the counter's three emitter MOSFETs are enabled: bit 0 = gates
125+
// 00..07, bit 1 = 10..17, bit 2 = 20..27. Read from "banks" (fw >= 5).
126+
//
127+
// Defaults to ALL, not to 0. A counter too old to report the field is
128+
// running its whole entrance, and a zero default would make every pre-v5
129+
// counter look like it had been switched off entirely — the exact
130+
// misreading that would then be written into stored history.
131+
uint8_t bank_mask = BANK_MASK_ALL;
104132
};
105133

134+
// How many gates this counter is actually watching, given its bank mask. The
135+
// counter keeps reporting num_gates as what is WIRED (24), because that is a
136+
// fact about the board; this is what is being counted right now.
137+
inline uint8_t activeGates(const Measurement& m) {
138+
uint8_t banks = 0;
139+
for (uint8_t bit = 1; bit; bit = static_cast<uint8_t>(bit << 1)) {
140+
if (m.bank_mask & bit) banks++;
141+
}
142+
return static_cast<uint8_t>(banks * 8U);
143+
}
144+
145+
// Is any bank switched off? The flat totals that follow are then deliberate,
146+
// in exactly the way isNightIdle() means for a whole counter.
147+
inline bool hasDisabledBank(const Measurement& m) {
148+
return (m.bank_mask & BANK_MASK_ALL) != BANK_MASK_ALL;
149+
}
150+
106151
// Is this counter currently suspended? Reads the countdown rather than the
107152
// status bit, because the countdown is the value the firmware derives at
108153
// serialization time and therefore the one that cannot lag the other.
@@ -294,6 +339,14 @@ inline bool parseMeasurement(const char* json, size_t len, Measurement& out) {
294339
out.glitch_count = value;
295340
} else if (klen == 6 && memcmp(key, "idle_s", 6) == 0) {
296341
out.idle_s = value;
342+
} else if (klen == 5 && memcmp(key, "banks", 5) == 0) {
343+
// Masked to the three banks this hardware has, so a counter on a
344+
// future four-FET board cannot report a bank HiveHub would then
345+
// try to switch. A value of 0 is stored as read: it is not a thing
346+
// the counter can be in (it refuses an all-off mask), so seeing one
347+
// means something is wrong and flattening it to ALL would hide it.
348+
detail::assignU8(out.bank_mask,
349+
value & static_cast<uint32_t>(BANK_MASK_ALL));
297350
}
298351
// else: unknown key, already skipped.
299352
}

firmware/include/config.h

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,20 @@
633633
#ifndef BEECOUNTER_CTRL_OP_SET_IDLE
634634
#define BEECOUNTER_CTRL_OP_SET_IDLE 0x01
635635
#endif
636+
// Emitter-bank enables (HiveTraffic protocol v5 and later). The counter's 48 IR
637+
// LEDs sit behind three MOSFETs, one per MCP23017 — bank 1 = gates 00..07,
638+
// bank 2 = 10..17, bank 3 = 20..27 — and each draws roughly 80 mA at 3.3 V, on
639+
// top of a ~60 mA floor: one bank ~0.14 A, two ~0.22 A, three ~0.30 A. We write
640+
// the configured mask once per cycle, gated on the counter reporting fw >= 5
641+
// (wire::REV_LED_BANKS) so an older one is not sent an opcode it can only log
642+
// as unknown, every cycle, forever.
643+
#ifndef BEECOUNTER_CTRL_OP_SET_BANKS
644+
#define BEECOUNTER_CTRL_OP_SET_BANKS 0x03
645+
#endif
646+
// All three banks on: the default, and what a counter runs after any reset.
647+
#ifndef BEECOUNTER_BANK_MASK_ALL
648+
#define BEECOUNTER_BANK_MASK_ALL 0x07
649+
#endif
636650

637651
// OTA characteristics. Unlike HiveInside these live in the SAME service as the
638652
// measurement characteristic above — HiveTraffic has only one service.

firmware/include/device_prefs.h

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ String wifiPassKey(int index);
1212
void seedPrefsFromSecretsIfNeeded();
1313
void loadConfigFromPrefs();
1414
void saveScaleConfig();
15-
// Persist the HiveTraffic night-mode window from the last /config fetch, so an
16-
// offline boot still honours it. Called from fetchRemoteConfig().
15+
// Persist the HiveTraffic power settings from the last /config fetch — the
16+
// night-mode window and the emitter-bank mask — so an offline boot still
17+
// honours them. Called from fetchRemoteConfig().
1718
void saveNightModePrefs();
1819
// Latch "the server has this device's claim" so the claim code stops riding
1920
// along on every upload.

firmware/include/globals.h

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,24 @@ extern uint32_t nightMaxTraffic;
153153
// tzset() so localtime() honours it. Empty means UTC.
154154
extern String nightTimezone;
155155

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

0 commit comments

Comments
 (0)