diff --git a/Firmware/README.md b/Firmware/README.md index 043ae2f..4f3946f 100644 --- a/Firmware/README.md +++ b/Firmware/README.md @@ -61,6 +61,10 @@ The previous 2-FET build split U3's gates across banks 1 and 2 (00..13 / 14..27) 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. +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. + +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. + ### Counting Each gate is a small state machine: @@ -180,10 +184,18 @@ Press a single key in the serial monitor: | `0` | Force IR LEDs OFF | | `a` | IR LEDs AUTO (normal pulsed mode) | | `n` | Arm / clear a 60 s night-mode suspension (press again to resume) | +| `4` | Toggle emitter bank 1 (GATE_00..07) | +| `5` | Toggle emitter bank 2 (GATE_10..17) | +| `6` | Toggle emitter bank 3 (GATE_20..27) | | `h` | Show the command list | -Each reading lists the raw MCP23017 port words plus a per-gate `BLOCK`/`clear` -line for the inner and outer sensor. The emitters are pulsed on for every read +The bank keys are `4`/`5`/`6` because the schematic labels those rails `/GPIO4`, +`/GPIO5` and `/GPIO6` — misleading net names (they are physically GPIO19/20/18) +but the ones silkscreened next to the FETs. + +Each reading lists the raw MCP23017 port words, the current bank mask, plus a +per-gate `BLOCK`/`clear` line for the inner and outer sensor. A gate whose bank +is switched off prints `` rather than a beam state. The emitters are pulsed on for every read regardless of the LED mode, so the readout is always valid. Wave a finger or a bee through a gate and you should see that gate's `inner`/`outer` flip to `BLOCK`. @@ -282,6 +294,13 @@ No ESP32, no MCP23017 and no bee required. well-formed documents full of zeros, which is indistinguishable from a spell of bad weather until someone reads a week of totals — so none of this is something a bench session would catch. +* **`include/bank_state.h`** — the emitter-bank enable mask. Every mistake it + can make is silent: a bank that should be on but is off produces a + permanently flat third of the totals, which reads exactly like a dead FET, + and a bank that should be off but is on quietly costs ~80 mA on a supply + sized without it. The suite pins the refusal of an all-off mask, the + one-based bank numbering that `gates::TABLE[].led_bank` depends on, and the + masking of bits above the last physical bank. Everything hardware-facing stays in `src/main.cpp` and is still verified on the bench with the IR-sensor console above. diff --git a/Firmware/include/bank_state.h b/Firmware/include/bank_state.h new file mode 100644 index 0000000..d1d4fc6 --- /dev/null +++ b/Firmware/include/bank_state.h @@ -0,0 +1,114 @@ +// ============================================================================ +// bank_state.h — which emitter banks (MOSFETs) are enabled +// ============================================================================ +// +// Arduino-free, like idle_state.h and gate_logic.h, and for the same reason: +// the interesting part of "run only some of the counter" is a handful of +// bitmask rules that decide whether eight gates get counted at all. Getting one +// wrong is silent — the totals for those gates simply stay flat, which looks +// exactly like a dead FET — so the rules are pinned by +// test/test_bank_state/ on a host compiler. src/main.cpp owns the GPIO and the +// poll loop and calls in here for the verdict. +// +// The model +// --------- +// Since the 2026-08 hardware revision there is one IRLB8721 per MCP23017, so a +// bank IS a chip is eight gates: +// +// bank 1 (bit 0) -> U2 @ 0x20, gates 00..07 +// bank 2 (bit 1) -> U3 @ 0x21, gates 10..17 +// bank 3 (bit 2) -> U4 @ 0x22, gates 20..27 +// +// HiveHub writes a mask; this header decides what is actually applied. Three +// rules, each of which exists because the alternative fails quietly: +// +// 1. **Bits above the last bank are ignored.** A four-bank board's mask +// arriving at a three-bank counter must not conjure a bank 4 whose GPIO +// does not exist. +// 2. **A mask of 0 is refused, not applied.** Every other decision in this +// firmware is arranged so that a bad write costs a cycle rather than a +// deployment; accepting 0 would let one corrupted byte blind a counter +// until someone walks to the hive. A counter that should count nothing is +// unpaired in HiveHub, not masked to zero here. +// 3. **It is not persisted.** A reset comes back with every bank enabled and +// counting, and HiveHub re-asserts the mask on its next upload cycle. The +// worst case is one cycle of drawing more current than asked for, which is +// the same failure direction night mode chose. +// +// Why not just leave the FET off and read the chip anyway +// ------------------------------------------------------ +// That is exactly what this does — the chip is still read and still health- +// checked, so `mcps_healthy` keeps meaning what it has always meant. What the +// caller must additionally do is SKIP the gates on a dark bank, which is not +// paranoia: an unlit QRE1113 is not a sensor that reads "clear", it is a bare +// phototransistor under a 100k pull-up, and direct sun through a hive entrance +// is quite capable of pulling one low. Counting those would invent crossings on +// gates the operator deliberately switched off. +// ============================================================================ + +#pragma once + +#include + +#include "counter_protocol.h" + +namespace bankstate { + +// Enabled-bank bitmask. Default-constructed is "everything on", which is what a +// freshly booted counter must always be. +struct State { + uint8_t mask = beecounter_proto::BANK_MASK_ALL; +}; + +// Result of a SET_BANKS request, so the caller can log what it actually did +// rather than what it was asked to do. +struct Request { + uint8_t granted = beecounter_proto::BANK_MASK_ALL; + bool accepted = false; // false: refused, `granted` is the unchanged mask + bool changed = false; // did the applied mask actually move? +}; + +// Bit for bank number 1..NUM banks. Bank 0 does not exist and yields 0, so a +// caller that mixes up 0- and 1-based numbering gets "no bank" rather than a +// silently shifted-by-one map. +inline uint8_t bankBit(uint8_t bank) { + if (bank == 0 || bank > 8) return 0; + return (uint8_t)(1u << (bank - 1)); +} + +// Is this bank's emitter rail allowed to light? +inline bool enabled(const State& s, uint8_t bank) { + const uint8_t bit = bankBit(bank); + return bit != 0 && (s.mask & bit) != 0; +} + +// How many banks the mask turns on. Drives the reported active gate count and +// the log line; also the cheapest way to say "this counter is running on a +// third of its entrance". +inline uint8_t enabledCount(const State& s) { + uint8_t n = 0; + for (uint8_t bit = 1; bit; bit = (uint8_t)(bit << 1)) { + if (s.mask & bit) n++; + } + return n; +} + +// Apply a requested mask, honouring the three rules above. +inline Request request(State& s, uint8_t requested) { + Request r; + const uint8_t sane = (uint8_t)(requested & beecounter_proto::BANK_MASK_ALL); + if (sane == 0) { + // Refused. Leave the counter counting on whatever it already had. + r.granted = s.mask; + r.accepted = false; + r.changed = false; + return r; + } + r.accepted = true; + r.changed = sane != s.mask; + s.mask = sane; + r.granted = sane; + return r; +} + +} // namespace bankstate diff --git a/Firmware/include/ble_link.h b/Firmware/include/ble_link.h index b0f147f..05f02f1 100644 --- a/Firmware/include/ble_link.h +++ b/Firmware/include/ble_link.h @@ -30,6 +30,13 @@ struct Telemetry { // anyone reading the stored history later — tell "no bees flew" from "this // counter was deliberately not looking", which the totals alone cannot say. uint32_t idle_s; + // Enabled emitter banks, one bit per MOSFET (bit 0 = bank 1 = gates + // 00..07, and so on). New in protocol v5, reported as "banks". A cleared + // bit means those eight gates are dark and deliberately not counted, so + // their share of the totals stays flat — which without this field is + // indistinguishable from the FET having died. 0x07 on any counter that + // has not been told otherwise. + uint8_t bank_mask; }; void getTelemetry(Telemetry& out); @@ -44,6 +51,17 @@ uint32_t applyIdleRequest(uint32_t duration_s); // Seconds of suspension left, for the control characteristic's read-back. uint32_t idleRemainingSeconds(); +// Apply an emitter-bank enable mask written to the control characteristic. +// Implemented in main.cpp alongside applyIdleRequest(), for the same reason: +// the FET pins and the gate state machines live there. Returns the mask +// actually in force afterwards, which is the unchanged one if the request was +// refused (see bank_state.h — a mask of 0 is never applied). +uint8_t applyBankMask(uint8_t mask); + +// The enabled-bank mask currently in force, for the control characteristic's +// read-back. +uint8_t bankMask(); + void begin(); bool isOtaActive(); void loopOta(); diff --git a/Firmware/include/counter_protocol.h b/Firmware/include/counter_protocol.h index 4072fb8..a5f0e22 100644 --- a/Firmware/include/counter_protocol.h +++ b/Firmware/include/counter_protocol.h @@ -41,13 +41,19 @@ namespace beecounter_proto { // says whether the counter is deliberately not sensing (night mode) and // for how much longer. Without it a night of zero crossings is // indistinguishable from a counter whose emitters have failed. +// v5 = adds the "banks" field: the bitmask of emitter banks (MOSFETs) that are +// currently enabled. Since the 2026-08 revision one FET feeds one +// MCP23017, so a disabled bank means eight specific gates are dark and +// not counted, and the totals for them are permanently flat. Without the +// field that is indistinguishable from a dead FET — the same failure +// idle_s was added to disambiguate, at a third of the counter each time. // // A counter in the field keeps emitting an older revision until it is updated // over the air, and the OTA relay has to read this very characteristic before // it can update anything — so HiveHub's parser reads "fw" first and accepts // every revision. Its tolerant parser must be deployed BEFORE any counter // emitting the new one. -constexpr uint8_t PROTOCOL_VERSION = 4; +constexpr uint8_t PROTOCOL_VERSION = 5; // -------------------------------------------------------------------------- // Status bitfield — reported as the JSON "status" field @@ -115,6 +121,43 @@ constexpr uint8_t OTA_ERR_NONE = 0x00; // behaviour it had before this existed. constexpr uint8_t CTRL_OP_SET_IDLE = 0x01; // + duration_s (4 LE) constexpr uint8_t CTRL_OP_RESUME = 0x02; // no payload: sense again now +constexpr uint8_t CTRL_OP_SET_BANKS = 0x03; // + bank bitmask (1 byte) + +// -------------------------------------------------------------------------- +// Emitter bank enables — the second power control, and a very different one +// -------------------------------------------------------------------------- +// Night mode answers "when should the whole counter stop?"; this answers "how +// much of the counter should exist at all?". Since the 2026-08 hardware +// revision there are three IRLB8721 MOSFETs, one per MCP23017, so an entrance +// narrower than 24 gates — or a power budget that will not carry 24 — can run +// with only the banks it needs: +// +// bank 1 (bit 0) -> U2, gates 00..07 +// bank 2 (bit 1) -> U3, gates 10..17 +// bank 3 (bit 2) -> U4, gates 20..27 +// +// Measured on the 3.3 V rail, with the pulsed sampler at its defaults: +// 1 bank / 8 gates ~0.14 A +// 2 banks / 16 gates ~0.22 A +// 3 banks / 24 gates ~0.30 A +// i.e. roughly 80 mA per bank on top of a ~60 mA floor, which is why this is a +// coarse but very effective knob: dropping one bank saves about as much as a +// quarter of the night does. +// +// Unlike night mode this is a CONFIGURATION, not a deadline — there is nothing +// for it to expire into. It is still not persisted, for the same reason night +// mode is not: a counter that resets comes back with everything enabled and +// counting, and HiveHub re-asserts the mask on its next upload cycle. The worst +// case is one cycle of drawing more current than asked, never a counter that +// boots blind on eight gates because of a write it received a month ago. +// +// A mask of 0 is REFUSED rather than applied. It is not a configuration anyone +// needs — a counter that should count nothing is unpaired — and accepting it +// would turn one malformed byte into a permanently blind counter, which is +// exactly what every other decision in this file is arranged to prevent. Bits +// above the highest bank are ignored, so a future four-FET board reading this +// firmware's mask sees no phantom bank. +constexpr uint8_t BANK_MASK_ALL = 0x07; // all three banks enabled (default) // Longest suspension the counter will accept, whatever HiveHub asks for. One // 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 constexpr uint32_t MAX_IDLE_SECONDS = 3600; // Control status, as read back from the control characteristic: -// state(1) + remaining_s(4 LE) -// state is one of the two below; remaining_s is 0 unless idle. +// state(1) + remaining_s(4 LE) + bank_mask(1) +// state is one of the two below; remaining_s is 0 unless idle; bank_mask is the +// enabled-bank bitmask currently in force. +// +// The trailing byte is new in protocol v5 and is deliberately APPENDED: a +// client that reads five bytes and stops — every HiveHub built against v4 — +// still gets exactly the value it used to. constexpr uint8_t CTRL_STATE_SENSING = 0x00; constexpr uint8_t CTRL_STATE_IDLE = 0x01; // Bytes in that read-back value. -constexpr uint8_t CTRL_STATUS_LENGTH = 5; +constexpr uint8_t CTRL_STATUS_LENGTH = 6; // Bytes in a well-formed SET_IDLE write (opcode + uint32 LE). constexpr uint8_t CTRL_SET_IDLE_LENGTH = 5; +// Bytes in a well-formed SET_BANKS write (opcode + mask). +constexpr uint8_t CTRL_SET_BANKS_LENGTH = 2; + } // namespace beecounter_proto diff --git a/Firmware/include/measurement_json.h b/Firmware/include/measurement_json.h index 9acf362..feb2cb7 100644 --- a/Firmware/include/measurement_json.h +++ b/Firmware/include/measurement_json.h @@ -35,19 +35,20 @@ namespace beecounter_proto { // // {"fw":255,"ver":"<15>","uptime_s":4294967295,"status":255,"num_gates":255, // "mcps_healthy":255,"total_in":4294967295,"total_out":4294967295, -// "glitches":4294967295,"idle_s":4294967295} +// "glitches":4294967295,"idle_s":4294967295,"banks":255} // -// is 191 bytes, plus the NUL — measured, not estimated, by +// is 203 bytes, plus the NUL — measured, not estimated, by // test/test_measurement_json/ (which prints the number and fails if it grows -// past the buffer). 224 leaves room for a version string longer than any +// past the buffer). 240 leaves room for a version string longer than any // version.h has carried without another audit of this number; the truncation // check in buildMeasurementJson() is what actually guarantees a malformed // document is never published, so this is headroom, not a promise. // // (v2 fitted in ~155 bytes. Widening uptime_s and glitches to 32 bits and // renaming gates_healthy -> mcps_healthy added ~16 bytes; v4's idle_s added -// ~21 more. The buffer has not had to grow for either.) -constexpr unsigned MEASUREMENT_JSON_CAPACITY = 224; +// ~21 more, both absorbed by the old 224-byte buffer. v5's "banks" added ~12 +// and is what finally moved it.) +constexpr unsigned MEASUREMENT_JSON_CAPACITY = 240; // Serialize `t` plus the image version string into `out`. // @@ -68,7 +69,8 @@ inline int buildMeasurementJson(char* out, unsigned capacity, out, capacity, "{\"fw\":%u,\"ver\":\"%s\",\"uptime_s\":%lu,\"status\":%u," "\"num_gates\":%u,\"mcps_healthy\":%u,\"total_in\":%lu," - "\"total_out\":%lu,\"glitches\":%lu,\"idle_s\":%lu}", + "\"total_out\":%lu,\"glitches\":%lu,\"idle_s\":%lu," + "\"banks\":%u}", static_cast(t.protocol_version), fw_version ? fw_version : "", static_cast(t.uptime_s), @@ -78,7 +80,8 @@ inline int buildMeasurementJson(char* out, unsigned capacity, static_cast(t.total_in), static_cast(t.total_out), static_cast(t.glitch_count), - static_cast(t.idle_s)); + static_cast(t.idle_s), + static_cast(t.bank_mask)); if (length <= 0 || static_cast(length) >= capacity) return -1; return length; } diff --git a/Firmware/include/version.h b/Firmware/include/version.h index 66115fe..06ca43f 100644 --- a/Firmware/include/version.h +++ b/Firmware/include/version.h @@ -14,4 +14,4 @@ // and it is the only way to confirm afterwards that an update actually took. // // Bump this on every released image. -#define HIVETRAFFIC_FW_VERSION "0.2.0" +#define HIVETRAFFIC_FW_VERSION "0.3.0" diff --git a/Firmware/src/ble_link.cpp b/Firmware/src/ble_link.cpp index 969c462..4c8d81e 100644 --- a/Firmware/src/ble_link.cpp +++ b/Firmware/src/ble_link.cpp @@ -291,6 +291,28 @@ class ControlCallbacks : public NimBLECharacteristicCallbacks { applyIdleRequest(0); Serial.println(F("[BLE-CTRL] sensing resumed")); break; + case CTRL_OP_SET_BANKS: { + if (value.size() != CTRL_SET_BANKS_LENGTH) { + Serial.printf("[BLE-CTRL] SET_BANKS ignored: %u bytes, expected %u\n", + (unsigned)value.size(), + (unsigned)CTRL_SET_BANKS_LENGTH); + return; + } + // Deliberately NOT refused during an OTA, where SET_IDLE is. A + // suspension armed under a transfer would outlive the reboot it + // cannot survive; a bank mask is a configuration HiveHub re-asserts + // every cycle regardless, and refusing it here would only delay it + // by one. The emitters are dark for the transfer either way. + const uint8_t granted = applyBankMask(data[1]); + if (granted != data[1]) { + Serial.printf("[BLE-CTRL] banks 0x%02X requested, 0x%02X in force\n", + (unsigned)data[1], (unsigned)granted); + } else { + Serial.printf("[BLE-CTRL] emitter banks set to 0x%02X\n", + (unsigned)granted); + } + break; + } default: Serial.printf("[BLE-CTRL] unknown opcode 0x%02X ignored\n", (unsigned)data[0]); @@ -308,6 +330,9 @@ class ControlCallbacks : public NimBLECharacteristicCallbacks { value[2] = static_cast(remaining >> 8); value[3] = static_cast(remaining >> 16); value[4] = static_cast(remaining >> 24); + // Appended in v5; a client that reads the first five bytes and stops + // sees exactly the value it saw before this byte existed. + value[5] = bankMask(); characteristic->setValue(value, sizeof(value)); } }; diff --git a/Firmware/src/main.cpp b/Firmware/src/main.cpp index ea5c7fc..6c9b70a 100644 --- a/Firmware/src/main.cpp +++ b/Firmware/src/main.cpp @@ -29,6 +29,28 @@ // 5. Suspends 1-3 on request (night mode), so the emitters — which dominate // this board's power draw by an order of magnitude — are dark through the // hours European honey bees do not fly. See the section below. +// 6. Runs on a subset of its three emitter banks on request, so an entrance +// narrower than 24 gates — or a supply that will not carry 24 — costs only +// the banks it uses. See the section below. +// +// Emitter bank enables (protocol v5) +// ---------------------------------- +// The 2026-08 revision put one IRLB8721 behind each MCP23017, which makes each +// bank independently switchable: bank 1 = U2 = gates 00..07, bank 2 = U3 = +// 10..17, bank 3 = U4 = 20..27. Measured on the 3.3 V rail with the pulsed +// sampler at its defaults, one bank draws ~0.14 A, two ~0.22 A and three +// ~0.30 A, so dropping a bank is worth roughly 80 mA continuously — about what +// a quarter of a night of night mode saves, except it applies all day. +// +// This is a configuration rather than a deadline, so unlike night mode it has +// no expiry; like night mode it is deliberately NOT persisted, and HiveHub +// re-asserts it every upload cycle. A reset therefore comes back counting on +// all 24 gates. The mask arithmetic lives in include/bank_state.h. +// +// The gates of a dark bank are SKIPPED, not merely unlit. An unpowered QRE1113 +// is a bare phototransistor under a 100k pull-up, and direct sun into a hive +// entrance can pull one low; feeding those readings to the state machine would +// invent crossings on gates the operator switched off. // // Night mode (protocol v4) // ------------------------ @@ -70,6 +92,7 @@ #include "counter_protocol.h" #include "gate_logic.h" #include "idle_state.h" +#include "bank_state.h" // The BLE/GATT transport: a connectable NimBLE GATT server serving the // measurement characteristic and the OTA characteristics. See ble_link.h. @@ -207,6 +230,13 @@ static volatile uint8_t g_status_flags = 0; // ble::applyIdleRequest() below. Never persisted: a reset resumes counting. static idlestate::State g_idle; +// Which emitter banks are allowed to light. Owned here for the same reason +// g_idle is — this file drives the FET gates — and armed from the BLE control +// characteristic through ble::applyBankMask() below. Never persisted: a reset +// comes back with all three banks counting, and HiveHub re-asserts the mask on +// its next upload cycle. See bank_state.h. +static bankstate::State g_banks; + // ============================================================================ // MCP23017 channels + runtime health (all on Wire / bus 0) // ============================================================================ @@ -293,8 +323,14 @@ static volatile LedMode g_led_mode = LedMode::AUTO; // can momentarily turn the LEDs on/off within AUTO mode without fighting the // mode gate. static void driveIrLeds(bool on) { + // A disabled bank's FET gate is held LOW whatever `on` says: this is the + // one place that decides whether an emitter rail is ever energised, so + // enforcing the mask here means no other path — the pulsed sampler, the + // FORCE_ON debug mode, the night-mode backstop — can light a bank the + // operator switched off. for (uint8_t b = 0; b < pins::NUM_LED_BANKS; b++) { - digitalWrite(pins::IR_LED_BANK_EN[b], on ? HIGH : LOW); + const bool live = on && bankstate::enabled(g_banks, (uint8_t)(b + 1)); + digitalWrite(pins::IR_LED_BANK_EN[b], live ? HIGH : LOW); } if (on) g_status_flags |= beecounter_proto::STATUS_IR_LEDS_ON; else g_status_flags &= ~beecounter_proto::STATUS_IR_LEDS_ON; @@ -345,6 +381,19 @@ static void resetGatesForMcp(uint8_t addr) { } } +// Drop every gate on one emitter bank back to IDLE. Called when a bank is +// switched off (its gates stop being sampled, so a half-finished pairing would +// otherwise sit there indefinitely) and when one is switched back on (the +// pairing predates however long the bank was dark). Same reasoning as +// resetGatesForMcp() across a chip outage; the lifetime totals are untouched. +static void resetGatesForBank(uint8_t bank) { + for (uint8_t i = 0; i < gates::NUM_GATES; i++) { + if (gates::TABLE[i].led_bank == bank) { + g_gate_rt[i] = gatelogic::GateRuntime(); + } + } +} + // Drop every gate back to IDLE. Used on both edges of a night-mode suspension: // a gate that was half-way through a pairing when sensing stopped must not // combine that stale half with the first sample taken hours later and fabricate @@ -518,6 +567,12 @@ static bool pollAllGates() { for (uint8_t i = 0; i < gates::NUM_GATES; i++) { const auto& loc = gates::TABLE[i]; + // A gate on a disabled bank is skipped, not read as "clear": its + // emitters are dark, so the phototransistor is only reporting ambient + // light, and sun into the entrance would fabricate crossings on gates + // the operator deliberately switched off. The chip itself is still read + // above, so mcps_healthy keeps meaning "expanders answering". + if (!bankstate::enabled(g_banks, loc.led_bank)) continue; const int8_t ch = mcpIndexForAddress(loc.mcp_address); if (ch < 0 || !g_mcp[ch].valid) continue; // no trustworthy sample const uint16_t v = g_mcp[ch].value; @@ -569,7 +624,15 @@ static bool pollAllGates() { // 0 force IR LEDs OFF // a IR LEDs AUTO (normal pulsed mode) // n arm a 60 s night-mode suspension (press again to resume) +// 4 toggle emitter bank 1 (gates 00..07) +// 5 toggle emitter bank 2 (gates 10..17) +// 6 toggle emitter bank 3 (gates 20..27) // h print the command list +// +// The bank keys are 4/5/6 because the schematic calls those rails /GPIO4, +// /GPIO5 and /GPIO6 — misleading net names (they are physically GPIO19/20/18, +// see pins.h) but the labels silkscreened next to the FETs, which is what +// someone with a probe in one hand is actually reading. // ============================================================================ #ifdef IR_DEBUG @@ -587,6 +650,9 @@ static void irDebugPrintHelp() { Serial.println(F(" 0 force IR LEDs OFF")); Serial.println(F(" a IR LEDs AUTO (pulsed, normal mode)")); Serial.println(F(" n arm/clear a 60 s night-mode suspension")); + Serial.println(F(" 4 toggle emitter bank 1 (gates 00..07)")); + Serial.println(F(" 5 toggle emitter bank 2 (gates 10..17)")); + Serial.println(F(" 6 toggle emitter bank 3 (gates 20..27)")); Serial.println(F(" h show this help")); Serial.println(); } @@ -604,9 +670,10 @@ static void irDebugReadAndPrint() { auto getBit = [](uint16_t v, uint8_t pin) -> bool { return (v >> pin) & 0x1; }; - Serial.printf("[IR] t=%lus raw U2=0x%04X U3=0x%04X U4=0x%04X read_ok=%d\n", + Serial.printf("[IR] t=%lus raw U2=0x%04X U3=0x%04X U4=0x%04X read_ok=%d " + "banks=0x%02X\n", (unsigned long)(now_ms / 1000), g_mcp[0].value, g_mcp[1].value, - g_mcp[2].value, ok ? 1 : 0); + g_mcp[2].value, ok ? 1 : 0, (unsigned)g_banks.mask); for (uint8_t i = 0; i < gates::NUM_GATES; i++) { const auto& loc = gates::TABLE[i]; const int8_t ch = mcpIndexForAddress(loc.mcp_address); @@ -619,6 +686,15 @@ static void irDebugReadAndPrint() { continue; } const uint16_t v = g_mcp[ch].value; + if (!bankstate::enabled(g_banks, loc.led_bank)) { + // The chip answered, but this gate's emitters are switched off, so + // whatever the phototransistor says is ambient light and not a + // beam state. Saying "clear" here would be the same lie as saying + // it for a chip that failed to read. + Serial.printf(" %-8s bank:%u \n", loc.tag, + (unsigned)loc.led_bank); + continue; + } // BLOCKED == beam reflected/interrupted == sensor line LOW (bit 0). bool inner_blocked = !getBit(v, loc.inner_pin); bool outer_blocked = !getBit(v, loc.outer_pin); @@ -697,6 +773,22 @@ static void irDebugPoll() { (unsigned long)granted); break; } + case '4': + case '5': + case '6': { + // Toggle one bank. A refused request (the last bank going off) + // reports itself from applyBankMask(), so nothing extra is needed + // here to explain why the mask did not move. + const uint8_t bank = (uint8_t)(c - '3'); // '4' -> 1 + const uint8_t bit = bankstate::bankBit(bank); + const uint8_t want = (uint8_t)(g_banks.mask ^ bit); + const uint8_t got = ble::applyBankMask(want); + Serial.printf("[IR-DEBUG] bank %u %s — mask 0x%02X\n", + (unsigned)bank, + (got & bit) ? "ENABLED" : "disabled", + (unsigned)got); + break; + } case 'h': case '?': irDebugPrintHelp(); @@ -759,6 +851,11 @@ void getTelemetry(Telemetry& t) { t.total_out = g_total_out; t.glitch_count = g_glitch_count; t.idle_s = idle_left; + // Reported unconditionally, including the 0x07 of a counter nobody has + // reconfigured. A consumer that only saw the field when it was interesting + // could not tell "all banks on" from "counter too old to say", and those + // are opposite readings of the same flat totals. + t.bank_mask = g_banks.mask; } uint32_t applyIdleRequest(uint32_t duration_s) { @@ -792,6 +889,39 @@ uint32_t idleRemainingSeconds() { return idlestate::remainingSeconds(g_idle, millis()); } +uint8_t applyBankMask(uint8_t mask) { + const bankstate::Request r = bankstate::request(g_banks, mask); + if (!r.accepted) { + // bank_state.h refuses an all-off mask rather than blinding the + // counter on one byte. Say so: the alternative is a HiveHub that + // believes it switched everything off and a counter that did not. + Serial.printf("[BANKS] request 0x%02X refused; still 0x%02X\n", + (unsigned)mask, (unsigned)g_banks.mask); + return g_banks.mask; + } + if (r.changed) { + // Both edges matter. A bank going dark leaves any half-finished + // pairing on its gates unfinishable; a bank coming back would otherwise + // combine a pairing from before the gap with a sample from after it. + for (uint8_t b = 1; b <= pins::NUM_LED_BANKS; b++) { + resetGatesForBank(b); + } + // Park the FETs on the new mask immediately rather than waiting for the + // next poll — drawing the current is the whole thing being switched off. + driveIrLeds(false); + Serial.printf("[BANKS] mask 0x%02X — %u of %u banks, %u gates active\n", + (unsigned)r.granted, + (unsigned)bankstate::enabledCount(g_banks), + (unsigned)pins::NUM_LED_BANKS, + (unsigned)(bankstate::enabledCount(g_banks) * 8u)); + } + return r.granted; +} + +uint8_t bankMask() { + return g_banks.mask; +} + } // namespace ble @@ -847,6 +977,11 @@ void setup() { g_status_flags |= beecounter_proto::STATUS_READY; + Serial.printf("[SETUP] emitter banks 0x%02X (%u of %u, %u gates active)\n", + (unsigned)g_banks.mask, + (unsigned)bankstate::enabledCount(g_banks), + (unsigned)pins::NUM_LED_BANKS, + (unsigned)(bankstate::enabledCount(g_banks) * 8u)); Serial.println("[SETUP] Entering normal counting loop (pulsed IR)"); #ifdef IR_DEBUG @@ -929,13 +1064,14 @@ void loop() { last_dump_ms = now; Serial.printf( "[STAT] uptime=%lus total_in=%lu total_out=%lu " - "glitches=%lu status=0x%02X idle=%lus\n", + "glitches=%lu status=0x%02X idle=%lus banks=0x%02X\n", (unsigned long)(now / 1000), (unsigned long)g_total_in, (unsigned long)g_total_out, (unsigned long)g_glitch_count, (unsigned)g_status_flags, - (unsigned long)idlestate::remainingSeconds(g_idle, now) + (unsigned long)idlestate::remainingSeconds(g_idle, now), + (unsigned)g_banks.mask ); } } diff --git a/Firmware/test/run_tests.sh b/Firmware/test/run_tests.sh index 43e52fd..bab5260 100755 --- a/Firmware/test/run_tests.sh +++ b/Firmware/test/run_tests.sh @@ -11,7 +11,9 @@ # wire contract; # * include/idle_state.h — the night-mode suspension deadline, including # the millis() rollover and the "HiveHub stopped -# re-arming" case. +# re-arming" case; +# * include/bank_state.h — the emitter-bank enable mask, whose mistakes +# are eight gates that silently stop counting. # # Everything hardware-facing stays in src/main.cpp and is still verified on the # bench (see the IR_DEBUG console in the README). @@ -47,3 +49,10 @@ trap 'rm -rf "$OUT"' EXIT -o "$OUT/test_idle_state" "$OUT/test_idle_state" + +"$CXX" -std=c++11 -Wall -Wextra -Werror \ + -I include \ + test/test_bank_state/test_bank_state.cpp \ + -o "$OUT/test_bank_state" + +"$OUT/test_bank_state" diff --git a/Firmware/test/test_bank_state/test_bank_state.cpp b/Firmware/test/test_bank_state/test_bank_state.cpp new file mode 100644 index 0000000..bcce7c6 --- /dev/null +++ b/Firmware/test/test_bank_state/test_bank_state.cpp @@ -0,0 +1,165 @@ +// ============================================================================ +// Host-side tests for include/bank_state.h +// ============================================================================ +// +// The emitter-bank mask decides whether eight gates are counted at all, and +// gets it wrong silently in both directions: a bank that should be on but is +// off produces a permanently flat third of the totals, which reads as a dead +// FET, and a bank that should be off but is on just quietly costs ~80 mA on a +// supply that was sized without it. Neither shows up in a code read and neither +// is fun to reproduce on a hive, so the rules are pinned here. +// +// c++ -std=c++11 -I include +// test/test_bank_state/test_bank_state.cpp -o /tmp/t && /tmp/t +// +// or via test/run_tests.sh, which builds every host test. +// ============================================================================ + +#include "bank_state.h" + +#include +#include + +using namespace beecounter_proto; + +static int g_failures = 0; +static const char* g_case = ""; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf(" FAIL %s:%d [%s] %s\n", __FILE__, __LINE__, \ + g_case, #cond); \ + ++g_failures; \ + } \ + } while (0) + +static void test_default_is_everything_on() { + // A counter that boots, resets after a brownout, or reboots out of an OTA + // must come back counting all 24 gates. Nothing about this feature is + // persisted, so this default IS the post-reset behaviour. + g_case = "a fresh counter runs every bank"; + bankstate::State s; + CHECK(s.mask == BANK_MASK_ALL); + CHECK(bankstate::enabledCount(s) == 3); + CHECK(bankstate::enabled(s, 1)); + CHECK(bankstate::enabled(s, 2)); + CHECK(bankstate::enabled(s, 3)); +} + +static void test_bank_numbering_is_one_based() { + // gates::GateLocation::led_bank is 1..3; a 0-based read of it would map + // every gate one bank to the left and light the wrong eight. + g_case = "bank numbers are 1..3, and 0 is not a bank"; + CHECK(bankstate::bankBit(1) == 0x01); + CHECK(bankstate::bankBit(2) == 0x02); + CHECK(bankstate::bankBit(3) == 0x04); + CHECK(bankstate::bankBit(0) == 0x00); + + bankstate::State s; + CHECK(!bankstate::enabled(s, 0)); + CHECK(!bankstate::enabled(s, 4)); // no such FET on this board +} + +static void test_single_bank_leaves_the_others_dark() { + g_case = "one bank on means exactly one bank on"; + bankstate::State s; + const bankstate::Request r = bankstate::request(s, 0x02); + CHECK(r.accepted); + CHECK(r.changed); + CHECK(r.granted == 0x02); + CHECK(!bankstate::enabled(s, 1)); + CHECK(bankstate::enabled(s, 2)); + CHECK(!bankstate::enabled(s, 3)); + CHECK(bankstate::enabledCount(s) == 1); +} + +static void test_all_off_is_refused() { + // The rule that keeps one corrupted byte from blinding a counter until + // someone walks to the hive. A counter that should count nothing is + // unpaired in HiveHub; it is never masked to zero here. + g_case = "a mask of zero is refused, not applied"; + bankstate::State s; + bankstate::request(s, 0x05); // banks 1 and 3 + const bankstate::Request r = bankstate::request(s, 0x00); + CHECK(!r.accepted); + CHECK(!r.changed); + CHECK(r.granted == 0x05); + CHECK(s.mask == 0x05); // unchanged: still counting + CHECK(bankstate::enabledCount(s) == 2); +} + +static void test_bits_above_the_last_bank_are_ignored() { + // A four-FET board's mask must not conjure a bank 4 whose GPIO does not + // exist on this one — and 0xF8 alone must not read as "some banks on". + g_case = "phantom banks are masked off"; + bankstate::State s; + bankstate::Request r = bankstate::request(s, 0xFF); + CHECK(r.accepted); + CHECK(r.granted == BANK_MASK_ALL); + CHECK(s.mask == BANK_MASK_ALL); + CHECK(bankstate::enabledCount(s) == 3); + + // Only phantom bits set is the same as asking for nothing, and is refused + // the same way rather than applied as an empty mask. + r = bankstate::request(s, 0xF8); + CHECK(!r.accepted); + CHECK(s.mask == BANK_MASK_ALL); +} + +static void test_reasserting_the_same_mask_is_not_a_change() { + // HiveHub re-writes the mask every upload cycle. `changed` is what the + // firmware uses to decide whether to tear down the gate state machines, so + // a steady-state re-assert must not reset a pairing in progress every ten + // minutes. + g_case = "an unchanged re-assert is accepted but not a change"; + bankstate::State s; + bankstate::request(s, 0x03); + const bankstate::Request r = bankstate::request(s, 0x03); + CHECK(r.accepted); + CHECK(!r.changed); + CHECK(s.mask == 0x03); +} + +static void test_a_bank_can_come_back() { + g_case = "switching a bank back on is just another request"; + bankstate::State s; + bankstate::request(s, 0x01); + CHECK(bankstate::enabledCount(s) == 1); + const bankstate::Request r = bankstate::request(s, BANK_MASK_ALL); + CHECK(r.accepted); + CHECK(r.changed); + CHECK(bankstate::enabledCount(s) == 3); +} + +static void test_mask_matches_the_protocol_constant() { + // BANK_MASK_ALL and the three physical FETs have to agree; if a board + // revision adds one, this is the line that fails first. + g_case = "BANK_MASK_ALL covers exactly three banks"; + CHECK(BANK_MASK_ALL == 0x07); + CHECK(CTRL_OP_SET_BANKS == 0x03); + CHECK(CTRL_SET_BANKS_LENGTH == 2); + // The opcode space is shared with night mode; a collision would let a + // suspension request switch banks or vice versa. + CHECK(CTRL_OP_SET_BANKS != CTRL_OP_SET_IDLE); + CHECK(CTRL_OP_SET_BANKS != CTRL_OP_RESUME); +} + +int main() { + std::printf("bank_state tests\n"); + test_default_is_everything_on(); + test_bank_numbering_is_one_based(); + test_single_bank_leaves_the_others_dark(); + test_all_off_is_refused(); + test_bits_above_the_last_bank_are_ignored(); + test_reasserting_the_same_mask_is_not_a_change(); + test_a_bank_can_come_back(); + test_mask_matches_the_protocol_constant(); + + if (g_failures == 0) { + std::printf("all tests passed\n"); + return EXIT_SUCCESS; + } + std::printf("%d check(s) failed\n", g_failures); + return EXIT_FAILURE; +} diff --git a/Firmware/test/test_measurement_json/test_measurement_json.cpp b/Firmware/test/test_measurement_json/test_measurement_json.cpp index 8253374..08340ba 100644 --- a/Firmware/test/test_measurement_json/test_measurement_json.cpp +++ b/Firmware/test/test_measurement_json/test_measurement_json.cpp @@ -7,7 +7,9 @@ // revision necessary — a uint16_t uptime that clamped at 18 hours and a // uint16_t glitch tally that pinned at 65535 — were both invisible in a code // read and both needed a device left running to reproduce, so they are pinned -// down here instead. +// down here instead. v4's idle_s and v5's banks are here for the same reason +// from the other direction: both exist to say that a flat stretch of totals is +// deliberate, and both are useless if the field name drifts. // // Run them directly with any host compiler: // @@ -66,35 +68,62 @@ static ble::Telemetry nominal() { t.total_out = 95; t.glitch_count = 2; t.idle_s = 0; + t.bank_mask = BANK_MASK_ALL; return t; } // -------------------------------------------------------------------------- static void test_nominal_document() { - g_case = "nominal v4 document"; + g_case = "nominal v5 document"; char json[MEASUREMENT_JSON_CAPACITY]; const int n = buildMeasurementJson(json, sizeof(json), nominal(), "0.1.0"); CHECK(n > 0); CHECK(std::strcmp( json, - "{\"fw\":4,\"ver\":\"0.1.0\",\"uptime_s\":1234,\"status\":15," + "{\"fw\":5,\"ver\":\"0.1.0\",\"uptime_s\":1234,\"status\":15," "\"num_gates\":24,\"mcps_healthy\":3,\"total_in\":100," - "\"total_out\":95,\"glitches\":2,\"idle_s\":0}") == 0); + "\"total_out\":95,\"glitches\":2,\"idle_s\":0,\"banks\":7}") == 0); CHECK(n == (int)std::strlen(json)); } -static void test_protocol_version_is_four() { +static void test_protocol_version_is_five() { // The version byte is what HiveHub branches on. If this changes without the // parser learning the new revision, every counter goes unreadable. g_case = "fw is the protocol revision, not the image version"; - CHECK(PROTOCOL_VERSION == 4); + CHECK(PROTOCOL_VERSION == 5); char json[MEASUREMENT_JSON_CAPACITY]; buildMeasurementJson(json, sizeof(json), nominal(), "9.9.9"); - containsField(json, "\"fw\":4"); + containsField(json, "\"fw\":5"); containsField(json, "\"ver\":\"9.9.9\""); } +static void test_bank_mask_is_always_reported() { + // Why the field exists: eight gates that are deliberately dark and eight + // gates whose FET has died produce the same permanently flat share of the + // totals. Only this byte separates them — and it has to be present even + // when nothing is switched off, or "all banks on" and "counter too old to + // say" become the same absence. + g_case = "banks says which MOSFETs are live"; + ble::Telemetry t = nominal(); + char json[MEASUREMENT_JSON_CAPACITY]; + CHECK(buildMeasurementJson(json, sizeof(json), t, "0.3.0") > 0); + containsField(json, "\"banks\":7"); + + t.bank_mask = 0x01; // only gates 00..07 counted + CHECK(buildMeasurementJson(json, sizeof(json), t, "0.3.0") > 0); + containsField(json, "\"banks\":1"); + + t.bank_mask = 0x05; // banks 1 and 3 + CHECK(buildMeasurementJson(json, sizeof(json), t, "0.3.0") > 0); + containsField(json, "\"banks\":5"); + + // num_gates keeps reporting what is WIRED, not what is lit: it is the + // board's topology, and a consumer derives the active count from the mask. + // Moving it would silently rewrite the meaning of every stored reading. + containsField(json, "\"num_gates\":24"); +} + static void test_night_mode_is_visible_in_the_document() { // The reason idle_s exists: without it, a night of zero crossings and a // counter whose emitter FETs have died produce identical documents. The @@ -187,12 +216,13 @@ static void test_saturated_worst_case_fits_the_buffer() { t.total_out = UINT32_MAX; t.glitch_count = UINT32_MAX; t.idle_s = UINT32_MAX; + t.bank_mask = 255; char json[MEASUREMENT_JSON_CAPACITY]; const int n = buildMeasurementJson(json, sizeof(json), t, "255.255.255-rc1"); CHECK(n > 0); CHECK(n < (int)MEASUREMENT_JSON_CAPACITY); // Headroom, so a longer version string cannot silently start truncating. - CHECK(n <= 200); + CHECK(n <= 215); std::printf(" worst case is %d of %u bytes\n", n, MEASUREMENT_JSON_CAPACITY); } @@ -220,7 +250,8 @@ static void test_missing_version_string() { int main() { std::printf("measurement_json tests\n"); test_nominal_document(); - test_protocol_version_is_four(); + test_protocol_version_is_five(); + test_bank_mask_is_always_reported(); test_night_mode_is_visible_in_the_document(); test_night_idle_bit_does_not_collide(); test_uptime_past_the_old_ceiling(); diff --git a/README.md b/README.md index c3e92f4..2547656 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ the 3× MCP23017 port expanders. | # | Component | Qty | Notes | |---|-----------|-----|-------| -| 4 | IRLB8721PbF N-channel MOSFET (TO-220) | 3 | 3.3 V gate-safe. One per MCP23017: Q1 = LED_BANK_1 (gates 00..07), Q2 = LED_BANK_2 (gates 10..17), Q3 = LED_BANK_3 (gates 20..27) | +| 4 | IRLB8721PbF N-channel MOSFET (TO-220) | 3 | 3.3 V gate-safe. One per MCP23017: Q1 = LED_BANK_1 (gates 00..07), Q2 = LED_BANK_2 (gates 10..17), Q3 = LED_BANK_3 (gates 20..27). Individually switchable at runtime — see [Power Budget](#6-power-budget) | ### 2.5 Resistors @@ -309,7 +309,7 @@ together is under 20 mA, an order of magnitude below. The per-gate figure depends on the actual LED forward voltage; **measure it on an assembled board** before sizing a panel or a pack, rather than trusting the range above. -Two things follow, and both are implemented: +Three things follow, and all three are implemented: * **Pulsed emitters** (`LedMode::AUTO`, the default since the 2026-06 revision) cut the duty cycle from 100 % to ~35 %. Raising `POLL_INTERVAL_MS` lowers it @@ -321,6 +321,21 @@ Two things follow, and both are implemented: between the counter fitting an off-grid budget and not. See [`docs/ble-mode.md`](docs/ble-mode.md#night-mode-the-control-characteristic) — including why it is *not* implemented as deep sleep. +* **Per-bank enables** switch the three MOSFETs individually, so an entrance + narrower than 24 gates costs only the banks it uses. Measured on an assembled + board, at 3.3 V: + + | 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. All three are enabled unless + HiveHub says otherwise (three checkboxes per device in its dashboard), and a + counter that resets comes back with all three on. It composes with night mode + rather than competing with it. See + [`docs/ble-mode.md`](docs/ble-mode.md#emitter-banks-the-other-power-control). Add solar for indefinite runtime. @@ -333,8 +348,8 @@ GATT contract lives in [`docs/ble-mode.md`](docs/ble-mode.md); this is a summary - Advertises as `BeeCounter`; HiveHub connects by the MAC paired in its portal. - One service, `8e8b0101-7a1c-4b9e-9a2f-1d6e0b9c1a01`, holding a READ - measurement characteristic, a READ/WRITE night-mode control characteristic and - three OTA characteristics. + measurement characteristic, a READ/WRITE control characteristic (night mode + and emitter-bank enables) and three OTA characteristics. - The measurement value is built on read, so it is never a stale snapshot: ```json @@ -379,14 +394,16 @@ Implemented in `Firmware/` (PlatformIO, `seeed_xiao_esp32c6` env). See re-probed until it comes back. - **BLE/GATT peripheral:** serves lifetime totals as JSON on read, accepts a firmware image on the OTA characteristics, and takes a bounded "stop sensing" - request on the control characteristic (`src/ble_link.cpp`). + request plus an emitter-bank enable mask on the control characteristic + (`src/ble_link.cpp`). - Per-gate debounce + direction state machine (IDLE → INNER/OUTER_FIRST → PAIRED) emits IN/OUT counts; glitches tallied for diagnostics. The logic lives in `Firmware/include/gate_logic.h` and is covered by host-side tests (`Firmware/test/run_tests.sh`). - IR banks driven on GPIO19 / silk D8 (bank 1), GPIO20 / silk D9 (bank 2) and - GPIO18 / silk D10 (bank 3) — one MOSFET per MCP23017; LED mode settable from - the IR_DEBUG serial console. + GPIO18 / silk D10 (bank 3) — one MOSFET per MCP23017. LED mode and per-bank + enables are both settable from the IR_DEBUG serial console (`1`/`0`/`a` and + `4`/`5`/`6`), and the bank mask is settable over BLE by HiveHub. - BLE OTA image receiver (`Update` library) with size + CRC-32 verification before the inactive app slot is selected. @@ -396,7 +413,8 @@ Implemented in `Firmware/` (PlatformIO, `seeed_xiao_esp32c6` env). See - Difference consecutive totals server-side to get the interval counts. - Write combined record to SD card; transmit via WiFi if available. - Optionally relay a firmware update to the C6 over BLE (`update_beecounter`). -- Optionally re-arm the counter's night-mode suspension for the next cycle. +- Optionally re-arm the counter's night-mode suspension for the next cycle, and + re-assert its emitter-bank enable mask. - Sleep ~10 minutes. --- diff --git a/claude.md b/claude.md index 54fa481..211be39 100644 --- a/claude.md +++ b/claude.md @@ -44,6 +44,8 @@ backend. - `src/ble_link.cpp` — NimBLE GATT peripheral: the measurement characteristic, the night-mode control characteristic and the OTA characteristics. - `include/idle_state.h` — night-mode suspension as pure deadline arithmetic. + - `include/bank_state.h` — the emitter-bank (MOSFET) enable mask, as pure + bitmask rules. - `include/pins.h` — **authoritative** GPIO map for the PCB. - `include/counter_protocol.h` — status bitfield + OTA state codes shared between `main.cpp` and `ble_link.cpp`. @@ -91,6 +93,13 @@ backend. - **Three IR emitter banks, one MOSFET per MCP23017** since the 2026-08 hardware revision: bank 1 = gates 00..07 (U2), bank 2 = 10..17 (U3), bank 3 = 20..27 (U4). Older docs describing a 2-FET split (00..13 / 14..27) are stale. +- **Each bank is individually switchable** (protocol v5), because one bank + costs ~0.14 A, two ~0.22 A and three ~0.30 A at 3.3 V. Same fail-open rules as + night mode: not persisted, re-asserted by HiveHub every cycle, all three on + after any reset, and an all-off mask REFUSED rather than applied. Gates on a + dark bank must be *skipped*, never read as "clear" — an unpowered QRE1113 is a + bare phototransistor and sunlight into the entrance will pull it low. See + `include/bank_state.h` and `docs/ble-mode.md`. - OTA needs the dual-slot `partitions_4mb_ota_no_fs.csv` layout. A board still running a single-app image has to be updated once over USB. diff --git a/docs/ble-mode.md b/docs/ble-mode.md index e08791b..c71447f 100644 --- a/docs/ble-mode.md +++ b/docs/ble-mode.md @@ -24,19 +24,19 @@ advertises as `BeeCounter`, but HiveHub connects by the paired MAC. | Service | `8e8b0101-7a1c-4b9e-9a2f-1d6e0b9c1a01` | | Measurement characteristic | `8e8b0102-7a1c-4b9e-9a2f-1d6e0b9c1a01` | | Properties | READ | -| Control characteristic (night mode) | `8e8b0103-7a1c-4b9e-9a2f-1d6e0b9c1a01` | +| Control characteristic (night mode + emitter banks) | `8e8b0103-7a1c-4b9e-9a2f-1d6e0b9c1a01` | | Properties | READ, WRITE | The value is generated when HiveHub reads it, so it contains current lifetime totals rather than a periodically cached snapshot: ```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} ``` | Field | Type | Meaning | | --- | --- | --- | -| `fw` | uint8 | Revision of *this document's* format (`PROTOCOL_VERSION`), currently 4 | +| `fw` | uint8 | Revision of *this document's* format (`PROTOCOL_VERSION`), currently 5 | | `ver` | string | Image version from `include/version.h`, `MAJOR.MINOR.PATCH` | | `uptime_s` | uint32 | Seconds since boot | | `status` | uint8 | Status bitfield, see `include/counter_protocol.h` | @@ -45,6 +45,7 @@ totals rather than a periodically cached snapshot: | `total_in` / `total_out` | uint32 | Monotonic lifetime totals, saturating | | `glitches` | uint32 | Diagnostic tally of ambiguous/aborted pairings, saturating | | `idle_s` | uint32 | Seconds of night-mode suspension still to run; `0` while counting | +| `banks` | uint8 | Bitmask of enabled emitter banks (MOSFETs): bit 0 = gates 00..07, bit 1 = 10..17, bit 2 = 20..27. `7` unless banks have been switched off | The field names, UUIDs, and integer types match HiveHub's `firmware/include/bee_counter_wire.h` parser, which reads `fw` first and @@ -129,7 +130,7 @@ not a consumption. ## Power and performance choices -* Measurement JSON uses a fixed 224-byte stack buffer, without ArduinoJson or +* Measurement JSON uses a fixed 240-byte stack buffer, without ArduinoJson or `String` heap churn. * Telemetry is serialized only on a GATT read, not every two seconds. * The measurement path remains read-only; OTA uses three separate @@ -177,7 +178,8 @@ schedule; the counter is told only *how long* to stay quiet: | --- | --- | | SET_IDLE | `0x01 + duration_s(4 LE)` | | RESUME | `0x02` | -| Read-back | `state(1) + remaining_s(4 LE)`, state `0x00` sensing / `0x01` idle | +| SET_BANKS | `0x03 + bank_mask(1)` — see [Emitter banks](#emitter-banks-the-other-power-control) | +| Read-back | `state(1) + remaining_s(4 LE) + bank_mask(1)`, state `0x00` sensing / `0x01` idle | A `SET_IDLE` of `0` means the same thing as `RESUME`, so HiveHub can cancel on a connection it already has without a second opcode. @@ -238,6 +240,85 @@ that from a counter whose emitter FETs have died — which produces an identical row of zeros and is otherwise indistinguishable until someone reads a week of totals. +## Emitter banks (the other power control) + +Night mode answers *when should the counter stop?* This answers *how much of +the counter should exist at all?* + +Since the 2026-08 hardware revision the 48 IR emitters are split across three +IRLB8721 MOSFETs, one per MCP23017, so each third of the entrance is +independently switchable: + +| Bank | Bit | Expander | Gates | +| --- | --- | --- | --- | +| 1 | `0x01` | U2 @ 0x20 | 00..07 | +| 2 | `0x02` | U3 @ 0x21 | 10..17 | +| 3 | `0x04` | U4 @ 0x22 | 20..27 | + +Measured on the 3.3 V rail with the pulsed sampler at its defaults: + +| Banks enabled | Gates counted | Draw @ 3.3 V | +| --- | --- | --- | +| 1 | 8 | ~0.14 A | +| 2 | 16 | ~0.22 A | +| 3 (default) | 24 | ~0.30 A | + +That is roughly 80 mA per bank on top of a ~60 mA floor — dropping one bank +saves about as much current as a quarter of a night of night mode, except it +applies around the clock. The two features compose: a counter can be running on +one bank *and* be suspended, and the numbers multiply rather than compete. + +Use it when the entrance is physically narrower than 24 gates, when a hive is +being run with part of its entrance closed, or when an off-grid supply will not +carry the full board. It is a **configuration**, not a schedule — HiveHub's +dashboard exposes it as three checkboxes per device, all ticked by default. + +### The rules, and why each one exists + +* **All three enabled is the default and the post-reset state.** Nothing about + this is persisted on the counter, exactly as with night mode: a brownout, a + watchdog or an OTA reboot comes back counting all 24 gates, and HiveHub + re-asserts the mask on its next upload cycle. The worst case is one cycle of + drawing more current than was asked for — never a counter that boots blind on + two thirds of its entrance because of a write it received a month ago. +* **A mask of `0` is refused, not applied.** The counter keeps whatever it had + and says so on the serial log. Blinding a counter entirely is not a + configuration anyone needs — a counter that should count nothing is unpaired + — and accepting it would let one corrupted byte stop counting until someone + walks to the hive. Same reasoning as `start == end` disabling the night + window rather than covering the whole day. +* **Bits above bank 3 are ignored.** A four-FET board's mask arriving here must + not conjure a bank whose GPIO does not exist. A mask of *only* phantom bits is + therefore a zero mask, and is refused as one. +* **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 a + hive entrance can pull one low. Feeding those readings to the state machine + would invent crossings on gates the operator deliberately switched off. +* **The expander is still read and still health-checked.** `mcps_healthy` keeps + meaning "MCP23017s answering on the I²C bus", so a chip that dies while its + bank is off is still visible. The read costs about half a millisecond and no + measurable current; the emitters are what the feature is about. +* **`num_gates` keeps reporting 24.** It describes what is *wired*, which has + not changed. Active gates are `popcount(banks) * 8`, derived by the consumer, + so the meaning of every stored reading stays fixed. +* **It is accepted during an OTA**, where `SET_IDLE` is refused. A suspension + armed under a transfer would outlive a reboot it cannot survive; a bank mask + is re-asserted every cycle anyway, and the emitters are dark for the transfer + either way. + +### What a switched-off bank looks like in the data + +A third of the entrance stops 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 emitted on +every document including the `7` of a counter nobody has reconfigured: a field +that appeared only when it was interesting would make "all banks on" and +"counter too old to say" the same absence. + +On the bench the `-DIR_DEBUG` console toggles banks live with the `4`, `5` and +`6` keys (named for the schematic's `/GPIO4`, `/GPIO5`, `/GPIO6` FET rails), and +prints the mask on every readout. + ## Firmware update over connectable BLE HiveTraffic advertises connectably at all times for measurements and updates. @@ -344,7 +425,31 @@ before treating OTA as secure against a nearby active attacker. set, name, meaning or range of the reported fields changes; a firmware fix that reports the same fields bumps `ver` and leaves `fw` alone. -### v4 — current +### v5 — current + +Emitter bank enables. The three MOSFETs of the 2026-08 board can be switched +individually (see [Emitter banks](#emitter-banks-the-other-power-control)), and +the document now says which are live. + +| Change | v4 | v5 | +| --- | --- | --- | +| Enabled emitter banks | — | `banks` (uint8 bitmask, `7` = all three) | +| Control opcode `0x03` | unused | `SET_BANKS`, `0x03 + bank_mask(1)` | +| Control read-back | `state(1) + remaining_s(4 LE)` | + `bank_mask(1)`, appended | + +Additive in both directions, and deliberately so. A parser that skips unknown +keys reads a v5 document as a v4 one; a client that reads five bytes of the +control read-back and stops gets exactly the value it got before. The usual +deployment-order rule still applies for the same reason it did at v4: a HiveHub +that does not understand `banks` cannot tell a switched-off bank from a dead +FET, and it is HiveHub that switches them off. + +A counter running v4 or earlier has no `SET_BANKS` opcode. The write is ignored +and logged as an unknown opcode, so the feature degrades to "this counter runs +all three banks" — never to an error. HiveHub gates the write on `fw >= 5` +rather than relying on that. + +### v4 Night mode. The counter can be told to stop sensing for a bounded period (see [Night mode](#night-mode-the-control-characteristic)), and the document now says @@ -408,10 +513,11 @@ alone until both sides can be revised together. authorization and no firmware signature check: CRC-32 is an integrity check, not an authenticity one, so anyone in radio range can push a validly-framed image — and, since v4, write a `SET_IDLE` and stop the counter for up to an - hour. The second is strictly the lesser of the two (it expires by itself, it - changes nothing persistent, and anyone able to do it could already replace the - firmware), but it is a new way to deny counting and it is named here rather - than left to be discovered. This is an accepted, documented risk rather than an oversight, but + hour, or, since v5, a `SET_BANKS` and dark two thirds of its entrance. Both + are strictly the lesser of the two (neither is persisted, both are re-asserted + by HiveHub within one upload cycle, and anyone able to do either could already + replace the firmware), but they are new ways to deny counting and they are + named here rather than left to be discovered. This is an accepted, documented risk rather than an oversight, but closing it needs a design decision (Secure Boot + signed images vs. a BLE-layer authentication handshake vs. a per-device provisioning key) *and* a migration story for counters already in the field, since an unauthenticated