Skip to content

Commit 4f869d7

Browse files
MacNiteclaude
andcommitted
Let each of the three emitter banks be switched off individually
The 2026-08 board puts one IRLB8721 behind each MCP23017, which makes each third of the entrance independently switchable. Measured at 3.3 V, one bank draws ~0.14 A, two ~0.22 A and three ~0.30 A — roughly 80 mA per bank on top of a ~60 mA floor — so for an entrance narrower than 24 gates, or a supply that will not carry the full board, this is the coarsest and most effective power knob available. It composes with night mode rather than competing with it. Protocol v5: * control opcode 0x03 SET_BANKS + a 1-byte enable bitmask; * the control read-back gains a trailing mask byte (appended, so a client that reads five bytes and stops sees what it always did); * the measurement document gains "banks", emitted unconditionally — a field that only appeared when interesting would make "all banks on" and "counter too old to say" the same absence, and a switched-off bank produces exactly the flat totals a dead FET does. The mask rules live in the new include/bank_state.h, free of Arduino and pinned by host tests, because every mistake they can make is silent. They follow night mode's fail-open posture: never persisted, re-asserted by HiveHub every cycle, all three banks on after any reset, and an all-off mask refused rather than applied so one corrupted byte cannot blind a counter until someone walks to the hive. Gates on a dark bank are skipped, not read as "clear". An unpowered QRE1113 is a bare phototransistor under a 100k pull-up and direct sun into a hive entrance can pull one low, which would invent crossings on gates deliberately switched off. The expander is still read and health-checked, so mcps_healthy keeps meaning "MCP23017s answering", and num_gates keeps reporting what is wired. The IR_DEBUG console gains 4/5/6 to toggle banks on the bench, named for the schematic's /GPIO4../GPIO6 FET rails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TCTPLvKrrm1RmiaVHGxz4D
1 parent 7f463ed commit 4f869d7

14 files changed

Lines changed: 751 additions & 47 deletions

File tree

Firmware/README.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ The previous 2-FET build split U3's gates across banks 1 and 2 (00..13 / 14..27)
6161

6262
Driving the GPIO HIGH turns the bank's emitters on. In the default `LedMode::AUTO` the emitters are **pulsed**: all three banks are lit together only for the settle + MCP-read window of each poll (~1.75 ms at 100 kHz), then switched off until the next poll. This drops the emitter duty cycle from 100% to roughly 35% at the default 5 ms poll interval, cutting average emitter current proportionally, with no change to detection behaviour. The IR_DEBUG console's `1` / `0` / `a` keys force steady-on, blackout and pulsed mode respectively for bench work.
6363

64+
Each bank can also be **switched off entirely**, which is a separate control from the LED mode: HiveHub writes an enable bitmask to the control characteristic (`SET_BANKS`, protocol v5) and `include/bank_state.h` decides what is applied. One bank draws ~0.14 A at 3.3 V, two ~0.22 A, three ~0.30 A, so this is the coarsest and most effective power knob on the board — for an entrance narrower than 24 gates, or a supply that will not carry the full one.
65+
66+
All three banks are enabled by default and after any reset; the mask is never persisted, and HiveHub re-asserts it every upload cycle. A mask of `0` is refused rather than applied. Gates on a dark bank are **skipped**, not read as "clear": an unpowered QRE1113 is a bare phototransistor under a 100 kΩ pull-up, and direct sun into the entrance can pull one low. The expander itself is still read and health-checked, so `mcps_healthy` keeps its meaning. The IR_DEBUG console's `4` / `5` / `6` keys toggle banks 1/2/3 for bench work.
67+
6468
### Counting
6569

6670
Each gate is a small state machine:
@@ -180,10 +184,18 @@ Press a single key in the serial monitor:
180184
| `0` | Force IR LEDs OFF |
181185
| `a` | IR LEDs AUTO (normal pulsed mode) |
182186
| `n` | Arm / clear a 60 s night-mode suspension (press again to resume) |
187+
| `4` | Toggle emitter bank 1 (GATE_00..07) |
188+
| `5` | Toggle emitter bank 2 (GATE_10..17) |
189+
| `6` | Toggle emitter bank 3 (GATE_20..27) |
183190
| `h` | Show the command list |
184191

