From 0125bb64646196826b366aa69d1ce37212c139c1 Mon Sep 17 00:00:00 2001 From: Maxi Nitschke Date: Sat, 22 Aug 2026 19:31:44 +0000 Subject: [PATCH] Let a HiveTraffic counter's three emitter banks be switched individually MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The counter's 48 IR emitters sit behind three MOSFETs, one per group of eight gates, and they dominate its power draw by an order of magnitude. Measured on the counter's 3.3 V rail, one bank draws ~0.14 A, two ~0.22 A and three ~0.30 A — roughly 80 mA per bank on top of a ~60 mA floor. For an entrance narrower than 24 gates, part of one closed for the season, or an off-grid supply that will not carry the whole board, switching a bank off is the coarsest and most effective control available, and unlike night mode it applies around the clock. The two compose: a counter can be running one bank and be suspended. Dashboard → HiveTraffic setup → Emitter banks: three checkboxes, all ticked by default, alongside the night window and with the measured current per configuration spelled out — the point of the setting is the power, so the numbers belong next to the boxes rather than in a doc. Stored per device as three booleans (migration 026, all defaulting to true) and assembled by the firmware into the bitmask HiveTraffic's SET_BANKS opcode takes. Three columns rather than one integer because the dashboard draws three checkboxes and "banks = 5" is a worse thing to find in a config dump than "bank2 = false". All three off is rejected — in the dashboard, and in update_device_config with a 400. The counter refuses a mask of zero outright (it keeps the mask it had), so storing one would only leave three unticked boxes next to a counter cheerfully counting all 24 gates with nothing saying why. The check needs the stored row as well as the patch, because a PATCH carries only what changed and "turn bank 3 off" is fine on its own and fatal if 1 and 2 are already off; that merge is what test_bee_counter_banks.py pins. There is deliberately no database CHECK: a constraint could not see "turn the last one off and another one on" as a single edit. The mask is written on the connection the measurement read already has, and only when the mask the counter just REPORTED differs from the configured one — never against a HiveHub-side memory of what it last sent. The counter does not persist the mask, so one that browned out or rebooted out of an OTA comes back running all 24 gates and the next cycle's read finds and fixes it; a HiveHub that remembered "already configured" would leave it wide open indefinitely. bee_counter_wire.h learns wire revision 5's "banks" field, defaulting to all three on rather than to zero — a counter too old to report it is running its whole entrance, and reading that absence as "everything is off" would write the misreading into stored history. Bits above the third bank are masked off. Backend to 0.3.2, firmware to 0.24.19. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TCTPLvKrrm1RmiaVHGxz4D --- .github/workflows/ci.yml | 5 +- docs/api.md | 32 +++- docs/hivetraffic-bee-counter.md | 122 ++++++++++-- firmware/include/bee_counter_client.h | 10 + firmware/include/bee_counter_wire.h | 53 +++++ firmware/include/config.h | 14 ++ firmware/include/device_prefs.h | 5 +- firmware/include/globals.h | 18 ++ firmware/src/bee_counter_client.cpp | 66 ++++++- firmware/src/device_prefs.cpp | 12 ++ firmware/src/globals.cpp | 8 +- firmware/src/hivehub_network.cpp | 34 +++- server/config.py | 2 +- server/dashboard/assets/views.js | 85 +++++++- server/db.py | 16 ++ server/devices.py | 45 ++++- .../migrations/026_bee_counter_led_banks.sql | 48 +++++ server/schemas.py | 41 +++- test-data/test_bee_counter_banks.py | 181 ++++++++++++++++++ test-data/test_bee_counter_wire.cpp | 95 +++++++++ website/dashboard-demo/assets/api.js | 4 + website/dashboard-demo/assets/views.js | 85 +++++++- 22 files changed, 951 insertions(+), 30 deletions(-) create mode 100644 server/migrations/026_bee_counter_led_banks.sql create mode 100644 test-data/test_bee_counter_banks.py 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