diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d1d243..d4a2434 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,8 +72,8 @@ jobs: /tmp/test_beehive_decode # The HiveTraffic measurement decoder is a two-repo wire contract: it has # to keep reading fw:2 documents from counters the OTA relay has not - # reached yet, as well as the fw:3 and fw:4 ones newer firmware emits. - # A captured document of each revision is asserted here. + # reached yet, as well as the fw:3, fw:4 and fw:5 ones newer firmware + # emits. A captured document of each revision is asserted here. - name: Build and run bee counter wire tests run: | set -e @@ -129,6 +129,7 @@ jobs: python3 test-data/test_ble_sensor_rules.py python3 test-data/test_sd_import.py python3 test-data/test_data_export.py + python3 test-data/test_bee_counter_banks.py python3 -m pytest -q test-data/test_tempcomp.py test-data/test_hive_tempcomp.py \ test-data/test_hiveheart_fft.py \ test-data/test_mqtt_ha_discovery.py \ diff --git a/docs/api.md b/docs/api.md index 34e7b82..1e648e8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -382,7 +382,10 @@ Returns the current config for a device. A default config is created if none exi "beecounter_night_start_minute": 1200, "beecounter_night_end_minute": 360, "beecounter_night_max_traffic": 0, - "timezone": "" + "timezone": "", + "beecounter_bank1_enabled": true, + "beecounter_bank2_enabled": true, + "beecounter_bank3_enabled": true } ``` @@ -400,6 +403,33 @@ next cycle rather than waiting for an unrelated edit to bump the version. | `beecounter_night_max_traffic` | Crossings (in + out) in the last upload cycle above which night mode is postponed to the next cycle. `0` disables the check. | | `timezone` | POSIX TZ string, e.g. `CET-1CEST,M3.5.0,M10.5.0/3`. Empty means UTC. | +The `beecounter_bank*_enabled` fields configure **HiveTraffic emitter banks** — +see [hivetraffic-bee-counter.md](hivetraffic-bee-counter.md#emitter-banks). +Applied on every config fetch for the same reason the night-mode fields are. + +| Field | Meaning | +| --- | --- | +| `beecounter_bank1_enabled` | Emitter MOSFET for gates 00–07. `true` by default. | +| `beecounter_bank2_enabled` | Emitter MOSFET for gates 10–17. `true` by default. | +| `beecounter_bank3_enabled` | Emitter MOSFET for gates 20–27. `true` by default. | + +A counter's 48 IR emitters sit behind three MOSFETs, one per group of eight +gates, and they dominate its power draw. Measured on the counter's 3.3 V rail: +one bank ~0.14 A, two ~0.22 A, three ~0.30 A — roughly 80 mA per bank on top of +a ~60 mA floor. Switching one off stops its eight gates being counted at all, so +the totals drop in proportion; the counter reports the mask back as +`hives[].bee_counter.banks` so a deliberately dark bank is distinguishable from +a failed one. + +A PATCH that would leave **all three** disabled is rejected with `400`. The +counter refuses a mask of zero outright — it keeps the mask it had — so storing +one would only leave the dashboard and the hardware permanently disagreeing. A +counter that should count nothing is unpaired instead. + +The device requires HiveTraffic firmware 0.3.0 (wire revision 5) or newer to +apply the mask; on an older counter the write is skipped and all three banks +stay on. + `timezone` is load-bearing rather than cosmetic. The device clock is UTC, so without it a window entered as 20:00 fires at 21:00 local in summer — discarding an hour of foraging on exactly the long evenings that have most of it. It is a diff --git a/docs/hivetraffic-bee-counter.md b/docs/hivetraffic-bee-counter.md index 6e04fdc..7d8dbf6 100644 --- a/docs/hivetraffic-bee-counter.md +++ b/docs/hivetraffic-bee-counter.md @@ -114,12 +114,14 @@ All HiveTraffic devices share one service/characteristic (overridable via | --- | --- | | Service | `8e8b0101-7a1c-4b9e-9a2f-1d6e0b9c1a01` | | Measurement characteristic (READ) | `8e8b0102-7a1c-4b9e-9a2f-1d6e0b9c1a01` | +| Control characteristic (READ/WRITE) | `8e8b0103-7a1c-4b9e-9a2f-1d6e0b9c1a01` | The characteristic returns a compact JSON document — **totals only**: ```json -{ "fw":4, "ver":"0.2.0", "uptime_s":1234, "status":15, "num_gates":24, - "mcps_healthy":3, "total_in":100, "total_out":95, "glitches":2, "idle_s":0 } +{ "fw":5, "ver":"0.3.0", "uptime_s":1234, "status":15, "num_gates":24, + "mcps_healthy":3, "total_in":100, "total_out":95, "glitches":2, "idle_s":0, + "banks":7 } ``` `fw` is the wire-protocol revision; `ver` is the counter's own image version, @@ -130,21 +132,26 @@ HiveHub reads it, fills a totals-only `beecnt::Snapshot`, and disconnects. The wire format is totals-only by design: no latch/reset command exists over BLE, so a missed connection can never lose counts. -### Three wire revisions, all supported +### Four wire revisions, all supported `firmware/include/bee_counter_wire.h` reads `fw` **first** and branches on it. Every revision parses, and they produce the same record: -| | `fw:2` | `fw:3` | `fw:4` | -| --- | --- | --- | --- | -| Expander health field | `gates_healthy` | `mcps_healthy` | `mcps_healthy` | -| `uptime_s` | 16-bit on the device, clamped at 65535 (18 h 12 min) | 32-bit | 32-bit | -| `glitches` | 16-bit, pinned at 65535 | 32-bit, saturating | 32-bit, saturating | -| `idle_s` + status bit `0x80` | — | — | night-mode countdown | - -`fw:4` is purely additive, so the parser read those documents correctly before -it knew `idle_s` existed — it skips unknown keys. What it could not do is tell a -*suspended* counter from a broken one, which is the whole reason for reading it. +| | `fw:2` | `fw:3` | `fw:4` | `fw:5` | +| --- | --- | --- | --- | --- | +| Expander health field | `gates_healthy` | `mcps_healthy` | `mcps_healthy` | `mcps_healthy` | +| `uptime_s` | 16-bit on the device, clamped at 65535 (18 h 12 min) | 32-bit | 32-bit | 32-bit | +| `glitches` | 16-bit, pinned at 65535 | 32-bit, saturating | 32-bit, saturating | 32-bit, saturating | +| `idle_s` + status bit `0x80` | — | — | night-mode countdown | night-mode countdown | +| `banks` | — | — | — | enabled emitter MOSFETs | + +`fw:4` and `fw:5` are purely additive, so the parser read those documents +correctly before it knew `idle_s` or `banks` existed — it skips unknown keys. +What it could not do is tell a *suspended* or *narrowed* counter from a broken +one, which is the whole reason for reading them. A counter too old to report +`banks` is running all three, so the field defaults to `7` rather than `0`; +reading its absence as "everything is off" would misrepresent every counter the +OTA relay has not reached yet. This is not politeness toward old firmware. **A counter keeps reporting `fw:2` until the OTA relay updates it, and the relay reads this very characteristic @@ -250,6 +257,95 @@ Counters running firmware older than `fw:4` have no control characteristic. The write is skipped and they keep counting; the OTA relay will bring them up to a firmware that can be suspended in the normal course of things. +## Emitter banks + +Night mode decides *when* a counter stops. This decides *how much of it runs at +all*, and it applies around the clock. + +The counter's 48 IR emitters sit behind three IRLB8721 MOSFETs, one per +MCP23017, so each third of the entrance is independently switchable: + +| Bank | Gates | Expander | +| --- | --- | --- | +| 1 | 00–07 | U2 @ 0x20 | +| 2 | 10–17 | U3 @ 0x21 | +| 3 | 20–27 | U4 @ 0x22 | + +Measured on the counter's 3.3 V rail: + +| Banks enabled | Gates counted | Draw | +| --- | --- | --- | +| 1 | 8 | ~0.14 A | +| 2 | 16 | ~0.22 A | +| 3 (default) | 24 | ~0.30 A | + +Roughly 80 mA per bank on top of a ~60 mA floor. Dropping one saves about as +much current as a quarter of a night of night mode, except it saves it all day, +which makes it the coarsest and most effective power control the counter has. +The two compose rather than compete: a counter can be running one bank *and* be +suspended. + +Turn a bank off when the hive entrance is physically narrower than 24 gates, +when part of it is closed for the season, or when an off-grid supply will not +carry the whole board. + +### Setup + +Dashboard → **HiveTraffic setup** → *Emitter banks*: three checkboxes, all +ticked by default. The setting is per **device** (every counter paired to one +hub shares it) and applies to every paired counter, exactly like the night +window above it. + +| Field | Notes | +| --- | --- | +| Bank 1 / 2 / 3 | One checkbox per MOSFET. All three enabled unless you say otherwise | + +At least one must stay enabled. The dashboard refuses to save all three off and +the API rejects it with `400`, because the counter refuses a mask of zero +outright — it keeps whatever mask it had — so storing one would leave three +unticked boxes next to a counter cheerfully counting all 24 gates, with nothing +saying why. A counter that should count nothing is unpaired instead. + +### How it works + +1. `/api/v1/devices/{id}/config` delivers the three booleans; `fetchRemoteConfig` + assembles them into a bitmask (`beeBankMask`) and persists it in NVS, so a + hub that boots without WiFi still narrows its counters. +2. `bee_counter_client.cpp` reads the measurement characteristic as usual, and + compares the `banks` value the counter just **reported** with the configured + mask. +3. If they differ, a 2-byte `SET_BANKS` frame (`0x03` + mask) goes out on the + same connection — no extra scan, no extra connect. +4. The counter applies it, darkens the MOSFETs of the disabled banks, and skips + those gates entirely rather than reading them as "clear". + +Comparing against what the counter *reported* — rather than against a +HiveHub-side memory of what it last sent — is the whole self-healing property. +The counter deliberately does not persist the mask, so one that browned out, +watchdogged or rebooted out of an OTA comes back running all 24 gates; the next +cycle's read shows the disagreement and fixes it. A HiveHub that remembered "I +already configured this one" would leave that counter wide open indefinitely. + +Counters running firmware older than `fw:5` do not understand the opcode. The +write is gated on the reported revision and simply skipped, so they keep running +all three banks; the OTA relay will bring them up in the normal course of things. + +### Reading the data + +A switched-off bank's eight gates stop contributing to `total_in` / `total_out` +permanently, which is character for character what a dead emitter FET produces. +`banks` is the only thing that separates them, which is why it is stored on +every reading as `hives[].bee_counter.banks` — including the `7` of a counter +nobody has narrowed. A field that appeared only when it was interesting would +make "all banks on" and "counter too old to say" the same absence. + +`num_gates` keeps reporting **24**: it describes what is wired, which has not +changed. Active gates are `popcount(banks) * 8`. + +Expect the totals to drop roughly in proportion when a bank is switched off, and +do not compare a narrowed counter's numbers against its own earlier history — +the interval charts will show a step, and it is real. + ## Intervals are differenced server-side The wire format carries only the **monotonic lifetime totals**. The backend diff --git a/firmware/include/bee_counter_client.h b/firmware/include/bee_counter_client.h index f314234..f9aae36 100644 --- a/firmware/include/bee_counter_client.h +++ b/firmware/include/bee_counter_client.h @@ -62,6 +62,16 @@ struct Snapshot { // to report it. Non-zero says the flat interval that follows is deliberate // — we asked for it — rather than a failed emitter bank. uint32_t idle_s = 0; + // Which of the counter's three emitter MOSFETs are enabled ("banks", wire + // revision 5+): bit 0 = gates 00..07, bit 1 = 10..17, bit 2 = 20..27. + // + // BEECOUNTER_BANK_MASK_ALL for any counter too old to report it, which is + // the truth — a pre-v5 counter has no way to switch a bank off. A cleared + // bit means eight gates are dark and not being counted, so their share of + // the totals is permanently flat; without this field that is + // indistinguishable from the FET having failed, which is the same problem + // idle_s solves for the whole counter. + uint8_t bank_mask = BEECOUNTER_BANK_MASK_ALL; // 32-bit as of revision 3, saturating on the device rather than wrapping. uint32_t glitch_count = 0; }; diff --git a/firmware/include/bee_counter_wire.h b/firmware/include/bee_counter_wire.h index a0a4851..6bad97c 100644 --- a/firmware/include/bee_counter_wire.h +++ b/firmware/include/bee_counter_wire.h @@ -25,6 +25,13 @@ // correctly before it knew the field existed — it skips unknown // keys. What it could not do is tell a suspended counter from a // broken one, which is the point of reading it. +// fw >= 5 adds "banks": which of the counter's three emitter MOSFETs are +// enabled. Additive in the same way, and needed for the same reason +// — a bank switched off produces a permanently flat third of the +// totals, character for character what a dead FET produces. A +// counter too old to report it is running all three banks, which is +// why bank_mask defaults to ALL rather than to zero: the absence of +// the field means "everything is on", never "nothing is". // // Both must keep working, and not as a courtesy: a counter in the field reports // fw:2 until it is updated over the air, and HiveHub's OTA relay has to read @@ -68,6 +75,19 @@ constexpr uint8_t REV_MCPS_HEALTHY = 3; // First protocol revision carrying "idle_s" and the night-mode status bit. constexpr uint8_t REV_NIGHT_MODE = 4; +// First protocol revision carrying "banks", and therefore the first that +// understands the SET_BANKS control opcode. bee_counter_client.cpp gates the +// write on it rather than writing hopefully: an older counter ignores the +// opcode and logs it, which would print every cycle forever. +constexpr uint8_t REV_LED_BANKS = 5; + +// All three emitter banks enabled — the mask a counter runs unless told +// otherwise, the mask it returns to after any reset, and what this parser +// reports for a counter too old to carry the field. Mirrors +// beecounter_proto::BANK_MASK_ALL in the HiveTraffic repo; duplicated for the +// same reason STATUS_NIGHT_IDLE is, and pinned by the same test. +constexpr uint8_t BANK_MASK_ALL = 0x07; + // Status bit 0x80: the counter is deliberately not sensing. Mirrors // beecounter_proto::STATUS_NIGHT_IDLE in the HiveTraffic repo; duplicated // rather than shared because the two firmwares have no common header, and @@ -101,8 +121,33 @@ struct Measurement { // stop, because honey bees do not fly at night. Without it that is // indistinguishable from failed emitters. uint32_t idle_s = 0; + // Which of the counter's three emitter MOSFETs are enabled: bit 0 = gates + // 00..07, bit 1 = 10..17, bit 2 = 20..27. Read from "banks" (fw >= 5). + // + // Defaults to ALL, not to 0. A counter too old to report the field is + // running its whole entrance, and a zero default would make every pre-v5 + // counter look like it had been switched off entirely — the exact + // misreading that would then be written into stored history. + uint8_t bank_mask = BANK_MASK_ALL; }; +// How many gates this counter is actually watching, given its bank mask. The +// counter keeps reporting num_gates as what is WIRED (24), because that is a +// fact about the board; this is what is being counted right now. +inline uint8_t activeGates(const Measurement& m) { + uint8_t banks = 0; + for (uint8_t bit = 1; bit; bit = static_cast(bit << 1)) { + if (m.bank_mask & bit) banks++; + } + return static_cast(banks * 8U); +} + +// Is any bank switched off? The flat totals that follow are then deliberate, +// in exactly the way isNightIdle() means for a whole counter. +inline bool hasDisabledBank(const Measurement& m) { + return (m.bank_mask & BANK_MASK_ALL) != BANK_MASK_ALL; +} + // Is this counter currently suspended? Reads the countdown rather than the // status bit, because the countdown is the value the firmware derives at // serialization time and therefore the one that cannot lag the other. @@ -294,6 +339,14 @@ inline bool parseMeasurement(const char* json, size_t len, Measurement& out) { out.glitch_count = value; } else if (klen == 6 && memcmp(key, "idle_s", 6) == 0) { out.idle_s = value; + } else if (klen == 5 && memcmp(key, "banks", 5) == 0) { + // Masked to the three banks this hardware has, so a counter on a + // future four-FET board cannot report a bank HiveHub would then + // try to switch. A value of 0 is stored as read: it is not a thing + // the counter can be in (it refuses an all-off mask), so seeing one + // means something is wrong and flattening it to ALL would hide it. + detail::assignU8(out.bank_mask, + value & static_cast(BANK_MASK_ALL)); } // else: unknown key, already skipped. } diff --git a/firmware/include/config.h b/firmware/include/config.h index 5510f4e..9b80c16 100644 --- a/firmware/include/config.h +++ b/firmware/include/config.h @@ -633,6 +633,20 @@ #ifndef BEECOUNTER_CTRL_OP_SET_IDLE #define BEECOUNTER_CTRL_OP_SET_IDLE 0x01 #endif +// Emitter-bank enables (HiveTraffic protocol v5 and later). The counter's 48 IR +// LEDs sit behind three MOSFETs, one per MCP23017 — bank 1 = gates 00..07, +// bank 2 = 10..17, bank 3 = 20..27 — and each draws roughly 80 mA at 3.3 V, on +// top of a ~60 mA floor: one bank ~0.14 A, two ~0.22 A, three ~0.30 A. We write +// the configured mask once per cycle, gated on the counter reporting fw >= 5 +// (wire::REV_LED_BANKS) so an older one is not sent an opcode it can only log +// as unknown, every cycle, forever. +#ifndef BEECOUNTER_CTRL_OP_SET_BANKS +#define BEECOUNTER_CTRL_OP_SET_BANKS 0x03 +#endif +// All three banks on: the default, and what a counter runs after any reset. +#ifndef BEECOUNTER_BANK_MASK_ALL +#define BEECOUNTER_BANK_MASK_ALL 0x07 +#endif // OTA characteristics. Unlike HiveInside these live in the SAME service as the // measurement characteristic above — HiveTraffic has only one service. diff --git a/firmware/include/device_prefs.h b/firmware/include/device_prefs.h index 943efaf..e090030 100644 --- a/firmware/include/device_prefs.h +++ b/firmware/include/device_prefs.h @@ -12,8 +12,9 @@ String wifiPassKey(int index); void seedPrefsFromSecretsIfNeeded(); void loadConfigFromPrefs(); void saveScaleConfig(); -// Persist the HiveTraffic night-mode window from the last /config fetch, so an -// offline boot still honours it. Called from fetchRemoteConfig(). +// Persist the HiveTraffic power settings from the last /config fetch — the +// night-mode window and the emitter-bank mask — so an offline boot still +// honours them. Called from fetchRemoteConfig(). void saveNightModePrefs(); // Latch "the server has this device's claim" so the claim code stops riding // along on every upload. diff --git a/firmware/include/globals.h b/firmware/include/globals.h index 1ddb5b0..26fdd3d 100644 --- a/firmware/include/globals.h +++ b/firmware/include/globals.h @@ -153,6 +153,24 @@ extern uint32_t nightMaxTraffic; // tzset() so localtime() honours it. Empty means UTC. extern String nightTimezone; +// ---- HiveTraffic emitter banks ------------------------------------------- +// Which of a paired counter's three emitter MOSFETs may light. Bit 0 = bank 1 +// (gates 00..07), bit 1 = bank 2 (10..17), bit 2 = bank 3 (20..27). Delivered +// by /api/v1/devices/{id}/config as three booleans and assembled into this mask; +// persisted in NVS alongside the night window, and written to each paired +// counter once per upload cycle. +// +// The counter itself never persists it, so this re-assert is not belt and +// braces — it is the mechanism. A counter that reset comes back running all 24 +// gates and stays that way until we tell it otherwise, which costs at most one +// cycle of extra current and never a counter blind on eight gates for reasons +// nobody can reconstruct. +// +// BEECOUNTER_BANK_MASK_ALL (all three on) unless the dashboard says otherwise. +// A mask of 0 is never sent: the dashboard refuses to store it and the counter +// refuses to apply it. +extern uint8_t beeBankMask; + // ---- Scale calibration ---------------------------------------------------- extern long scale1Offset; extern long scale2Offset; diff --git a/firmware/src/bee_counter_client.cpp b/firmware/src/bee_counter_client.cpp index 0a01d4c..8388b88 100644 --- a/firmware/src/bee_counter_client.cpp +++ b/firmware/src/bee_counter_client.cpp @@ -67,6 +67,12 @@ void writeSnapshotToHive(JsonObject hive, const Snapshot& snap) { // the same absence in stored history, and the whole reason the field // exists is to tell a deliberate flat interval from a broken one. bc["idle_s"] = snap.idle_s; + // Same argument as idle_s: emitted unconditionally, including the 0x07 of a + // counter nobody has narrowed and the 0x07 implied by a pre-v5 counter. + // A field that appeared only when a bank was off would make "all banks on" + // and "counter too old to say" the same absence in stored history, and the + // whole point is to tell a deliberately dark bank from a dead FET. + bc["banks"] = snap.bank_mask; } // --------------------------------------------------------------------------- @@ -103,6 +109,7 @@ bool parseTrafficJson(const char* json, size_t len, Snapshot& out) { out.total_out = m.total_out; out.glitch_count = m.glitch_count; out.idle_s = m.idle_s; + out.bank_mask = m.bank_mask; return true; } @@ -136,6 +143,38 @@ bool writeIdleGrant(NimBLERemoteService* svc, uint32_t duration_s) { return chr->writeValue(frame, sizeof(frame), true); } +// Write the configured emitter-bank mask to an already-connected counter. +// +// Gated by the caller on the counter reporting wire revision 5 or later: an +// older firmware has the control characteristic but not this opcode, and would +// log an "unknown opcode" line every cycle for the rest of its deployment. +// +// The caller writes whenever the mask the counter just REPORTED differs from +// the configured one — never against a HiveHub-side memory of what it last +// sent. That distinction is the whole self-healing property: the counter does +// not persist the mask, so one that browned out, watchdogged or rebooted out of +// an OTA comes back reporting all three banks, and the next cycle's read shows +// the disagreement and fixes it. A HiveHub that instead remembered "I already +// configured this one" would leave that counter wide open indefinitely, and a +// HiveHub that wrote unconditionally would spend a GATT write every ten minutes +// to change nothing. +// +// Fire-and-forget beyond the return value, like writeIdleGrant: a failed write +// costs one cycle of a wider entrance than asked for, never a reading. +bool writeBankMask(NimBLERemoteService* svc, uint8_t mask) { + if (!svc) return false; + NimBLERemoteCharacteristic* chr = + svc->getCharacteristic(NimBLEUUID(BEECOUNTER_GATT_CONTROL_UUID)); + if (!chr || !chr->canWrite()) return false; + + uint8_t frame[2]; + frame[0] = BEECOUNTER_CTRL_OP_SET_BANKS; + frame[1] = mask; + // With response, for the same reason the idle grant is: one small write per + // cycle, and the reply is the only confirmation it landed. + return chr->writeValue(frame, sizeof(frame), true); +} + // Everything the night-mode decision needs that is the same for every counter // on this hub: the configured window, the current local time, the upload // interval and whether a firmware relay is unfinished. Gathered once per cycle @@ -256,7 +295,8 @@ bool readTrafficSlot(const String& mac, Snapshot& out, uint8_t slot, // is one the OTA relay has not reached yet, and that is the // difference between "old firmware" and "broken link". Serial.printf("[TRAFFIC] %s: fw=%s wire=v%u in=%lu out=%lu " - "uptime=%lus mcps=%u/3 status=0x%02X\n", + "uptime=%lus mcps=%u/3 status=0x%02X " + "banks=0x%02X\n", mac.c_str(), out.version[0] ? out.version : "?", (unsigned)out.fw_version, @@ -264,11 +304,33 @@ bool readTrafficSlot(const String& mac, Snapshot& out, uint8_t slot, (unsigned long)out.total_out, (unsigned long)out.uptime_s, (unsigned)out.mcps_healthy, - out.status_flags); + out.status_flags, + (unsigned)out.bank_mask); } } } + // Re-assert the emitter-bank mask on the connection we already have. + // Ordered before the night-mode grant only because it is the cheaper + // failure: the two are independent, and a counter can perfectly well be + // running one bank AND suspended. + // + // Gated on the counter having reported a revision that understands the + // opcode. A failed read leaves out.fw_version at 0, which correctly + // skips the write — we have no idea what we are talking to. + if (out.present && out.fw_version >= wire::REV_LED_BANKS && + out.bank_mask != beeBankMask) { + if (writeBankMask(svc, beeBankMask)) { + Serial.printf("[TRAFFIC] %s: emitter banks 0x%02X -> 0x%02X\n", + mac.c_str(), (unsigned)out.bank_mask, + (unsigned)beeBankMask); + } else { + Serial.printf("[TRAFFIC] %s: emitter bank write refused " + "(wanted 0x%02X)\n", + mac.c_str(), (unsigned)beeBankMask); + } + } + // Now that the totals are in hand, decide — and apply on the // connection we already have: no extra scan, no extra connect, one // 5-byte write. Doing it after the read also means a counter about to diff --git a/firmware/src/device_prefs.cpp b/firmware/src/device_prefs.cpp index 4374ec4..f9b508c 100644 --- a/firmware/src/device_prefs.cpp +++ b/firmware/src/device_prefs.cpp @@ -187,6 +187,13 @@ void loadConfigFromPrefs() { nightEndMinute = (uint16_t)prefs.getUShort("nm_end", 6 * 60); nightMaxTraffic = prefs.getUInt("nm_maxtraf", 0); nightTimezone = prefs.getString("nm_tz", ""); + // HiveTraffic emitter banks. Same reason as the window above: a hub that + // boots without WiFi must still narrow its counters to what the beekeeper + // configured, rather than running the full 24 gates until it can reach the + // server. Defaults to all three on, matching globals.cpp and the counter's + // own power-on state. + beeBankMask = (uint8_t)prefs.getUChar("bc_banks", + BEECOUNTER_BANK_MASK_ALL); // HiveTraffic (wireless bee counter) MACs live in the hive registry // (gHives[].ble "beecounter" pairings) and are read there by @@ -208,6 +215,7 @@ void loadConfigFromPrefs() { (unsigned)(nightEndMinute / 60), (unsigned)(nightEndMinute % 60), nightTimezone.length() ? nightTimezone.c_str() : "UTC", (unsigned long)nightMaxTraffic); + Serial.printf("[PREFS] bee counter banks: 0x%02X\n", (unsigned)beeBankMask); } void markClaimRegistered() { @@ -239,6 +247,10 @@ void saveNightModePrefs() { prefs.putUShort("nm_end", nightEndMinute); prefs.putUInt("nm_maxtraf", nightMaxTraffic); prefs.putString("nm_tz", nightTimezone); + // The emitter-bank mask rides along: both are HiveTraffic power settings + // delivered by the same /config fetch, and splitting them across two NVS + // writes would buy nothing but a second flash erase cycle. + prefs.putUChar("bc_banks", beeBankMask); prefs.end(); } diff --git a/firmware/src/globals.cpp b/firmware/src/globals.cpp index c6a0c72..9fe8bd7 100644 --- a/firmware/src/globals.cpp +++ b/firmware/src/globals.cpp @@ -3,7 +3,7 @@ #include -const char* const FIRMWARE_VERSION = "0.24.18"; +const char* const FIRMWARE_VERSION = "0.24.19"; #if ENABLE_HX711 HX711 scale1; @@ -57,6 +57,12 @@ uint16_t nightStartMinute = 20 * 60; // 20:00 local uint16_t nightEndMinute = 6 * 60; // 06:00 local uint32_t nightMaxTraffic = 0; // 0 = no traffic gate String nightTimezone = ""; // empty = UTC + +// HiveTraffic emitter banks: all three enabled until /config says otherwise, +// which matches the counter's own power-on default. Assembled from the three +// per-bank booleans in fetchRemoteConfig() and re-asserted over BLE every +// cycle, because the counter deliberately does not persist it. +uint8_t beeBankMask = BEECOUNTER_BANK_MASK_ALL; unsigned long calibrationModeStartedMs = 0; unsigned long calibrationModeIntervalMs = CALIBRATION_MODE_DEFAULT_INTERVAL_MS; unsigned long calibrationModeTimeoutMs = CALIBRATION_MODE_DEFAULT_TIMEOUT_MS; diff --git a/firmware/src/hivehub_network.cpp b/firmware/src/hivehub_network.cpp index 0b55946..c9e7039 100644 --- a/firmware/src/hivehub_network.cpp +++ b/firmware/src/hivehub_network.cpp @@ -485,6 +485,36 @@ void fetchRemoteConfig() { } nightMaxTraffic = doc["beecounter_night_max_traffic"] | 0; nightTimezone = doc["timezone"] | ""; + + // HiveTraffic emitter banks, delivered as three booleans and assembled into + // the bitmask the counter's SET_BANKS opcode takes. Applied unconditionally + // for the same reason the window above is: the dashboard is the only source, + // so there is no local edit for a config_version gate to protect. + // + // Defaults are all-on, per field, so a server too old to know these keys + // leaves every counter running its full entrance rather than switching banks + // off on the strength of a missing key. + { + uint8_t mask = 0; + if (doc["beecounter_bank1_enabled"] | true) mask |= 0x01; + if (doc["beecounter_bank2_enabled"] | true) mask |= 0x02; + if (doc["beecounter_bank3_enabled"] | true) mask |= 0x04; + // An all-off mask is refused rather than stored. The dashboard will not + // save one and the counter will not apply one, so honouring it here would + // only produce a HiveHub that believes it switched a counter off and a + // counter that is still counting — with the disagreement invisible until + // someone compares the two. + if (mask == 0) { + Serial.println("[CONFIG] All bee counter banks disabled in config — ignoring"); + mask = BEECOUNTER_BANK_MASK_ALL; + } + if (mask != beeBankMask) { + Serial.printf("[CONFIG] Bee counter banks 0x%02X -> 0x%02X\n", + (unsigned)beeBankMask, (unsigned)mask); + } + beeBankMask = mask; + } + if (nightModeEnabled) { Serial.printf("[CONFIG] Night mode %02u:%02u-%02u:%02u %s, max traffic %lu\n", (unsigned)(nightStartMinute / 60), (unsigned)(nightStartMinute % 60), @@ -493,8 +523,8 @@ void fetchRemoteConfig() { (unsigned long)nightMaxTraffic); } // Persisted so a hub that boots without WiFi still honours the last known - // window instead of running the emitters all night waiting for a config it - // cannot fetch. + // window and bank mask, instead of running the emitters all night — or all + // 24 gates — waiting for a config it cannot fetch. saveNightModePrefs(); // Bridge calibration into the hive registry (the authoritative source for the diff --git a/server/config.py b/server/config.py index 217ce7f..20139f6 100644 --- a/server/config.py +++ b/server/config.py @@ -7,7 +7,7 @@ # Backend server version. Bump on releases; reported in the OpenAPI metadata # (/docs) and by GET /health so a deployment's version is visible remotely. -SERVER_VERSION = "0.3.1" +SERVER_VERSION = "0.3.2" DATABASE_URL = os.environ["DATABASE_URL"] API_KEY = os.environ["API_KEY"] diff --git a/server/dashboard/assets/views.js b/server/dashboard/assets/views.js index 2f313ab..0a49a3c 100644 --- a/server/dashboard/assets/views.js +++ b/server/dashboard/assets/views.js @@ -2033,6 +2033,89 @@ function renderDevice(root, state) { } catch (err) { state.toast(err.message, "error"); nmSaveBtn.disabled = false; } }); + // ── HiveTraffic emitter banks ─────────────────────────────────────────── + // The counter's 48 IR emitters sit behind three MOSFETs, one per MCP23017, so + // each third of the entrance can be switched off independently. This is the + // coarsest power control the counter has and the one that applies around the + // clock, where night mode only applies at night. + const BANK_FIELDS = [ + ["beecounter_bank1_enabled", "Bank 1 — gates 00–07"], + ["beecounter_bank2_enabled", "Bank 2 — gates 10–17"], + ["beecounter_bank3_enabled", "Bank 3 — gates 20–27"], + ]; + const bankInputs = BANK_FIELDS.map(([key, label]) => { + const input = el("input", { type: "checkbox" }); + // ?? true, not || true: a stored false must survive. The default is on, + // which is also what a server too old to know these keys implies. + input.checked = cfg[key] ?? true; + return { key, label, input }; + }); + const bankSaveBtn = el("button", { class: "btn", type: "submit" }, "Save emitter banks"); + const bankForm = el("form", {}, + el("div", { class: "config-grid" }, + el("div", { class: "config-block" }, + el("h3", {}, "Emitter banks"), + el("p", { class: "note" }, + "A HiveTraffic counter's 48 IR emitters are driven by three MOSFETs, " + + "one per group of eight gates, and they are the largest item in its " + + "power budget by an order of magnitude. Switching a group off is " + + "purely about current draw — measured on the counter's 3.3 V rail:"), + el("div", { class: "rows" }, + el("div", { class: "row" }, + el("span", { class: "k" }, "1 bank — 8 gates"), + el("span", { class: "v" }, "~0.14 A")), + el("div", { class: "row" }, + el("span", { class: "k" }, "2 banks — 16 gates"), + el("span", { class: "v" }, "~0.22 A")), + el("div", { class: "row" }, + el("span", { class: "k" }, "3 banks — 24 gates"), + el("span", { class: "v" }, "~0.30 A"))), + el("p", { class: "note" }, + "That is roughly 80 mA per bank. Turn one off when the hive entrance " + + "is narrower than 24 gates, when part of it is closed, or when an " + + "off-grid supply will not carry the whole board. This stacks with " + + "night mode above rather than replacing it: night mode decides when " + + "the counter stops, this decides how much of it runs at all."), + ...bankInputs.map(({ label, input }) => + el("div", { class: "form-row" }, + el("label", {}, el("span", {}, label + " "), input))), + el("p", { class: "note" }, + "A switched-off bank stops counting its eight gates entirely, so " + + "their crossings are not missing from the data — they are not " + + "happening. Expect the totals to drop roughly in proportion, and do " + + "not compare a narrowed counter's numbers with its own history. At " + + "least one bank must stay on; a counter that should count nothing is " + + "unpaired instead."))), + el("p", { class: "note" }, + "Applies to every HiveTraffic counter paired to this device, and needs " + + "counter firmware 0.3.0 or newer. Saving bumps the config version; the " + + "counter is told on the next upload cycle, and is re-told after any " + + "reset — the setting is deliberately not stored on the counter itself."), + el("div", { class: "form-actions" }, bankSaveBtn)); + + bankForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const patch = {}; + for (const { key, input } of bankInputs) { + if (input.checked !== (cfg[key] ?? true)) patch[key] = input.checked; + } + // Caught here as well as by the API, because this is the only place a human + // is looking. The counter refuses an all-off mask outright — it keeps the + // mask it had — so storing one would leave three unticked boxes next to a + // counter cheerfully counting all 24 gates, with nothing saying why. + if (!bankInputs.some(({ input }) => input.checked)) { + state.toast("At least one emitter bank must stay enabled — unpair the counter instead", "error"); + return; + } + if (!Object.keys(patch).length) { state.toast("No changes to save"); return; } + bankSaveBtn.disabled = true; + try { + await state.actions.updateConfig(patch); + state.toast("Emitter banks saved", "success"); + state.reload({ full: true }); + } catch (err) { state.toast(err.message, "error"); bankSaveBtn.disabled = false; } + }); + const cfgSaveBtn = el("button", { class: "btn", type: "submit" }, "Save configuration"); const metaRow = (k, v) => el("div", { class: "row" }, el("span", { class: "k" }, k), el("span", { class: "v" }, v)); const cfgForm = el("form", {}, @@ -2719,7 +2802,7 @@ function renderDevice(root, state) { node.append( topGrid, collapsible("Configuration", false, cfgForm, calCard), - collapsible("HiveTraffic setup", false, nmForm), + collapsible("HiveTraffic setup", false, nmForm, bankForm), // Publishing is optional server-side (ENABLE_PUBLIC_EMBEDS): when it is off, // every /publish endpoint 404s, so the panel is not built at all rather than // offered and then failing on open. state.features comes from the auth diff --git a/server/db.py b/server/db.py index 2aae5cb..b1fb812 100644 --- a/server/db.py +++ b/server/db.py @@ -466,6 +466,22 @@ def init_db(): ALTER TABLE device_configs ADD CONSTRAINT device_configs_night_max_traffic_check CHECK (beecounter_night_max_traffic >= 0); + -- HiveTraffic emitter-bank enables (migration 026). One + -- IRLB8721 MOSFET per MCP23017 since the 2026-08 board, so each + -- third of the entrance is independently switchable: bank 1 = + -- gates 00..07, bank 2 = 10..17, bank 3 = 20..27. Measured at + -- 3.3 V, one bank draws ~0.14 A, two ~0.22 A, three ~0.30 A. + -- All TRUE by default: a counter runs its whole entrance until + -- someone deliberately narrows it. Three booleans rather than a + -- bitmask because the dashboard draws three checkboxes and a + -- config dump should be readable. No CHECK forbidding all-false + -- here — the dashboard refuses it and the counter refuses to + -- apply it, and a constraint would break a PATCH that turns the + -- last one off and another one on at once. + ALTER TABLE device_configs ADD COLUMN IF NOT EXISTS beecounter_bank1_enabled BOOLEAN NOT NULL DEFAULT true; + ALTER TABLE device_configs ADD COLUMN IF NOT EXISTS beecounter_bank2_enabled BOOLEAN NOT NULL DEFAULT true; + ALTER TABLE device_configs ADD COLUMN IF NOT EXISTS beecounter_bank3_enabled BOOLEAN NOT NULL DEFAULT true; + CREATE TABLE IF NOT EXISTS firmware_releases ( id BIGSERIAL PRIMARY KEY, version TEXT NOT NULL, diff --git a/server/devices.py b/server/devices.py index ad72066..eb7b00f 100644 --- a/server/devices.py +++ b/server/devices.py @@ -90,7 +90,8 @@ def ensure_device_config( scale1_tempco_kg_per_c, scale2_tempco_kg_per_c, scale_offsets_by_hive, beecounter_night_mode_enabled, beecounter_night_start_minute, - beecounter_night_end_minute, beecounter_night_max_traffic, timezone + beecounter_night_end_minute, beecounter_night_max_traffic, timezone, + beecounter_bank1_enabled, beecounter_bank2_enabled, beecounter_bank3_enabled """ @@ -170,6 +171,9 @@ def device_config_row_to_model(r) -> DeviceConfig: beecounter_night_end_minute=r[15], beecounter_night_max_traffic=r[16], timezone=r[17], + beecounter_bank1_enabled=r[18], + beecounter_bank2_enabled=r[19], + beecounter_bank3_enabled=r[20], ) @@ -191,6 +195,44 @@ def get_device_config(device_id: str): return fetch_device_config(device_id) +BEECOUNTER_BANK_FIELDS = ( + "beecounter_bank1_enabled", + "beecounter_bank2_enabled", + "beecounter_bank3_enabled", +) + + +def reject_all_banks_off(device_id: str, fields: dict) -> None: + """Refuse a config patch that would leave a counter with no emitter bank on. + + A PATCH carries only what changed, so this needs the stored row as well as + the patch: turning bank 3 off is fine on its own and fatal if 1 and 2 are + already off. Merge, then check. + + Rejected rather than stored, because storing it accomplishes nothing. The + counter refuses a mask of 0 outright (HiveTraffic's bank_state.h keeps the + previous mask rather than blinding itself on one byte), so the row and the + hardware would simply disagree forever, with the dashboard showing three + unticked boxes next to a counter cheerfully counting all 24 gates. A + beekeeper who wants a counter to count nothing unpairs it. + """ + if not any(f in fields for f in BEECOUNTER_BANK_FIELDS): + return + current = fetch_device_config(device_id) + merged = [ + fields.get(f, getattr(current, f)) for f in BEECOUNTER_BANK_FIELDS + ] + if not any(merged): + raise HTTPException( + status_code=400, + detail=( + "At least one HiveTraffic emitter bank must stay enabled — a " + "counter with all three off would count nothing. Unpair the " + "counter instead." + ), + ) + + @router.patch("/api/v1/devices/{device_id}/config", dependencies=[Depends(require_device_key)]) def update_device_config(device_id: str, patch: DeviceConfigUpdate): ensure_device_config(device_id) @@ -201,6 +243,7 @@ def update_device_config(device_id: str, patch: DeviceConfigUpdate): hive_scales = fields.pop("hive_scales", None) if not fields and not hive_scales: return get_device_config(device_id) + reject_all_banks_off(device_id, fields) with get_conn() as conn: with conn.cursor() as cur: if hive_scales: diff --git a/server/migrations/026_bee_counter_led_banks.sql b/server/migrations/026_bee_counter_led_banks.sql new file mode 100644 index 0000000..197cb2e --- /dev/null +++ b/server/migrations/026_bee_counter_led_banks.sql @@ -0,0 +1,48 @@ +-- 026_bee_counter_led_banks.sql — per-bank emitter enables for HiveTraffic. +-- +-- The 2026-08 counter board puts one IRLB8721 MOSFET behind each MCP23017, so +-- each third of the entrance is independently switchable: +-- +-- bank 1 -> U2, gates 00..07 +-- bank 2 -> U3, gates 10..17 +-- bank 3 -> U4, gates 20..27 +-- +-- Measured on the counter's 3.3 V rail: one bank ~0.14 A, two ~0.22 A, three +-- ~0.30 A. Roughly 80 mA per bank on top of a ~60 mA floor, which makes this +-- the coarsest power control the counter has — for an entrance narrower than +-- 24 gates, or a supply that will not carry the full board. It stacks with +-- night mode (025) rather than replacing it: night mode decides WHEN the +-- counter stops, this decides HOW MUCH of it runs at all. +-- +-- beecounter_bank1_enabled gates 00..07 +-- beecounter_bank2_enabled gates 10..17 +-- beecounter_bank3_enabled gates 20..27 +-- +-- All three default to TRUE, so nothing changes for an existing device: a +-- counter runs its whole entrance until someone deliberately narrows it. +-- +-- Per DEVICE rather than per hive, for the same reason night mode is: the +-- alternative is a JSONB column and a second editor for a distinction nobody +-- has asked for. A hub whose hives need different bank masks is a hub whose +-- counters should be split across two devices. +-- +-- Deliberately three booleans rather than one bitmask integer. The dashboard +-- draws three checkboxes, the API is read by humans, and "banks = 5" is a +-- worse thing to find in a config dump than "bank2 = false". The firmware +-- assembles the mask; the database stores what was ticked. +-- +-- There is intentionally NO constraint forbidding all three false. The +-- dashboard refuses to save it and the counter refuses to apply it (a mask of +-- 0 leaves the previous mask in force), so the database is the wrong place for +-- a third opinion — and a CHECK here would fail a PATCH that turns the last +-- one off and another one on in the same request. +-- +-- init_db() in server/main.py applies the same columns idempotently; this is +-- the standalone migration for an already-running database. + +ALTER TABLE device_configs + ADD COLUMN IF NOT EXISTS beecounter_bank1_enabled BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE device_configs + ADD COLUMN IF NOT EXISTS beecounter_bank2_enabled BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE device_configs + ADD COLUMN IF NOT EXISTS beecounter_bank3_enabled BOOLEAN NOT NULL DEFAULT true; diff --git a/server/schemas.py b/server/schemas.py index d7c4093..c2fa5e8 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -87,9 +87,9 @@ class HiveBeeCounterIn(BaseModel): interval_in: Optional[int] = None interval_out: Optional[int] = None # The diagnostic fields the counter also reports — protocol_version, - # status_flags, uptime_s, num_gates, mcps_healthy, glitch_count, idle_s — - # are deliberately NOT declared. extra="allow" carries them through into - # hive_readings.raw_json, which is where they belong: nothing charts or + # status_flags, uptime_s, num_gates, mcps_healthy, glitch_count, idle_s, + # banks — are deliberately NOT declared. extra="allow" carries them through + # into hive_readings.raw_json, which is where they belong: nothing charts or # alerts on them, so a column each would be dead weight. # # mcps_healthy counts MCP23017 port expanders (0..3), NOT gates — each @@ -103,6 +103,15 @@ class HiveBeeCounterIn(BaseModel): # stop sensing because honey bees do not fly at night — rather than a failed # emitter bank. Nothing charts it either, but it is the only thing that # distinguishes the two, so it must reach raw_json intact. + # + # banks (wire revision 5) is the same argument at a third of the counter: + # a bitmask of the three emitter MOSFETs that are enabled (bit 0 = gates + # 00..07, and so on; 7 = all three). A cleared bit means those eight gates + # are deliberately dark, so their share of the totals is permanently flat — + # character for character what a dead FET produces. It is emitted on every + # reading, including the 7 of a counter nobody has narrowed, because a field + # that appeared only when interesting would make "all banks on" and "counter + # too old to say" the same absence. class HiveHeartIn(BaseModel): @@ -513,6 +522,25 @@ class DeviceConfig(BaseModel): # of it. A POSIX string rather than an IANA name because the ESP32 has no # tz database — newlib parses this form directly, DST rules included. timezone: str = "" + # ── HiveTraffic emitter banks (see firmware/include/bee_counter_client.h) ─ + # The counter's 48 IR emitters sit behind three IRLB8721 MOSFETs, one per + # MCP23017, so each third of the entrance is independently switchable: + # bank 1 = gates 00..07, bank 2 = 10..17, bank 3 = 20..27. Measured on the + # counter's 3.3 V rail, one bank draws ~0.14 A, two ~0.22 A and three + # ~0.30 A — about 80 mA per bank on top of a ~60 mA floor. + # + # All three ON by default: a counter runs its whole entrance until someone + # deliberately narrows it. Turning one off stops eight gates being counted + # at all, so their share of the totals goes permanently flat — which is why + # the counter reports the mask back (wire revision 5) rather than leaving it + # indistinguishable from a dead FET. + # + # Three booleans rather than one bitmask: the dashboard draws three + # checkboxes and this object is read by humans. The firmware assembles the + # mask it writes over BLE. + beecounter_bank1_enabled: bool = True + beecounter_bank2_enabled: bool = True + beecounter_bank3_enabled: bool = True class DeviceConfigUpdate(BaseModel): @@ -538,6 +566,13 @@ class DeviceConfigUpdate(BaseModel): beecounter_night_end_minute: Optional[int] = Field(default=None, ge=0, le=1439) beecounter_night_max_traffic: Optional[int] = Field(default=None, ge=0) timezone: Optional[str] = Field(default=None, max_length=64) + # Emitter banks. Not validated against each other here: a PATCH carries only + # the fields that changed, so "is the last bank being turned off?" cannot be + # answered from this object alone — it needs the stored row. That check runs + # in update_device_config(), which has both halves. + beecounter_bank1_enabled: Optional[bool] = None + beecounter_bank2_enabled: Optional[bool] = None + beecounter_bank3_enabled: Optional[bool] = None # The project was renamed HiveScale -> HiveHub, but the canonical OTA target diff --git a/test-data/test_bee_counter_banks.py b/test-data/test_bee_counter_banks.py new file mode 100644 index 0000000..5cf501b --- /dev/null +++ b/test-data/test_bee_counter_banks.py @@ -0,0 +1,181 @@ +"""Tests for the HiveTraffic emitter-bank config guard (server/devices.py). + +Run: python3 -m pytest test-data/test_bee_counter_banks.py + or: PYTHONPATH=server python3 test-data/test_bee_counter_banks.py (no DB needed) + +A counter's 48 IR emitters sit behind three MOSFETs, one per group of eight +gates, and each can be switched off to save roughly 80 mA at 3.3 V. All three +off is the one combination that must never be stored: the counter refuses a mask +of zero outright — it keeps whatever mask it had — so a stored all-off row would +leave the dashboard showing three unticked boxes next to a counter cheerfully +counting all 24 gates, with nothing anywhere saying why. + +The subtle half is that a PATCH carries only what CHANGED. "Is the last bank +being turned off?" therefore cannot be answered from the patch alone — turning +bank 3 off is perfectly fine on its own and fatal if 1 and 2 are already off — +so the guard has to merge the patch onto the stored row first. That merge is +what this file pins; it is the kind of thing that looks obviously right and is +obviously wrong the first time somebody sends a one-field patch. +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "server")) + +# devices.py pulls in the app's config module, which requires these to exist. No +# database or network is touched — fetch_device_config is stubbed out below. +os.environ.setdefault("DATABASE_URL", "postgresql://localhost/test") +os.environ.setdefault("API_KEY", "test-api-key") +os.environ.setdefault("JWT_SECRET", "test-jwt-secret") + +import devices # noqa: E402 +from fastapi import HTTPException # noqa: E402 +from schemas import DeviceConfig, DeviceConfigUpdate # noqa: E402 + + +_failures = 0 + + +def check(name, condition): + global _failures + status = "ok" if condition else "FAIL" + if not condition: + _failures += 1 + print(f"[{status}] {name}") + + +def stored(b1=True, b2=True, b3=True): + """Install a stored config with the given bank state and return it.""" + cfg = DeviceConfig( + device_id="dev-1", + beecounter_bank1_enabled=b1, + beecounter_bank2_enabled=b2, + beecounter_bank3_enabled=b3, + ) + devices.fetch_device_config = lambda device_id: cfg + return cfg + + +def rejects(fields): + """Does the guard refuse this patch against the currently stored config?""" + try: + devices.reject_all_banks_off("dev-1", dict(fields)) + except HTTPException as exc: + return exc.status_code == 400 + return False + + +def test_defaults_are_all_on(): + # The whole feature is opt-in: an existing device keeps counting its full + # entrance until someone deliberately narrows it. + cfg = DeviceConfig(device_id="dev-1") + check( + "all three banks default to enabled", + cfg.beecounter_bank1_enabled + and cfg.beecounter_bank2_enabled + and cfg.beecounter_bank3_enabled, + ) + patch = DeviceConfigUpdate() + check( + "an empty patch touches no bank field", + not any(f in patch.model_dump(exclude_unset=True) + for f in devices.BEECOUNTER_BANK_FIELDS), + ) + + +def test_turning_one_bank_off_is_fine(): + stored() + check("bank 1 off, 2 and 3 on", not rejects({"beecounter_bank1_enabled": False})) + check("bank 2 off, 1 and 3 on", not rejects({"beecounter_bank2_enabled": False})) + check("bank 3 off, 1 and 2 on", not rejects({"beecounter_bank3_enabled": False})) + + +def test_turning_two_banks_off_is_fine(): + # One bank on is a legitimate configuration — an eight-gate entrance at + # ~0.14 A instead of ~0.30 A is most of the point of the feature. + stored() + check( + "two off in one patch leaves one on", + not rejects({ + "beecounter_bank1_enabled": False, + "beecounter_bank2_enabled": False, + }), + ) + + +def test_the_last_bank_cannot_be_turned_off(): + # The case a patch-only check would miss entirely: this patch touches ONE + # field and sets it to False, which is indistinguishable from the perfectly + # legal patch above without the stored row. + stored(b1=False, b2=False, b3=True) + check( + "turning off the last enabled bank is rejected", + rejects({"beecounter_bank3_enabled": False}), + ) + check( + "turning off an already-off bank is a no-op, not a rejection", + not rejects({"beecounter_bank1_enabled": False}), + ) + + +def test_all_three_off_in_one_patch_is_rejected(): + stored() + check( + "a patch disabling all three is rejected", + rejects({ + "beecounter_bank1_enabled": False, + "beecounter_bank2_enabled": False, + "beecounter_bank3_enabled": False, + }), + ) + + +def test_a_patch_may_swap_which_bank_is_on(): + # Why there is no database CHECK constraint: this patch passes through a + # moment where bank 3 is off and bank 1 is not yet on, and a column-level + # constraint has no way to see them as one edit. + stored(b1=False, b2=False, b3=True) + check( + "turning the last one off while turning another on is allowed", + not rejects({ + "beecounter_bank3_enabled": False, + "beecounter_bank1_enabled": True, + }), + ) + + +def test_unrelated_patches_skip_the_check_entirely(): + # The guard costs a config read, so it must not run on every PATCH. With + # all banks already off in the stored row (which the guard itself makes + # unreachable, but a hand-edited database could produce), a patch that + # touches no bank field still has to sail through — it is not this edit's + # business to fail on a state it did not create. + stored(b1=False, b2=False, b3=False) + check( + "a send-interval patch is not blocked by bank state", + not rejects({"send_interval_seconds": 900}), + ) + + +def main(): + test_defaults_are_all_on() + test_turning_one_bank_off_is_fine() + test_turning_two_banks_off_is_fine() + test_the_last_bank_cannot_be_turned_off() + test_all_three_off_in_one_patch_is_rejected() + test_a_patch_may_swap_which_bank_is_on() + test_unrelated_patches_skip_the_check_entirely() + + if _failures: + print(f"\n{_failures} check(s) FAILED") + return 1 + print("\nAll emitter-bank config checks passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +else: + def test_all(): + assert main() == 0 diff --git a/test-data/test_bee_counter_wire.cpp b/test-data/test_bee_counter_wire.cpp index a67dbd1..3ca23c8 100644 --- a/test-data/test_bee_counter_wire.cpp +++ b/test-data/test_bee_counter_wire.cpp @@ -81,6 +81,24 @@ static const char* kV4Idle = "\"num_gates\":24,\"mcps_healthy\":3,\"total_in\":8123," "\"total_out\":8090,\"glitches\":2,\"idle_s\":1187}"; +// The same device again after the emitter-bank update, running everything: +// every v4 key unchanged plus "banks", which is 7 whenever all three MOSFETs +// are enabled — which is every counter nobody has deliberately narrowed. +static const char* kV5 = + "{\"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}"; + +// A counter running one bank: eight gates counted, sixteen deliberately dark. +// num_gates still reports 24 because that is what is WIRED; the mask is what +// says how much of it is looking. This is the document that has to be +// distinguishable from a counter with two dead emitter FETs, which produces the +// same permanently flat two thirds of the totals. +static const char* kV5OneBank = + "{\"fw\":5,\"ver\":\"0.3.0\",\"uptime_s\":9000,\"status\":15," + "\"num_gates\":24,\"mcps_healthy\":3,\"total_in\":410," + "\"total_out\":402,\"glitches\":1,\"idle_s\":0,\"banks\":1}"; + // --------------------------------------------------------------------------- static void test_v2_document() { @@ -143,6 +161,78 @@ static void test_v4_suspended_document() { eqi("total_in", m.total_in, 8123); } +static void test_v5_document() { + g_case = "fw:5"; + std::printf("fw:5 document (all three emitter banks live):\n"); + const Measurement m = parseOrDie(kV5); + eqi("protocol_version", m.protocol_version, 5); + eqs("version", m.version, "0.3.0"); + eqi("bank_mask", m.bank_mask, 7); + eqi("active gates", activeGates(m), 24); + check("no bank disabled", !hasDisabledBank(m)); + // Everything v4 carried still decodes: the revision is purely additive. + eqi("mcps_healthy", m.mcps_healthy, 3); + eqi("idle_s", m.idle_s, 0); + eqi("total_in", m.total_in, 100); +} + +static void test_v5_narrowed_document() { + // Why the field exists. Two thirds of the entrance contributing nothing, for + // good, is character for character what two dead emitter FETs produce; the + // mask is the only thing separating "deliberately narrowed" from "broken". + g_case = "fw:5 one bank"; + std::printf("fw:5 document (counter narrowed to one bank):\n"); + const Measurement m = parseOrDie(kV5OneBank); + eqi("bank_mask", m.bank_mask, 1); + eqi("active gates", activeGates(m), 8); + check("a bank is disabled", hasDisabledBank(m)); + // num_gates describes the BOARD and must not move with the mask — every + // stored reading would silently change meaning if it did. + eqi("num_gates", m.num_gates, 24); + // And it is not suspended: the two power controls are independent, and a + // narrowed counter is still counting on the banks it has. + check("not night idle", !isNightIdle(m)); +} + +static void test_older_counters_report_every_bank_on() { + // The default that matters. A counter too old to report "banks" is running + // its whole entrance — it has no way not to — so the absence of the field + // must read as 0x07, never as 0. A zero default would make every counter the + // OTA relay has not reached yet look like it had been switched off, and that + // reading would be written into stored history. + g_case = "pre-v5 counters"; + const Measurement v2 = parseOrDie(kV2); + const Measurement v4 = parseOrDie(kV4); + eqi("v2 bank_mask", v2.bank_mask, BANK_MASK_ALL); + eqi("v4 bank_mask", v4.bank_mask, BANK_MASK_ALL); + eqi("v4 active gates", activeGates(v4), 24); + check("v2 no bank disabled", !hasDisabledBank(v2)); + check("v4 no bank disabled", !hasDisabledBank(v4)); +} + +static void test_phantom_banks_are_masked_off() { + // A four-FET board's mask arriving at this parser must not report a bank + // HiveHub would then try to address. Bits above the third are dropped. + g_case = "phantom bank bits"; + const Measurement m = parseOrDie("{\"fw\":5,\"banks\":255}"); + eqi("bank_mask", m.bank_mask, BANK_MASK_ALL); + eqi("active gates", activeGates(m), 24); + // A mask of 0 is not something the counter can be in — it refuses one — so + // it is stored as read rather than flattened to ALL, which would hide it. + const Measurement zero = parseOrDie("{\"fw\":5,\"banks\":0}"); + eqi("zero stays zero", zero.bank_mask, 0); + eqi("zero active gates", activeGates(zero), 0); +} + +static void test_bank_constants_match_the_counter() { + // Duplicated from HiveTraffic's counter_protocol.h because the two firmwares + // share no header. If the counter grows a fourth FET, or moves the revision + // that introduced the field, this is what catches it on this side. + g_case = "bank constants"; + check("BANK_MASK_ALL is three banks", BANK_MASK_ALL == 0x07); + check("REV_LED_BANKS is 5", REV_LED_BANKS == 5); +} + static void test_night_status_bit_matches_the_counter() { // Duplicated from HiveTraffic's counter_protocol.h because the two firmwares // share no header. If the counter ever moves the bit, this is what catches it @@ -327,6 +417,11 @@ int main() { test_v3_document(); test_v4_document(); test_v4_suspended_document(); + test_v5_document(); + test_v5_narrowed_document(); + test_older_counters_report_every_bank_on(); + test_phantom_banks_are_masked_off(); + test_bank_constants_match_the_counter(); test_night_status_bit_matches_the_counter(); test_older_counters_report_no_suspension(); test_both_revisions_agree(); diff --git a/website/dashboard-demo/assets/api.js b/website/dashboard-demo/assets/api.js index 6b8a9a5..9064a29 100644 --- a/website/dashboard-demo/assets/api.js +++ b/website/dashboard-demo/assets/api.js @@ -172,6 +172,10 @@ export const api = { tempco_ref_temp_c: 25.0, scale1_tempco_kg_per_c: -0.012, scale2_tempco_kg_per_c: -0.011, beecounter_night_mode_enabled: true, beecounter_night_start_minute: 21 * 60, beecounter_night_end_minute: 5 * 60 + 30, beecounter_night_max_traffic: 60, + // All three emitter banks live: the counter is running its whole 24-gate + // entrance, which is the default and what the demo device does. + beecounter_bank1_enabled: true, beecounter_bank2_enabled: true, + beecounter_bank3_enabled: true, timezone: "CET-1CEST,M3.5.0,M10.5.0/3", }), diff --git a/website/dashboard-demo/assets/views.js b/website/dashboard-demo/assets/views.js index bbe9fc1..fc5b487 100644 --- a/website/dashboard-demo/assets/views.js +++ b/website/dashboard-demo/assets/views.js @@ -1769,6 +1769,89 @@ function renderDevice(root, state) { "the config version; the counter is told on the next upload cycle."), el("div", { class: "form-actions" }, nmSaveBtn)); + // ── HiveTraffic emitter banks ─────────────────────────────────────────── + // The counter's 48 IR emitters sit behind three MOSFETs, one per MCP23017, so + // each third of the entrance can be switched off independently. This is the + // coarsest power control the counter has and the one that applies around the + // clock, where night mode only applies at night. + const BANK_FIELDS = [ + ["beecounter_bank1_enabled", "Bank 1 — gates 00–07"], + ["beecounter_bank2_enabled", "Bank 2 — gates 10–17"], + ["beecounter_bank3_enabled", "Bank 3 — gates 20–27"], + ]; + const bankInputs = BANK_FIELDS.map(([key, label]) => { + const input = el("input", { type: "checkbox" }); + // ?? true, not || true: a stored false must survive. The default is on, + // which is also what a server too old to know these keys implies. + input.checked = cfg[key] ?? true; + return { key, label, input }; + }); + const bankSaveBtn = el("button", { class: "btn", type: "submit" }, "Save emitter banks"); + const bankForm = el("form", {}, + el("div", { class: "config-grid" }, + el("div", { class: "config-block" }, + el("h3", {}, "Emitter banks"), + el("p", { class: "note" }, + "A HiveTraffic counter's 48 IR emitters are driven by three MOSFETs, " + + "one per group of eight gates, and they are the largest item in its " + + "power budget by an order of magnitude. Switching a group off is " + + "purely about current draw — measured on the counter's 3.3 V rail:"), + el("div", { class: "rows" }, + el("div", { class: "row" }, + el("span", { class: "k" }, "1 bank — 8 gates"), + el("span", { class: "v" }, "~0.14 A")), + el("div", { class: "row" }, + el("span", { class: "k" }, "2 banks — 16 gates"), + el("span", { class: "v" }, "~0.22 A")), + el("div", { class: "row" }, + el("span", { class: "k" }, "3 banks — 24 gates"), + el("span", { class: "v" }, "~0.30 A"))), + el("p", { class: "note" }, + "That is roughly 80 mA per bank. Turn one off when the hive entrance " + + "is narrower than 24 gates, when part of it is closed, or when an " + + "off-grid supply will not carry the whole board. This stacks with " + + "night mode above rather than replacing it: night mode decides when " + + "the counter stops, this decides how much of it runs at all."), + ...bankInputs.map(({ label, input }) => + el("div", { class: "form-row" }, + el("label", {}, el("span", {}, label + " "), input))), + el("p", { class: "note" }, + "A switched-off bank stops counting its eight gates entirely, so " + + "their crossings are not missing from the data — they are not " + + "happening. Expect the totals to drop roughly in proportion, and do " + + "not compare a narrowed counter's numbers with its own history. At " + + "least one bank must stay on; a counter that should count nothing is " + + "unpaired instead."))), + el("p", { class: "note" }, + "Applies to every HiveTraffic counter paired to this device, and needs " + + "counter firmware 0.3.0 or newer. Saving bumps the config version; the " + + "counter is told on the next upload cycle, and is re-told after any " + + "reset — the setting is deliberately not stored on the counter itself."), + el("div", { class: "form-actions" }, bankSaveBtn)); + + bankForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const patch = {}; + for (const { key, input } of bankInputs) { + if (input.checked !== (cfg[key] ?? true)) patch[key] = input.checked; + } + // Caught here as well as by the API, because this is the only place a human + // is looking. The counter refuses an all-off mask outright — it keeps the + // mask it had — so storing one would leave three unticked boxes next to a + // counter cheerfully counting all 24 gates, with nothing saying why. + if (!bankInputs.some(({ input }) => input.checked)) { + state.toast("At least one emitter bank must stay enabled — unpair the counter instead", "error"); + return; + } + if (!Object.keys(patch).length) { state.toast("No changes to save"); return; } + bankSaveBtn.disabled = true; + try { + await state.actions.updateConfig(patch); + state.toast("Emitter banks saved", "success"); + state.reload({ full: true }); + } catch (err) { state.toast(err.message, "error"); bankSaveBtn.disabled = false; } + }); + const cfgSaveBtn = el("button", { class: "btn", type: "submit" }, "Save configuration"); const metaRow = (k, v) => el("div", { class: "row" }, el("span", { class: "k" }, k), el("span", { class: "v" }, v)); const cfgForm = el("form", {}, @@ -2451,7 +2534,7 @@ function renderDevice(root, state) { node.append( topGrid, collapsible("Configuration", false, cfgForm, calCard), - collapsible("HiveTraffic setup", false, nmForm), + collapsible("HiveTraffic setup", false, nmForm, bankForm), // Publishing is optional server-side (ENABLE_PUBLIC_EMBEDS): when it is off, // every /publish endpoint 404s, so the panel is not built at all rather than // offered and then failing on open. state.features comes from the auth