185-
Each reading lists the raw MCP23017 port words plus a per-gate `BLOCK`/`clear`
186-
line for the inner and outer sensor. The emitters are pulsed on for every read
192+
The bank keys are `4`/`5`/`6` because the schematic labels those rails `/GPIO4`,
193+
`/GPIO5` and `/GPIO6` — misleading net names (they are physically GPIO19/20/18)
194+
but the ones silkscreened next to the FETs.
195+
196+
Each reading lists the raw MCP23017 port words, the current bank mask, plus a
197+
per-gate `BLOCK`/`clear` line for the inner and outer sensor. A gate whose bank
198+
is switched off prints `<bank disabled>` rather than a beam state. The emitters are pulsed on for every read
187199
regardless of the LED mode, so the readout is always valid. Wave a finger or a
188200
bee through a gate and you should see that gate's `inner`/`outer` flip to
189201
`BLOCK`.
@@ -282,6 +294,13 @@ No ESP32, no MCP23017 and no bee required.
282294
well-formed documents full of zeros, which is indistinguishable from a spell
283295
of bad weather until someone reads a week of totals — so none of this is
284296
something a bench session would catch.
297+
* **`include/bank_state.h`** — the emitter-bank enable mask. Every mistake it
298+
can make is silent: a bank that should be on but is off produces a
299+
permanently flat third of the totals, which reads exactly like a dead FET,
300+
and a bank that should be off but is on quietly costs ~80 mA on a supply
301+
sized without it. The suite pins the refusal of an all-off mask, the
302+
one-based bank numbering that `gates::TABLE[].led_bank` depends on, and the
303+
masking of bits above the last physical bank.
285304

286305
Everything hardware-facing stays in `src/main.cpp` and is still verified on the
287306
bench with the IR-sensor console above.

Firmware/include/bank_state.h

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// ============================================================================
2+
// bank_state.h — which emitter banks (MOSFETs) are enabled
3+
// ============================================================================
4+
//
5+
// Arduino-free, like idle_state.h and gate_logic.h, and for the same reason:
6+
// the interesting part of "run only some of the counter" is a handful of
7+
// bitmask rules that decide whether eight gates get counted at all. Getting one
8+
// wrong is silent — the totals for those gates simply stay flat, which looks
9+
// exactly like a dead FET — so the rules are pinned by
10+
// test/test_bank_state/ on a host compiler. src/main.cpp owns the GPIO and the
11+
// poll loop and calls in here for the verdict.
12+
//
13+
// The model
14+
// ---------
15+
// Since the 2026-08 hardware revision there is one IRLB8721 per MCP23017, so a
16+
// bank IS a chip is eight gates:
17+
//
18+
// bank 1 (bit 0) -> U2 @ 0x20, gates 00..07
19+
// bank 2 (bit 1) -> U3 @ 0x21, gates 10..17
20+
// bank 3 (bit 2) -> U4 @ 0x22, gates 20..27
21+
//
22+
// HiveHub writes a mask; this header decides what is actually applied. Three
23+
// rules, each of which exists because the alternative fails quietly:
24+
//
25+
// 1. **Bits above the last bank are ignored.** A four-bank board's mask
26+
// arriving at a three-bank counter must not conjure a bank 4 whose GPIO
27+
// does not exist.
28+
// 2. **A mask of 0 is refused, not applied.** Every other decision in this
29+
// firmware is arranged so that a bad write costs a cycle rather than a
30+
// deployment; accepting 0 would let one corrupted byte blind a counter
31+
// until someone walks to the hive. A counter that should count nothing is
32+
// unpaired in HiveHub, not masked to zero here.
33+
// 3. **It is not persisted.** A reset comes back with every bank enabled and
34+
// counting, and HiveHub re-asserts the mask on its next upload cycle. The
35+
// worst case is one cycle of drawing more current than asked for, which is
36+
// the same failure direction night mode chose.
37+
//
38+
// Why not just leave the FET off and read the chip anyway
39+
// ------------------------------------------------------
40+
// That is exactly what this does — the chip is still read and still health-
41+
// checked, so `mcps_healthy` keeps meaning what it has always meant. What the
42+
// caller must additionally do is SKIP the gates on a dark bank, which is not
43+
// paranoia: an unlit QRE1113 is not a sensor that reads "clear", it is a bare
44+
// phototransistor under a 100k pull-up, and direct sun through a hive entrance
45+
// is quite capable of pulling one low. Counting those would invent crossings on
46+
// gates the operator deliberately switched off.
47+
// ============================================================================
48+
49+
#pragma once
50+
51+
#include <stdint.h>
52+
53+
#include "counter_protocol.h"
54+
55+
namespace bankstate {
56+
57+
// Enabled-bank bitmask. Default-constructed is "everything on", which is what a
58+
// freshly booted counter must always be.
59+
struct State {
60+
uint8_t mask = beecounter_proto::BANK_MASK_ALL;
61+
};
62+
63+
// Result of a SET_BANKS request, so the caller can log what it actually did
64+
// rather than what it was asked to do.
65+
struct Request {
66+
uint8_t granted = beecounter_proto::BANK_MASK_ALL;
67+
bool accepted = false; // false: refused, `granted` is the unchanged mask
68+
bool changed = false; // did the applied mask actually move?
69+
};
70+
71+
// Bit for bank number 1..NUM banks. Bank 0 does not exist and yields 0, so a
72+
// caller that mixes up 0- and 1-based numbering gets "no bank" rather than a
73+
// silently shifted-by-one map.
74+
inline uint8_t bankBit(uint8_t bank) {
75+
if (bank == 0 || bank > 8) return 0;
76+
return (uint8_t)(1u << (bank - 1));
77+
}
78+
79+
// Is this bank's emitter rail allowed to light?
80+
inline bool enabled(const State& s, uint8_t bank) {
81+
const uint8_t bit = bankBit(bank);
82+
return bit != 0 && (s.mask & bit) != 0;
83+
}
84+
85+
// How many banks the mask turns on. Drives the reported active gate count and
86+
// the log line; also the cheapest way to say "this counter is running on a
87+
// third of its entrance".
88+
inline uint8_t enabledCount(const State& s) {
89+
uint8_t n = 0;
90+
for (uint8_t bit = 1; bit; bit = (uint8_t)(bit << 1)) {
91+
if (s.mask & bit) n++;
92+
}
93+
return n;
94+
}
95+
96+
// Apply a requested mask, honouring the three rules above.
97+
inline Request request(State& s, uint8_t requested) {
98+
Request r;
99+
const uint8_t sane = (uint8_t)(requested & beecounter_proto::BANK_MASK_ALL);
100+
if (sane == 0) {
101+
// Refused. Leave the counter counting on whatever it already had.
102+
r.granted = s.mask;
103+
r.accepted = false;
104+
r.changed = false;
105+
return r;
106+
}
107+
r.accepted = true;
108+
r.changed = sane != s.mask;
109+
s.mask = sane;
110+
r.granted = sane;
111+
return r;
112+
}
113+
114+
} // namespace bankstate

Firmware/include/ble_link.h

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ struct Telemetry {
3030
// anyone reading the stored history later — tell "no bees flew" from "this
3131
// counter was deliberately not looking", which the totals alone cannot say.
3232
uint32_t idle_s;
33+
// Enabled emitter banks, one bit per MOSFET (bit 0 = bank 1 = gates
34+
// 00..07, and so on). New in protocol v5, reported as "banks". A cleared
35+
// bit means those eight gates are dark and deliberately not counted, so
36+
// their share of the totals stays flat — which without this field is
37+
// indistinguishable from the FET having died. 0x07 on any counter that
38+
// has not been told otherwise.
39+
uint8_t bank_mask;
3340
};
3441

3542
void getTelemetry(Telemetry& out);
@@ -44,6 +51,17 @@ uint32_t applyIdleRequest(uint32_t duration_s);
4451
// Seconds of suspension left, for the control characteristic's read-back.
4552
uint32_t idleRemainingSeconds();
4653

54+
// Apply an emitter-bank enable mask written to the control characteristic.
55+
// Implemented in main.cpp alongside applyIdleRequest(), for the same reason:
56+
// the FET pins and the gate state machines live there. Returns the mask
57+
// actually in force afterwards, which is the unchanged one if the request was
58+
// refused (see bank_state.h — a mask of 0 is never applied).
59+
uint8_t applyBankMask(uint8_t mask);
60+
61+
// The enabled-bank mask currently in force, for the control characteristic's
62+
// read-back.
63+
uint8_t bankMask();
64+
4765
void begin();
4866
bool isOtaActive();
4967
void loopOta();

Firmware/include/counter_protocol.h

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,19 @@ namespace beecounter_proto {
4141
// says whether the counter is deliberately not sensing (night mode) and
4242
// for how much longer. Without it a night of zero crossings is
4343
// indistinguishable from a counter whose emitters have failed.
44+
// v5 = adds the "banks" field: the bitmask of emitter banks (MOSFETs) that are
45+
// currently enabled. Since the 2026-08 revision one FET feeds one
46+
// MCP23017, so a disabled bank means eight specific gates are dark and
47+
// not counted, and the totals for them are permanently flat. Without the
48+
// field that is indistinguishable from a dead FET — the same failure
49+
// idle_s was added to disambiguate, at a third of the counter each time.
4450
//
4551
// A counter in the field keeps emitting an older revision until it is updated
4652
// over the air, and the OTA relay has to read this very characteristic before
4753
// it can update anything — so HiveHub's parser reads "fw" first and accepts
4854
// every revision. Its tolerant parser must be deployed BEFORE any counter
4955
// emitting the new one.
50-
constexpr uint8_t PROTOCOL_VERSION = 4;
56+
constexpr uint8_t PROTOCOL_VERSION = 5;
5157

5258
// --------------------------------------------------------------------------
5359
// Status bitfield — reported as the JSON "status" field
@@ -115,6 +121,43 @@ constexpr uint8_t OTA_ERR_NONE = 0x00;
115121
// behaviour it had before this existed.
116122
constexpr uint8_t CTRL_OP_SET_IDLE = 0x01; // + duration_s (4 LE)
117123
constexpr uint8_t CTRL_OP_RESUME = 0x02; // no payload: sense again now
124+
constexpr uint8_t CTRL_OP_SET_BANKS = 0x03; // + bank bitmask (1 byte)
125+
126+
// --------------------------------------------------------------------------
127+
// Emitter bank enables — the second power control, and a very different one
128+
// --------------------------------------------------------------------------
129+
// Night mode answers "when should the whole counter stop?"; this answers "how
130+
// much of the counter should exist at all?". Since the 2026-08 hardware
131+
// revision there are three IRLB8721 MOSFETs, one per MCP23017, so an entrance
132+
// narrower than 24 gates — or a power budget that will not carry 24 — can run
133+
// with only the banks it needs:
134+
//
135+
// bank 1 (bit 0) -> U2, gates 00..07
136+
// bank 2 (bit 1) -> U3, gates 10..17
137+
// bank 3 (bit 2) -> U4, gates 20..27
138+
//
139+
// Measured on the 3.3 V rail, with the pulsed sampler at its defaults:
140+
// 1 bank / 8 gates ~0.14 A
141+
// 2 banks / 16 gates ~0.22 A
142+
// 3 banks / 24 gates ~0.30 A
143+
// i.e. roughly 80 mA per bank on top of a ~60 mA floor, which is why this is a
144+
// coarse but very effective knob: dropping one bank saves about as much as a
145+
// quarter of the night does.
146+
//
147+
// Unlike night mode this is a CONFIGURATION, not a deadline — there is nothing
148+
// for it to expire into. It is still not persisted, for the same reason night
149+
// mode is not: a counter that resets comes back with everything enabled and
150+
// counting, and HiveHub re-asserts the mask on its next upload cycle. The worst
151+
// case is one cycle of drawing more current than asked, never a counter that
152+
// boots blind on eight gates because of a write it received a month ago.
153+
//
154+
// A mask of 0 is REFUSED rather than applied. It is not a configuration anyone
155+
// needs — a counter that should count nothing is unpaired — and accepting it
156+
// would turn one malformed byte into a permanently blind counter, which is
157+
// exactly what every other decision in this file is arranged to prevent. Bits
158+
// above the highest bank are ignored, so a future four-FET board reading this
159+
// firmware's mask sees no phantom bank.
160+
constexpr uint8_t BANK_MASK_ALL = 0x07; // all three banks enabled (default)
118161

119162
// Longest suspension the counter will accept, whatever HiveHub asks for. One
120163
// hour is several times HiveHub's default 10-minute upload cycle — enough that
@@ -126,15 +169,23 @@ constexpr uint8_t CTRL_OP_RESUME = 0x02; // no payload: sense again now
126169
constexpr uint32_t MAX_IDLE_SECONDS = 3600;
127170

128171
// Control status, as read back from the control characteristic:
129-
// state(1) + remaining_s(4 LE)
130-
// state is one of the two below; remaining_s is 0 unless idle.
172+
// state(1) + remaining_s(4 LE) + bank_mask(1)
173+
// state is one of the two below; remaining_s is 0 unless idle; bank_mask is the
174+
// enabled-bank bitmask currently in force.
175+
//
176+
// The trailing byte is new in protocol v5 and is deliberately APPENDED: a
177+
// client that reads five bytes and stops — every HiveHub built against v4 —
178+
// still gets exactly the value it used to.
131179
constexpr uint8_t CTRL_STATE_SENSING = 0x00;
132180
constexpr uint8_t CTRL_STATE_IDLE = 0x01;
133181

134182
// Bytes in that read-back value.
135-
constexpr uint8_t CTRL_STATUS_LENGTH = 5;
183+
constexpr uint8_t CTRL_STATUS_LENGTH = 6;
136184

137185
// Bytes in a well-formed SET_IDLE write (opcode + uint32 LE).
138186
constexpr uint8_t CTRL_SET_IDLE_LENGTH = 5;
139187

188+
// Bytes in a well-formed SET_BANKS write (opcode + mask).
189+
constexpr uint8_t CTRL_SET_BANKS_LENGTH = 2;
190+
140191
} // namespace beecounter_proto

Firmware/include/measurement_json.h

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,20 @@ namespace beecounter_proto {
3535
//
3636
// {"fw":255,"ver":"<15>","uptime_s":4294967295,"status":255,"num_gates":255,
3737
// "mcps_healthy":255,"total_in":4294967295,"total_out":4294967295,
38-
// "glitches":4294967295,"idle_s":4294967295}
38+
// "glitches":4294967295,"idle_s":4294967295,"banks":255}
3939
//
40-
// is 191 bytes, plus the NUL — measured, not estimated, by
40+
// is 203 bytes, plus the NUL — measured, not estimated, by
4141
// test/test_measurement_json/ (which prints the number and fails if it grows
42-
// past the buffer). 224 leaves room for a version string longer than any
42+
// past the buffer). 240 leaves room for a version string longer than any
4343
// version.h has carried without another audit of this number; the truncation
4444
// check in buildMeasurementJson() is what actually guarantees a malformed
4545
// document is never published, so this is headroom, not a promise.
4646
//
4747
// (v2 fitted in ~155 bytes. Widening uptime_s and glitches to 32 bits and
4848
// renaming gates_healthy -> mcps_healthy added ~16 bytes; v4's idle_s added
49-
// ~21 more. The buffer has not had to grow for either.)
50-
constexpr unsigned MEASUREMENT_JSON_CAPACITY = 224;
49+
// ~21 more, both absorbed by the old 224-byte buffer. v5's "banks" added ~12
50+
// and is what finally moved it.)
51+
constexpr unsigned MEASUREMENT_JSON_CAPACITY = 240;
5152

5253
// Serialize `t` plus the image version string into `out`.
5354
//
@@ -68,7 +69,8 @@ inline int buildMeasurementJson(char* out, unsigned capacity,
6869
out, capacity,
6970
"{\"fw\":%u,\"ver\":\"%s\",\"uptime_s\":%lu,\"status\":%u,"
7071
"\"num_gates\":%u,\"mcps_healthy\":%u,\"total_in\":%lu,"
71-
"\"total_out\":%lu,\"glitches\":%lu,\"idle_s\":%lu}",
72+
"\"total_out\":%lu,\"glitches\":%lu,\"idle_s\":%lu,"
73+
"\"banks\":%u}",
7274
static_cast<unsigned>(t.protocol_version),
7375
fw_version ? fw_version : "",
7476
static_cast<unsigned long>(t.uptime_s),
@@ -78,7 +80,8 @@ inline int buildMeasurementJson(char* out, unsigned capacity,
7880
static_cast<unsigned long>(t.total_in),
7981
static_cast<unsigned long>(t.total_out),
8082
static_cast<unsigned long>(t.glitch_count),
81-
static_cast<unsigned long>(t.idle_s));
83+
static_cast<unsigned long>(t.idle_s),
84+
static_cast<unsigned>(t.bank_mask));
8285
if (length <= 0 || static_cast<unsigned>(length) >= capacity) return -1;
8386
return length;
8487
}

Firmware/include/version.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@
1414
// and it is the only way to confirm afterwards that an update actually took.
1515
//
1616
// Bump this on every released image.
17-
#define HIVETRAFFIC_FW_VERSION "0.2.0"
17+
#define HIVETRAFFIC_FW_VERSION "0.3.0"

Firmware/src/ble_link.cpp

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,28 @@ class ControlCallbacks : public NimBLECharacteristicCallbacks {
291291
applyIdleRequest(0);
292292
Serial.println(F("[BLE-CTRL] sensing resumed"));
293293
break;
294+
case CTRL_OP_SET_BANKS: {
295+
if (value.size() != CTRL_SET_BANKS_LENGTH) {
296+
Serial.printf("[BLE-CTRL] SET_BANKS ignored: %u bytes, expected %u\n",
297+
(unsigned)value.size(),
298+
(unsigned)CTRL_SET_BANKS_LENGTH);
299+
return;
300+
}
301+
// Deliberately NOT refused during an OTA, where SET_IDLE is. A
302+
// suspension armed under a transfer would outlive the reboot it
303+
// cannot survive; a bank mask is a configuration HiveHub re-asserts
304+
// every cycle regardless, and refusing it here would only delay it
305+
// by one. The emitters are dark for the transfer either way.
306+
const uint8_t granted = applyBankMask(data[1]);
307+
if (granted != data[1]) {
308+
Serial.printf("[BLE-CTRL] banks 0x%02X requested, 0x%02X in force\n",
309+
(unsigned)data[1], (unsigned)granted);
310+
} else {
311+
Serial.printf("[BLE-CTRL] emitter banks set to 0x%02X\n",
312+
(unsigned)granted);
313+
}
314+
break;
315+
}
294316
default:
295317
Serial.printf("[BLE-CTRL] unknown opcode 0x%02X ignored\n",
296318
(unsigned)data[0]);
@@ -308,6 +330,9 @@ class ControlCallbacks : public NimBLECharacteristicCallbacks {
308330
value[2] = static_cast<uint8_t>(remaining >> 8);
309331
value[3] = static_cast<uint8_t>(remaining >> 16);
310332
value[4] = static_cast<uint8_t>(remaining >> 24);
333+
// Appended in v5; a client that reads the first five bytes and stops
334+
// sees exactly the value it saw before this byte existed.
335+
value[5] = bankMask();
311336
characteristic->setValue(value, sizeof(value));
312337
}
313338
};

0 commit comments

Comments
 (0)