From 5324ebb2fa6be575889a4e47d803910149878db9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 09:29:26 +0000 Subject: [PATCH 1/4] Add HiveTraffic night mode: schedule, traffic gate, config and UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A HiveTraffic counter's 48 IR emitters are its power budget — 24 series pairs behind 22 ohm ballast, all lit for the settle+read window of every 5 ms poll, roughly 0.5-1.0 A peak and an order of magnitude above the ~18 mA everything else on that board draws. European honey bees are diurnal: flight needs light, and stops below roughly 10 C regardless. So every night that current buys nothing. HiveHub now owns the schedule, because the counter cannot: it has no RTC, no NVS and no network, while this device has NTP and a DS3231 good to +/-2 ppm. Once per upload cycle the existing GATT session carries one extra 5-byte write telling the counter how long to stop sensing — no extra scan, no extra connect. The counter forgets it on any reset and it expires on its own, so a HiveHub that dies mid-night cannot leave a counter blind. The decision (firmware/include/night_mode.h) is a pure header with host tests, because it goes wrong quietly in both directions: suspend when you should not and a day's foraging becomes a row of zeros that reads as bad weather; fail to suspend and nothing happens except a flat battery three weeks later. Every uncertain path returns "keep counting" — no valid local time, no traffic baseline, a window whose ends are equal, a relay in flight. The tests cover midnight wrap, an unsynced clock, the traffic gate and a whole simulated night cycle by cycle. The traffic gate is the "not yet" rule the feature was asked for: if more than N bees crossed in the last cycle, night mode waits. It differences the previous cycle's totals, held in RTC memory across HiveHub's own deep sleep — without that there would never be a previous reading and the gate would postpone forever. A hive with no baseline is postponed rather than waved through: "I don't know how busy this is" is not "it is quiet". Timezone handling is load-bearing rather than cosmetic. The device clock is UTC (configTime(0, 0, ...)), so without a TZ a window entered as 20:00 would fire at 21:00 local in summer, discarding an hour of foraging on exactly the long evenings that have most of it. Stored as a POSIX TZ string because the ESP32 has no tz database; the dashboard offers presets. Ships OFF. Settings live in device_configs (migration 025, applied unconditionally on each config fetch rather than behind the config_version gate — that gate exists to protect portal-side calibration, and night mode has no portal counterpart), and are edited under Device & admin. Persisted in NVS so a hub that boots without WiFi still honours the last known window, which is the deployment the feature is for. The wire parser learns fw:4 — idle_s and status bit 0x80 — and keeps reading fw:2 and fw:3, since a counter reports the old revision until the OTA relay reaches it and the relay must read this characteristic first. The addition is purely additive, so the key-skipping parser already read v4 documents; what it could not do is tell a suspended counter from a broken one, which is the whole point of reading it. Also settles the interaction with the standing "relay firmware at night" advice, which deep sleep would have broken: a suspended counter stays advertising, so relays run exactly as in daylight, and HiveHub declines to suspend a counter whose relay is unfinished. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016gFpezSuXW3sHMd1xaSYnd --- .github/workflows/ci.yml | 15 +- docs/api.md | 30 +- docs/hivetraffic-bee-counter.md | 109 ++++++- firmware/include/bee_counter_client.h | 5 + firmware/include/bee_counter_wire.h | 35 ++- firmware/include/config.h | 14 + firmware/include/device_prefs.h | 3 + firmware/include/globals.h | 30 ++ firmware/include/night_mode.h | 204 +++++++++++++ firmware/include/sensors.h | 7 + firmware/src/bee_counter_client.cpp | 172 ++++++++++- firmware/src/device_prefs.cpp | 30 ++ firmware/src/globals.cpp | 20 ++ firmware/src/hivehub_network.cpp | 34 +++ firmware/src/sensors.cpp | 62 ++++ server/dashboard/assets/views.js | 123 +++++++- server/db.py | 24 ++ server/devices.py | 9 +- .../migrations/025_bee_counter_night_mode.sql | 59 ++++ server/schemas.py | 44 ++- test-data/test_bee_counter_wire.cpp | 82 +++++ test-data/test_night_mode.cpp | 284 ++++++++++++++++++ website/dashboard-demo/assets/api.js | 3 + website/dashboard-demo/assets/views.js | 81 ++++- 24 files changed, 1455 insertions(+), 24 deletions(-) create mode 100644 firmware/include/night_mode.h create mode 100644 server/migrations/025_bee_counter_night_mode.sql create mode 100644 test-data/test_night_mode.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4187861..be385be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,14 +72,25 @@ 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 ones current firmware emits. Both - # captured documents are asserted here. + # reached yet, as well as the fw:3 and fw:4 ones newer firmware emits. + # A captured document of each revision is asserted here. - name: Build and run bee counter wire tests run: | set -e g++ -std=gnu++17 -Wall -Wextra -Werror -I firmware/include \ -o /tmp/test_bee_counter_wire test-data/test_bee_counter_wire.cpp /tmp/test_bee_counter_wire + # Night mode decides whether to blind a bee counter, and gets it wrong + # quietly in both directions — a lost day of foraging looks like bad + # weather, and a counter that never sleeps looks like nothing at all. + # Midnight wrap, an unsynced clock and the traffic gate are all pinned + # here rather than left to a season of field observation. + - name: Build and run night mode tests + run: | + set -e + g++ -std=gnu++17 -Wall -Wextra -Werror -I firmware/include \ + -o /tmp/test_night_mode test-data/test_night_mode.cpp + /tmp/test_night_mode server-tests: runs-on: ubuntu-latest diff --git a/docs/api.md b/docs/api.md index 1c57360..56f62c9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -377,10 +377,36 @@ Returns the current config for a device. A default config is created if none exi "tempco_source": "ambient", "tempco_ref_temp_c": 20.0, "scale1_tempco_kg_per_c": 0.0, - "scale2_tempco_kg_per_c": 0.0 + "scale2_tempco_kg_per_c": 0.0, + "beecounter_night_mode_enabled": false, + "beecounter_night_start_minute": 1200, + "beecounter_night_end_minute": 360, + "beecounter_night_max_traffic": 0, + "timezone": "" } ``` +The `beecounter_night_*` fields configure **HiveTraffic night mode** — see +[hivetraffic-bee-counter.md](hivetraffic-bee-counter.md#night-mode). Unlike the +calibration fields below, the device applies them on **every** config fetch +rather than only when `config_version` changes: there is no portal-side +counterpart to protect, and turning the window off has to take effect on the +next cycle rather than waiting for an unrelated edit to bump the version. + +| Field | Meaning | +| --- | --- | +| `beecounter_night_mode_enabled` | Master switch. `false` by default, so an existing device is unaffected until it is turned on. | +| `beecounter_night_start_minute` / `beecounter_night_end_minute` | The window, in **local** minutes since midnight (0–1439). `start > end` wraps midnight, which the 20:00–06:00 default does. `start == end` is an **empty** window, not a 24-hour one — the firmware refuses it, so a single mis-set field cannot stop a counter for good. | +| `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. | + +`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 +POSIX string rather than an IANA name because the ESP32 carries no tz database; +newlib parses this form directly, DST rules included. The dashboard offers +presets for the common zones and a free-text field for anything else. + The `tempco_*` fields drive backend load-cell temperature compensation. They are informational for the device (which keeps sending raw weights); the correction is applied in the backend on read. See @@ -1115,7 +1141,7 @@ The backend auto-creates and updates the schema on startup. | `devices` | Device identity, claim status, per-device API key hash, display name, last seen, firmware version, and the owner-approved firmware version (accept-to-apply gate) | | `device_members` | Users with `owner`, `admin`, or `viewer` role per device | | `device_channels` | Display names for scale channel 1 and 2 | -| `device_configs` | Send interval, offsets, calibration factors, config version | +| `device_configs` | Send interval, offsets, calibration factors, config version, bee-counter night mode | | `measurements` | Measurement records, including power/acoustic/bee-counter columns and `raw_json` | | `firmware_releases` | Firmware versions available for OTA, with `target`, `crc32`, and `owner_user_id` (NULL = global/official; otherwise the owner the release is private to) | | `device_commands` | Pending, claimed, done, and failed commands, with the `attempts` counter used to recover abandoned claims | diff --git a/docs/hivetraffic-bee-counter.md b/docs/hivetraffic-bee-counter.md index 3bcbe85..8b7e65b 100644 --- a/docs/hivetraffic-bee-counter.md +++ b/docs/hivetraffic-bee-counter.md @@ -60,6 +60,15 @@ the counter reboots and the following measurement read reports the new version. and pauses gate polling while writing flash, so every bee crossing during those minutes is lost. Relay at night or in poor flying weather. +That advice still holds with [night mode](#night-mode) enabled, and the two do +not collide: a suspended counter stays advertising and connectable, so the relay +runs exactly as it would in daylight. The counter refuses a *new* suspension +while a transfer is in progress, HiveHub does not suspend a counter while a +relay is unfinished, and the post-OTA reboot clears any suspension anyway — the +next cycle re-arms it if the window is still open. This is the main reason night +mode is a sensing suspension rather than deep sleep, which would have made the +recommended relay window the one time of day a counter is unreachable. + ### Version gate The relay is refused with `409` unless the release is strictly newer than the @@ -107,8 +116,8 @@ All HiveTraffic devices share one service/characteristic (overridable via The characteristic returns a compact JSON document — **totals only**: ```json -{ "fw":3, "ver":"0.2.0", "uptime_s":1234, "status":15, "num_gates":24, - "mcps_healthy":3, "total_in":100, "total_out":95, "glitches":2 } +{ "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` is the wire-protocol revision; `ver` is the counter's own image version, @@ -119,16 +128,21 @@ 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. -### Two wire revisions, both supported +### Three wire revisions, all supported `firmware/include/bee_counter_wire.h` reads `fw` **first** and branches on it. -Both revisions parse, and both produce the same record: +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:2` | `fw:3` | -| --- | --- | --- | -| Expander health field | `gates_healthy` | `mcps_healthy` | -| `uptime_s` | 16-bit on the device, clamped at 65535 (18 h 12 min) | 32-bit | -| `glitches` | 16-bit, pinned at 65535 | 32-bit, saturating | +`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. 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 @@ -157,6 +171,83 @@ The decoder is covered by `test-data/test_bee_counter_wire.cpp`, which parses a captured document of each revision and asserts they decode identically. It runs in CI on a plain host compiler — no ESP32 and no counter required. +## Night mode + +A HiveTraffic counter's 48 IR emitters are its power budget: 24 series pairs +behind 22 Ω ballast, all lit together for the settle+read window of every 5 ms +poll — roughly 0.5–1.0 A peak and, at the pulsed sampler's ~35 % duty, an +average an order of magnitude above the ~18 mA the ESP32-C6 and its three port +expanders draw between them. On an off-grid hive it is the whole supply. + +European honey bees are diurnal. Flight requires light — they do not fly in +darkness at any temperature — and stops below roughly 10 °C regardless, so +overnight that draw buys nothing. Night mode parks the emitters. + +**It is off by default.** Turn it on per device under **Device & admin → +Bee counter night mode**, or `PATCH` the config fields directly (see +[api.md](api.md#get-apiv1devicesdevice_idconfig)). + +| Setting | Meaning | +| --- | --- | +| Enable night mode | Master switch | +| Night starts / ends | **Local** wall-clock times. The window may cross midnight; setting both the same disables it rather than covering the whole day | +| Timezone | POSIX TZ string. The device clock is UTC, so without one the window drifts an hour at each DST change | +| Postpone above | Crossings in the last cycle above which night mode waits for the next one. 0 goes by the clock alone | + +### How it works + +The counter has no RTC, no NVS and no network, and this design deliberately does +not give it any. HiveHub owns the schedule and tells the counter only a bounded +**duration** — "stop sensing for N seconds" — re-armed once per upload cycle for +as long as the window lasts: + +1. `bee_counter_client.cpp` reads the measurement characteristic as usual. +2. `night_mode.h::decide()` weighs the local clock, the window, the traffic gate + and whether a firmware relay is unfinished. +3. If it says suspend, a 5-byte `SET_IDLE` frame goes out **on the same + connection** — no extra scan, no extra connect. +4. The counter reports `idle_s` and status bit `0x80` until the grant expires. + +Every path that is not certain returns "keep counting": no valid local time, no +traffic baseline yet, a window whose ends are equal, a relay in flight. The +worst outcome of a bug here is a counter that counts, which is what it did +before the feature existed. The grant is deliberately short (two upload cycles, +capped at an hour on both sides), so a HiveHub that dies mid-night cannot leave +a counter blind — the deadline simply runs out. + +The traffic gate is the "not yet" rule: if more bees crossed in the last cycle +than the threshold, night mode waits. It is evaluated from the totals of the +*previous* cycle, held in RTC memory across HiveHub's own deep sleep, which for +a threshold like "fewer than 100 crossings in the last ten minutes" is the +intended reading. A hive with no baseline yet — first cycle after a reboot — is +postponed rather than waved through: "I don't know how busy this is" is not "it +is quiet". + +### Why not deep sleep + +Deep sleep saves the residual ~18 mA on top of what parking the emitters already +saves, under 10 % of the total, and costs the measurement read (every night row +would carry `bee_counter.ok=false`), the firmware relay path that is +specifically recommended for night use, the ability to cancel a wrong schedule, +the counter's RAM-held lifetime totals, and a truthful `uptime_s`. The ESP32-C6's +deep-sleep timer also runs off a temperature-dependent internal RC oscillator: +over an 8–12 h sleep in a hive that swings 10–25 °C, expect minutes of drift and +up to ~30 minutes worst case. Re-arming a short grant against HiveHub's DS3231 +(±2 ppm) means nothing accumulates. See HiveTraffic's `docs/ble-mode.md` for the +device-side reasoning. + +### Reading the data + +The counter's totals are frozen while suspended, so the differenced interval +across the window is a genuine zero rather than a gap — the same value a quiet +night produces anyway. What `idle_s` adds is the ability to tell that zero from +a counter whose emitter FETs have died, which otherwise produces an identical +row. It is stored on every reading as `hives[].bee_counter.idle_s`. + +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. + ## 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 54db746..f314234 100644 --- a/firmware/include/bee_counter_client.h +++ b/firmware/include/bee_counter_client.h @@ -57,6 +57,11 @@ struct Snapshot { uint8_t mcps_healthy = 0; uint32_t total_in = 0; // lifetime totals — never reset by us uint32_t total_out = 0; + // Seconds of night-mode suspension still to run on the counter ("idle_s", + // wire revision 4+); 0 while it is counting, and 0 for any counter too old + // 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; // 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 ec40fb7..a0a4851 100644 --- a/firmware/include/bee_counter_wire.h +++ b/firmware/include/bee_counter_wire.h @@ -19,6 +19,12 @@ // clamped at 65535 / pinned at 65535 respectively. // fw >= 3 the same count is named "mcps_healthy"; uptime_s and glitches are // 32-bit. +// fw >= 4 adds "idle_s" and status bit 0x80: the counter can be told to stop +// sensing for a bounded period (night mode) and now says when it +// has. Purely additive, so this parser would have read a v4 document +// 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. // // 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 @@ -35,8 +41,8 @@ // Why a hand-written reader rather than ArduinoJson // --------------------------------------------------------------------------- // The document is generated by a single snprintf in HiveTraffic's -// measurement_json.h: a flat object of nine fixed keys, no nesting, no arrays, -// no escapes, ~171 bytes at its widest. A full JSON parser buys nothing for +// measurement_json.h: a flat object of ten fixed keys, no nesting, no arrays, +// no escapes, ~191 bytes at its widest. A full JSON parser buys nothing for // that shape and costs a heap-allocating JsonDocument on every GATT read — and, // more importantly here, it cannot be linked into a host test, which is how // this contract stopped being checkable in the first place. @@ -59,6 +65,16 @@ namespace wire { // First protocol revision that names the expander count "mcps_healthy". 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; + +// 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 +// pinned by test-data/test_bee_counter_wire.cpp so the pair cannot drift apart +// silently. +constexpr uint8_t STATUS_NIGHT_IDLE = 0x80; + // One decoded measurement document. Field names are HiveHub's, normalized // across both revisions; `protocol_version` records which revision produced it. struct Measurement { @@ -79,8 +95,21 @@ struct Measurement { uint32_t total_in = 0; // lifetime totals, saturating at UINT32_MAX uint32_t total_out = 0; uint32_t glitch_count = 0; // "glitches", saturating + // Seconds of night-mode suspension still to run; 0 while counting, and 0 + // on any counter older than fw:4. Non-zero means the zeros in total_in / + // total_out for this interval are deliberate: HiveHub asked the counter to + // stop, because honey bees do not fly at night. Without it that is + // indistinguishable from failed emitters. + uint32_t idle_s = 0; }; +// 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. +inline bool isNightIdle(const Measurement& m) { + return m.idle_s > 0; +} + namespace detail { inline bool isSpace(char c) { @@ -263,6 +292,8 @@ inline bool parseMeasurement(const char* json, size_t len, Measurement& out) { out.total_out = value; } else if (klen == 8 && memcmp(key, "glitches", 8) == 0) { out.glitch_count = value; + } else if (klen == 6 && memcmp(key, "idle_s", 6) == 0) { + out.idle_s = value; } // else: unknown key, already skipped. } diff --git a/firmware/include/config.h b/firmware/include/config.h index 6701af5..5510f4e 100644 --- a/firmware/include/config.h +++ b/firmware/include/config.h @@ -620,6 +620,20 @@ #ifndef BEECOUNTER_GATT_CHAR_UUID #define BEECOUNTER_GATT_CHAR_UUID "8e8b0102-7a1c-4b9e-9a2f-1d6e0b9c1a01" #endif +// Night-mode control characteristic (HiveTraffic protocol v4 and later). We +// write "stop sensing for N seconds" here once per cycle for as long as the +// configured night window lasts; see night_mode.h for the decision and +// HiveTraffic's docs/ble-mode.md for the frames. A counter too old to have it +// simply has no such characteristic, and the write is skipped — night mode +// degrades to "this counter keeps counting", never to an error. +#ifndef BEECOUNTER_GATT_CONTROL_UUID +#define BEECOUNTER_GATT_CONTROL_UUID "8e8b0103-7a1c-4b9e-9a2f-1d6e0b9c1a01" +#endif +// Control opcodes, mirroring beecounter_proto in the HiveTraffic repo. +#ifndef BEECOUNTER_CTRL_OP_SET_IDLE +#define BEECOUNTER_CTRL_OP_SET_IDLE 0x01 +#endif + // OTA characteristics. Unlike HiveInside these live in the SAME service as the // measurement characteristic above — HiveTraffic has only one service. // Must match HiveTraffic Firmware/src/ble_link.cpp. diff --git a/firmware/include/device_prefs.h b/firmware/include/device_prefs.h index bfa5567..943efaf 100644 --- a/firmware/include/device_prefs.h +++ b/firmware/include/device_prefs.h @@ -12,6 +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(). +void saveNightModePrefs(); // Latch "the server has this device's claim" so the claim code stops riding // along on every upload. void markClaimRegistered(); diff --git a/firmware/include/globals.h b/firmware/include/globals.h index 3704666..1ddb5b0 100644 --- a/firmware/include/globals.h +++ b/firmware/include/globals.h @@ -133,6 +133,26 @@ extern String scaleMac1; // up to MAX_HIVES. Paired in the portal, seeded via HIVE_i_JSON, or via the // legacy WBEECNT_n_MAC / counter_mac{0,1} keys (migrated into the registry). +// ---- HiveTraffic night mode ---------------------------------------------- +// When to tell a paired HiveTraffic counter to stop sensing, because honey bees +// are diurnal and its 48 IR emitters are the largest item in an off-grid power +// budget. Delivered by /api/v1/devices/{id}/config, persisted in NVS so a hub +// that boots without WiFi still applies the last known window, and decided per +// cycle by night_mode.h. OFF unless deliberately enabled. +// +// The window is LOCAL wall-clock minutes since midnight and may wrap midnight +// (20:00 -> 06:00 is start 1200, end 360). See nightTimezone below: without a +// TZ the device clock is UTC, and "20:00" would drift by an hour twice a year. +extern bool nightModeEnabled; +extern uint16_t nightStartMinute; +extern uint16_t nightEndMinute; +// Crossings (in + out) in the last cycle above which night mode is postponed to +// the next one. 0 disables the check. +extern uint32_t nightMaxTraffic; +// POSIX TZ string (e.g. "CET-1CEST,M3.5.0,M10.5.0/3"), applied with setenv()/ +// tzset() so localtime() honours it. Empty means UTC. +extern String nightTimezone; + // ---- Scale calibration ---------------------------------------------------- extern long scale1Offset; extern long scale2Offset; @@ -156,6 +176,16 @@ extern uint32_t rtcBootCount; // See markRelayInFlight()/reportInterruptedRelay() in hivehub_network.cpp. extern uint32_t rtcRelayCommandId; extern uint32_t rtcRelayMagic; +// Previous cycle's HiveTraffic lifetime totals, per hive index, so the night +// mode traffic gate can difference them into "crossings since the last cycle". +// In RTC memory because HiveHub deep-sleeps between cycles: in plain RAM there +// would never be a previous reading to compare against, and the gate would +// postpone night mode forever. rtcCounterTotalsValid is a per-hive bitmask, so +// a hive that has never been read is "unknown" rather than "zero" — which the +// gate treats very differently. +extern uint32_t rtcCounterTotalIn[MAX_HIVES]; +extern uint32_t rtcCounterTotalOut[MAX_HIVES]; +extern uint32_t rtcCounterTotalsValid; // ---- Small shared utilities ---------------------------------------------- void debugLine(); diff --git a/firmware/include/night_mode.h b/firmware/include/night_mode.h new file mode 100644 index 0000000..018898b --- /dev/null +++ b/firmware/include/night_mode.h @@ -0,0 +1,204 @@ +// ============================================================================ +// night_mode.h — deciding when to suspend a HiveTraffic counter +// ============================================================================ +// +// Pure logic, free of Arduino / NimBLE / time.h, so the decision can be +// exercised on a host compiler against the cases that actually bite — midnight +// wrap, a clock that never synced, a hive still flying at 21:00 in June — none +// of which anyone will sit through on real hardware. See +// test-data/test_night_mode.cpp. bee_counter_client.cpp owns the GATT session +// and calls in here for the verdict. +// +// --------------------------------------------------------------------------- +// Why this decision lives on HiveHub +// --------------------------------------------------------------------------- +// The counter has no RTC, no NVS and no network; HiveHub has NTP and a DS3231 +// good to +/-2 ppm. So HiveHub owns the schedule and the counter is told only a +// DURATION — "stop sensing for N seconds" — which it forgets on any reset. See +// HiveTraffic's docs/ble-mode.md for the device half. +// +// That split is what makes the whole feature fail safe. Every path through +// decide() that is not certain returns "count": no clock, no traffic baseline, +// a window that would swallow the day, an OTA about to run. The worst outcome +// of a bug here is a counter that keeps counting, which is what it did before +// night mode existed. +// +// --------------------------------------------------------------------------- +// Why the suspension is re-armed rather than set once +// --------------------------------------------------------------------------- +// decide() is called once per upload cycle and grants a little over one cycle +// of suspension at a time. Nothing accumulates: the counter's own clock only +// has to be right for ten minutes, HiveHub's DS3231 keeps the actual schedule, +// and a HiveHub that stops calling — dead, out of range, carried off — lets the +// counter free itself within one grant. A single "sleep until 06:00" would put +// all of that on the counter's temperature-dependent RC oscillator and give it +// no way back if the schedule turned out to be wrong. +// ============================================================================ + +#pragma once + +#include + +namespace nightmode { + +// Minutes in a day, and the sentinel for "no valid local time". +constexpr uint16_t MINUTES_PER_DAY = 1440; + +// Bounds on a single grant. The floor keeps a short configured upload interval +// from turning into a stream of tiny suspensions the counter has to re-arm +// constantly; the ceiling matches HiveTraffic's own MAX_IDLE_SECONDS, so what +// is asked for is what gets granted and the log does not lie about it. +constexpr uint32_t MIN_GRANT_S = 120; +constexpr uint32_t MAX_GRANT_S = 3600; + +// How much longer than one upload cycle each grant runs. Exactly one cycle +// would have the counter resuming — lighting all 48 emitters — in the gap +// before HiveHub reconnects, every single cycle of the night. Two cycles rides +// out one missed connection and still frees the counter promptly if HiveHub +// stops calling for good. +constexpr uint32_t GRANT_CYCLES = 2; + +// Why decide() answered the way it did. Logged verbatim: "the counter kept +// counting last night" is otherwise a question with six plausible answers. +enum class Reason : uint8_t { + DISABLED, // night mode is off for this device + NO_CLOCK, // no valid local time — never guess at the schedule + INVALID_WINDOW, // start == end, or a value out of range + OUTSIDE_WINDOW, // daytime + RELAY_PENDING, // an OTA relay is queued for this counter + TRAFFIC_UNKNOWN, // no previous totals to difference yet + TRAFFIC_ABOVE_LIMIT, // still busy; postpone to the next cycle + SUSPEND, // all clear: stop sensing +}; + +// Per-device configuration, as edited in the dashboard and delivered by +// /api/v1/devices/{id}/config. +struct Config { + bool enabled = false; // OFF unless deliberately turned on + uint16_t start_minute = 20 * 60; // 20:00 local + uint16_t end_minute = 6 * 60; // 06:00 local + // Crossings (in + out) in the last upload cycle above which night mode is + // postponed to the next cycle. 0 disables the gate, entering the window on + // the clock alone. + uint32_t max_traffic = 0; +}; + +// One hive's traffic since the previous cycle. +struct Traffic { + bool known = false; // is there a previous reading to difference? + uint32_t crossings = 0; // in + out over that interval +}; + +struct Decision { + bool suspend = false; + uint32_t duration_s = 0; // 0 when suspend is false + Reason reason = Reason::DISABLED; +}; + +// Is `minute` inside the window, which may wrap midnight? +// +// start == end is treated as EMPTY, not as a 24-hour window. The other reading +// would mean a single mis-set field could stop a counter for good, one hour at +// a time, and there is no legitimate configuration it would serve — a beekeeper +// who wants the counter off entirely unpairs it. +inline bool inWindow(uint16_t minute, uint16_t start, uint16_t end) { + if (start >= MINUTES_PER_DAY || end >= MINUTES_PER_DAY) return false; + if (start == end) return false; + if (start < end) return minute >= start && minute < end; + return minute >= start || minute < end; // wraps midnight +} + +// Crossings between two lifetime totals, handling a counter that restarted. +// +// The counter's totals are monotonic until it reboots, when they restart from +// zero. Attributing that as `current` — rather than as a huge negative, or as +// zero — matches what the backend does when it differences the same totals +// (server/measurements.py::difference_bee_counter_intervals), so the gate here +// and the interval charted later never disagree about how busy a hive was. +inline uint32_t crossingsBetween(uint32_t previous, uint32_t current) { + return current >= previous ? current - previous : current; +} + +// How long a single grant should run, given the configured upload interval. +inline uint32_t grantSeconds(uint32_t cycle_seconds) { + uint64_t grant = (uint64_t)cycle_seconds * GRANT_CYCLES; + if (grant < MIN_GRANT_S) grant = MIN_GRANT_S; + if (grant > MAX_GRANT_S) grant = MAX_GRANT_S; + return (uint32_t)grant; +} + +// Decide what to tell one counter this cycle. +// +// `minute_of_day` is local time, or >= MINUTES_PER_DAY for "unknown". The +// checks are ordered cheapest-and-most-decisive first, and every early return +// is "keep counting". +inline Decision decide(const Config& cfg, uint16_t minute_of_day, + const Traffic& traffic, uint32_t cycle_seconds, + bool relay_pending) { + Decision d; + + if (!cfg.enabled) { + d.reason = Reason::DISABLED; + return d; + } + if (cfg.start_minute >= MINUTES_PER_DAY || + cfg.end_minute >= MINUTES_PER_DAY || + cfg.start_minute == cfg.end_minute) { + d.reason = Reason::INVALID_WINDOW; + return d; + } + // An unsynced clock is the one input whose absence is silent: RTC missing + // and NTP not yet reached leaves a plausible-looking 1970 that would put + // every hive in the middle of the night forever. + if (minute_of_day >= MINUTES_PER_DAY) { + d.reason = Reason::NO_CLOCK; + return d; + } + // Never suspend a counter we are about to stream an image to. The relay + // needs it awake, and the standing advice is specifically to relay at night + // because a transfer costs counted bees — so the two would collide exactly + // when both are meant to run. + if (relay_pending) { + d.reason = Reason::RELAY_PENDING; + return d; + } + if (!inWindow(minute_of_day, cfg.start_minute, cfg.end_minute)) { + d.reason = Reason::OUTSIDE_WINDOW; + return d; + } + if (cfg.max_traffic > 0) { + // No baseline yet (first cycle after a HiveHub reboot, or the first + // successful read of this counter): the gate exists precisely to avoid + // shutting the beam on a hive that is still flying, and "I don't know" + // is not "it's quiet". Costs one cycle. + if (!traffic.known) { + d.reason = Reason::TRAFFIC_UNKNOWN; + return d; + } + if (traffic.crossings > cfg.max_traffic) { + d.reason = Reason::TRAFFIC_ABOVE_LIMIT; + return d; + } + } + + d.suspend = true; + d.duration_s = grantSeconds(cycle_seconds); + d.reason = Reason::SUSPEND; + return d; +} + +inline const char* reasonName(Reason r) { + switch (r) { + case Reason::DISABLED: return "disabled"; + case Reason::NO_CLOCK: return "no valid local time"; + case Reason::INVALID_WINDOW: return "invalid window"; + case Reason::OUTSIDE_WINDOW: return "outside the night window"; + case Reason::RELAY_PENDING: return "an OTA relay is queued"; + case Reason::TRAFFIC_UNKNOWN: return "no traffic baseline yet"; + case Reason::TRAFFIC_ABOVE_LIMIT: return "still busy"; + case Reason::SUSPEND: return "inside the night window"; + } + return "unknown"; +} + +} // namespace nightmode diff --git a/firmware/include/sensors.h b/firmware/include/sensors.h index 4f08e64..337f8e0 100644 --- a/firmware/include/sensors.h +++ b/firmware/include/sensors.h @@ -11,6 +11,13 @@ String timestampNow(); void syncTime(); + +// Local wall-clock minutes since midnight (0..1439), honouring the configured +// POSIX timezone, or nightmode::MINUTES_PER_DAY when there is no trustworthy +// clock. Only the HiveTraffic night-mode window uses local time; everything +// else in this firmware is UTC. See the comment above the definition for why +// the timezone is load-bearing rather than cosmetic. +uint16_t localMinuteOfDay(); void initializeTime(bool wokeFromDeepSleep); float weightFromRaw(long raw, long offset, float factor); diff --git a/firmware/src/bee_counter_client.cpp b/firmware/src/bee_counter_client.cpp index 5d2fea8..0a01d4c 100644 --- a/firmware/src/bee_counter_client.cpp +++ b/firmware/src/bee_counter_client.cpp @@ -7,8 +7,11 @@ #include // strlcpy for the counter's reported version #include #include "ble_stack.h" -#include "bee_counter_wire.h" // the fw:2 / fw:3 tolerant document decoder +#include "bee_counter_wire.h" // the tolerant fw:2/3/4 document decoder #include "hive_config.h" +#include "night_mode.h" +#include "sensors.h" // localMinuteOfDay() +#include "globals.h" // the night-mode config + RTC totals baseline #endif namespace beecnt { @@ -58,6 +61,12 @@ void writeSnapshotToHive(JsonObject hive, const Snapshot& snap) { bc["total_in"] = snap.total_in; bc["total_out"] = snap.total_out; bc["glitch_count"] = snap.glitch_count; + // Emitted unconditionally, including the 0 a counting counter reports and + // the 0 a pre-v4 counter implies. Omitting it when zero would make "this + // counter cannot be suspended" and "this counter is counting right now" + // 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; } // --------------------------------------------------------------------------- @@ -93,13 +102,121 @@ bool parseTrafficJson(const char* json, size_t len, Snapshot& out) { out.total_in = m.total_in; out.total_out = m.total_out; out.glitch_count = m.glitch_count; + out.idle_s = m.idle_s; return true; } +// Write one night-mode grant to an already-connected counter. +// +// Returns false when the counter has no control characteristic, which is simply +// what a pre-v4 firmware looks like: night mode then degrades to "this counter +// keeps counting", never to an error, and the OTA relay will bring it up to a +// firmware that can be suspended in the normal course of things. +// +// Deliberately fire-and-forget beyond that. The counter's own deadline is the +// authority, we re-arm every cycle anyway, and a failed write costs one cycle +// of emitters — not correctness. Blocking the measurement read on it would +// trade the reading we came for against a power optimisation. +bool writeIdleGrant(NimBLERemoteService* svc, uint32_t duration_s) { + if (!svc) return false; + NimBLERemoteCharacteristic* chr = + svc->getCharacteristic(NimBLEUUID(BEECOUNTER_GATT_CONTROL_UUID)); + if (!chr || !chr->canWrite()) return false; + + // opcode + uint32 little-endian, matching beecounter_proto::CTRL_OP_SET_IDLE. + uint8_t frame[5]; + frame[0] = BEECOUNTER_CTRL_OP_SET_IDLE; + frame[1] = (uint8_t)(duration_s); + frame[2] = (uint8_t)(duration_s >> 8); + frame[3] = (uint8_t)(duration_s >> 16); + frame[4] = (uint8_t)(duration_s >> 24); + // With response: the counter's reply is the only confirmation that the + // grant landed, and there is exactly one small write per cycle, so there is + // nothing to gain from the unacknowledged variant. + 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 +// rather than per hive — the clock does not move meaningfully across a handful +// of GATT sessions, and a window boundary falling between two hives would +// suspend one and not the other for no reason a beekeeper could reconstruct. +struct NightContext { + nightmode::Config cfg; + uint16_t minute = nightmode::MINUTES_PER_DAY; // "no clock" + uint32_t cycle_s = 600; + bool relay_pending = false; +}; + +// Is a firmware relay in flight? +// +// The ordinary relay path needs no guard: checkCommands() runs after this +// function, a suspended counter still accepts an image (HiveTraffic pauses +// sensing during a transfer regardless, and refuses a *new* suspension while +// one is running), and the post-OTA reboot clears any suspension anyway. +// +// What this catches is the interrupted case. rtcRelayMagic survives a reset, so +// it is set exactly while a relay is unfinished — and that is when the counter +// most needs to be awake, advertising and reachable for the retry, rather than +// suspended by a HiveHub that is about to try again. Conservative on purpose: +// it is also set for a HiveInside relay, and one extra cycle of counting is not +// worth distinguishing them. +bool relayInFlight() { + return rtcRelayMagic != 0; +} + +// Assemble the night-mode configuration the dashboard delivered. +nightmode::Config nightConfig() { + nightmode::Config c; + c.enabled = nightModeEnabled; + c.start_minute = nightStartMinute; + c.end_minute = nightEndMinute; + c.max_traffic = nightMaxTraffic; + return c; +} + +// Crossings on hive slot `h` since the previous cycle, from the totals kept in +// RTC memory across HiveHub's own deep sleep. `known` is false until this slot +// has been read at least once — the gate must not treat "never seen" as "quiet". +nightmode::Traffic trafficSince(uint8_t h, const Snapshot& snap) { + nightmode::Traffic t; + if (h >= MAX_HIVES || !snap.present) return t; + if (rtcCounterTotalsValid & (1UL << h)) { + t.known = true; + t.crossings = + nightmode::crossingsBetween(rtcCounterTotalIn[h], snap.total_in) + + nightmode::crossingsBetween(rtcCounterTotalOut[h], snap.total_out); + } + return t; +} + +// Remember this cycle's totals as the next cycle's baseline. +void rememberTotals(uint8_t h, const Snapshot& snap) { + if (h >= MAX_HIVES || !snap.present) return; + rtcCounterTotalIn[h] = snap.total_in; + rtcCounterTotalOut[h] = snap.total_out; + rtcCounterTotalsValid |= (1UL << h); +} + // Connect to `mac`, read the measurement characteristic once, parse it into -// `out`. Tries public then random address type (HiveTraffic advertises with the +// `out`, then decide and apply night mode on the same connection. +// +// The ordering is forced and worth stating: the night-mode traffic gate asks +// how busy this hive was during the cycle that just ended, which is this +// cycle's fresh totals minus the previous cycle's — so the decision cannot be +// made until the read has happened. Doing it here rather than in the caller is +// what keeps it to ONE connection per counter per cycle. +// +// Tries public then random address type (HiveTraffic advertises with the // ESP32's default random static address, but seeded MACs may be either). -bool readTrafficSlot(const String& mac, Snapshot& out) { +// +// `slot` is the hive index, for the RTC-held totals baseline. A grant of 0 is a +// real instruction (resume now), not a no-op: it is how a counter suspended by +// a previous cycle is released the moment the window ends or the beekeeper +// turns the feature off. +bool readTrafficSlot(const String& mac, Snapshot& out, uint8_t slot, + const NightContext& night) { if (mac.length() == 0) return false; NimBLEClient* client = NimBLEDevice::createClient(); @@ -151,6 +268,41 @@ bool readTrafficSlot(const String& mac, Snapshot& out) { } } } + + // 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 + // be suspended still reports everything it counted up to this moment; + // suspending first would freeze the totals a fraction of a second + // before we sampled them. + // + // A failed read leaves out.present false, so trafficSince() reports no + // baseline and the gate (if configured) postpones — which is the right + // answer: we have no idea how busy this hive is. + const nightmode::Decision decision = + nightmode::decide(night.cfg, night.minute, trafficSince(slot, out), + night.cycle_s, night.relay_pending); + if (night.cfg.enabled) { + if (writeIdleGrant(svc, decision.duration_s)) { + Serial.printf("[TRAFFIC] %s: night mode %s (%lus) — %s\n", + mac.c_str(), + decision.suspend ? "armed" : "cleared", + (unsigned long)decision.duration_s, + nightmode::reasonName(decision.reason)); + } else if (decision.suspend) { + // Only worth a line when we wanted something to happen. A + // pre-v4 counter has no control characteristic at all, so a + // failed "resume" write is its expected steady state and would + // otherwise print every cycle, forever. + Serial.printf("[TRAFFIC] %s: night mode not applied " + "(counter too old, or write refused)\n", + mac.c_str()); + } + } + // The baseline for the next cycle's gate. Only advanced on a successful + // read (rememberTotals guards on present), so an unreachable cycle does + // not turn into a fabricated interval next time. + rememberTotals(slot, out); } // HiveTraffic stays connectable; close the link deterministically and wait @@ -177,6 +329,12 @@ void bleRunCycleRegistry(Snapshot* out, uint8_t cap) { } if (!anyPaired) return; + NightContext night; + night.cfg = nightConfig(); + night.minute = localMinuteOfDay(); + night.cycle_s = (uint32_t)(sendIntervalMs / 1000UL); + night.relay_pending = relayInFlight(); + blestack::acquire(); uint8_t readAttempts = 0; for (uint8_t h = 0; h < hivecfg::gHiveCount && h < cap; h++) { @@ -191,7 +349,13 @@ void bleRunCycleRegistry(Snapshot* out, uint8_t cap) { break; } readAttempts++; - (void)readTrafficSlot(p.mac, out[h]); + + // readTrafficSlot reads, then decides, then applies — all on one + // connection. The decision has to follow the read because the + // traffic gate is about the cycle that just ended, and it has to + // precede the disconnect because the alternative is a second + // connection per counter per cycle. + (void)readTrafficSlot(p.mac, out[h], h, night); break; // at most one HiveTraffic counter per hive } } diff --git a/firmware/src/device_prefs.cpp b/firmware/src/device_prefs.cpp index de964bd..4374ec4 100644 --- a/firmware/src/device_prefs.cpp +++ b/firmware/src/device_prefs.cpp @@ -178,6 +178,16 @@ void loadConfigFromPrefs() { scaleMac1 = prefs.getString("scale_mac1", ""); #endif + // HiveTraffic night mode. NVS keys are <=15 characters (the ESP32 Preferences + // limit) and the defaults match globals.cpp, so a device that has never been + // configured — or one whose namespace was wiped — comes back with the feature + // off rather than with a half-set window. + nightModeEnabled = prefs.getBool("nm_on", false); + nightStartMinute = (uint16_t)prefs.getUShort("nm_start", 20 * 60); + nightEndMinute = (uint16_t)prefs.getUShort("nm_end", 6 * 60); + nightMaxTraffic = prefs.getUInt("nm_maxtraf", 0); + nightTimezone = prefs.getString("nm_tz", ""); + // HiveTraffic (wireless bee counter) MACs live in the hive registry // (gHives[].ble "beecounter" pairings) and are read there by // bee_counter_client.cpp, so no per-slot globals are loaded here. The legacy @@ -192,6 +202,12 @@ void loadConfigFromPrefs() { Serial.printf("[PREFS] interval ms: %lu\n", sendIntervalMs); Serial.printf("[PREFS] scale1 offset: %ld factor: %.6f\n", scale1Offset, scale1Factor); Serial.printf("[PREFS] scale2 offset: %ld factor: %.6f\n", scale2Offset, scale2Factor); + Serial.printf("[PREFS] night mode: %s %02u:%02u-%02u:%02u tz=%s max_traffic=%lu\n", + nightModeEnabled ? "on" : "off", + (unsigned)(nightStartMinute / 60), (unsigned)(nightStartMinute % 60), + (unsigned)(nightEndMinute / 60), (unsigned)(nightEndMinute % 60), + nightTimezone.length() ? nightTimezone.c_str() : "UTC", + (unsigned long)nightMaxTraffic); } void markClaimRegistered() { @@ -212,6 +228,20 @@ void clearClaimRegistered() { Serial.println("[PREFS] Claim released; claim_code will be sent again"); } +// Persist the night-mode window delivered by /config, so a hub that boots +// without WiFi still honours the last known one. Without this a device that +// cannot reach the server runs the emitters all night — the exact deployment +// (off-grid, marginal connectivity) the feature exists for. +void saveNightModePrefs() { + prefs.begin("hivescale", false); + prefs.putBool("nm_on", nightModeEnabled); + prefs.putUShort("nm_start", nightStartMinute); + prefs.putUShort("nm_end", nightEndMinute); + prefs.putUInt("nm_maxtraf", nightMaxTraffic); + prefs.putString("nm_tz", nightTimezone); + prefs.end(); +} + void saveScaleConfig() { prefs.begin("hivescale", false); prefs.putUInt("interval", sendIntervalMs / 1000UL); diff --git a/firmware/src/globals.cpp b/firmware/src/globals.cpp index f83cb3d..dde8786 100644 --- a/firmware/src/globals.cpp +++ b/firmware/src/globals.cpp @@ -47,6 +47,16 @@ unsigned long lastCycleMs = 0; unsigned long lastOtaCheckMs = 0; unsigned long provisioningStartedMs = 0; unsigned long sendIntervalMs = 10UL * 60UL * 1000UL; + +// HiveTraffic night mode. Defaults are the feature switched OFF with a +// plausible window behind it, so enabling it in the dashboard is one checkbox +// and never a half-configured state. See night_mode.h for what they mean and +// hivehub_network.cpp::fetchRemoteConfig for where they come from. +bool nightModeEnabled = false; +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 unsigned long calibrationModeStartedMs = 0; unsigned long calibrationModeIntervalMs = CALIBRATION_MODE_DEFAULT_INTERVAL_MS; unsigned long calibrationModeTimeoutMs = CALIBRATION_MODE_DEFAULT_TIMEOUT_MS; @@ -94,6 +104,16 @@ RTC_DATA_ATTR uint32_t rtcBootCount = 0; RTC_DATA_ATTR uint32_t rtcRelayCommandId = 0; RTC_DATA_ATTR uint32_t rtcRelayMagic = 0; +// Previous cycle's HiveTraffic totals per hive, for the night-mode traffic +// gate. RTC memory because HiveHub deep-sleeps between cycles — in plain RAM +// there would never be a previous reading and the gate would postpone night +// mode forever. The validity bitmask matters: hive N never read yet must be +// "unknown", not "zero crossings", or the gate would wave through a hive it has +// no information about. Zeroed on a cold boot, which is exactly right. +RTC_DATA_ATTR uint32_t rtcCounterTotalIn[MAX_HIVES] = {0}; +RTC_DATA_ATTR uint32_t rtcCounterTotalOut[MAX_HIVES] = {0}; +RTC_DATA_ATTR uint32_t rtcCounterTotalsValid = 0; + void debugLine() { Serial.println("----------------------------------------"); } diff --git a/firmware/src/hivehub_network.cpp b/firmware/src/hivehub_network.cpp index 069557d..0b55946 100644 --- a/firmware/src/hivehub_network.cpp +++ b/firmware/src/hivehub_network.cpp @@ -8,6 +8,7 @@ #include "ca_cert.h" #include "hive_config.h" #include "heap_diag.h" +#include "night_mode.h" // MINUTES_PER_DAY, for clamping the delivered window #include #include @@ -463,6 +464,39 @@ void fetchRemoteConfig() { sendIntervalMs = (unsigned long)(doc["send_interval_seconds"] | 600) * 1000UL; + // HiveTraffic night mode. Applied UNCONDITIONALLY, unlike the calibration + // below: the config_version gate exists because a portal-side tare must not + // be reverted by a server that never learned about it, and night mode has no + // portal-side counterpart to protect — the dashboard is its only source. A + // beekeeper who turns the window off expects it off on the next cycle, not + // after the next unrelated config edit bumps the version. + // + // Defaults match globals.cpp, so a server too old to know these keys leaves + // the feature off rather than half-configured. The window is clamped here as + // well as server-side: night_mode.h refuses an out-of-range window outright, + // and "the counter never slept and nothing said why" is a bad way to find out + // a field was 1500. + nightModeEnabled = doc["beecounter_night_mode_enabled"] | false; + { + const uint32_t start = doc["beecounter_night_start_minute"] | 1200; + const uint32_t end = doc["beecounter_night_end_minute"] | 360; + nightStartMinute = start < nightmode::MINUTES_PER_DAY ? (uint16_t)start : 1200; + nightEndMinute = end < nightmode::MINUTES_PER_DAY ? (uint16_t)end : 360; + } + nightMaxTraffic = doc["beecounter_night_max_traffic"] | 0; + nightTimezone = doc["timezone"] | ""; + if (nightModeEnabled) { + Serial.printf("[CONFIG] Night mode %02u:%02u-%02u:%02u %s, max traffic %lu\n", + (unsigned)(nightStartMinute / 60), (unsigned)(nightStartMinute % 60), + (unsigned)(nightEndMinute / 60), (unsigned)(nightEndMinute % 60), + nightTimezone.length() ? nightTimezone.c_str() : "UTC", + (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. + saveNightModePrefs(); + // Bridge calibration into the hive registry (the authoritative source for the // read path) ONLY when the server config actually changed since it was last // applied. /config always returns scale1/2 offset+factor (DB defaults diff --git a/firmware/src/sensors.cpp b/firmware/src/sensors.cpp index 190ce76..eab5039 100644 --- a/firmware/src/sensors.cpp +++ b/firmware/src/sensors.cpp @@ -15,6 +15,7 @@ #include #include #include +#include // setenv(), for the night-mode timezone #include #include @@ -30,6 +31,8 @@ #include "beehive_gatt.h" #endif +#include "night_mode.h" // nightmode::MINUTES_PER_DAY, the "no clock" sentinel + void initializeTime(bool wokeFromDeepSleep) { if (rtcHasValidTime()) { timeSource = "rtc"; @@ -47,6 +50,65 @@ void initializeTime(bool wokeFromDeepSleep) { syncTime(); } +// --------------------------------------------------------------------------- +// Local wall-clock time, for the HiveTraffic night-mode window +// --------------------------------------------------------------------------- +// Everything else in this firmware works in UTC and never has to care: readings +// carry a UTC timestamp and the backend does the rest. Night mode is the one +// feature the beekeeper expresses in local time — "20:00" means 20:00 at the +// apiary — so this is where UTC becomes local, and the only place a timezone +// exists at all. +// +// It matters more than it looks. configTime(0, 0, ...) puts the device on UTC, +// so without a TZ a window entered as 20:00 would fire at 21:00 local in +// summer and 20:00 in winter — an hour of foraging silently discarded on the +// long evenings when there is most of it. The TZ string carries the DST rules, +// so the window tracks the clock on the wall all year. + +// Push the configured POSIX TZ into the C library, when it changes. Cheap and +// idempotent: called from the accessor below rather than from a setup path, so +// no ordering between "config arrived" and "window evaluated" can leave a stale +// zone in place. +static void applyNightTimezone() { + static String applied = "\x01"; // impossible value: forces the first apply + if (applied == nightTimezone) return; + applied = nightTimezone; + if (nightTimezone.length()) { + setenv("TZ", nightTimezone.c_str(), 1); + } else { + setenv("TZ", "UTC0", 1); + } + tzset(); + Serial.printf("[TIME] Timezone set to %s\n", + nightTimezone.length() ? nightTimezone.c_str() : "UTC"); +} + +// Current UTC epoch, or 0 when there is no trustworthy clock. +// +// The system clock is preferred because NTP sets it directly; the DS3231 is the +// fallback for a boot with no WiFi. Both hold UTC — syncTime() writes gmtime() +// into the RTC — so DateTime::unixtime() is a straight conversion and no +// timezone is involved on this side. +static time_t currentUtcEpoch() { + const time_t sys = time(nullptr); + if (sys > 1700000000) return sys; // plausible: past Nov 2023 + if (rtcHasValidTime()) return (time_t)rtc.now().unixtime(); + return 0; +} + +uint16_t localMinuteOfDay() { + const time_t epoch = currentUtcEpoch(); + // No clock: the caller must treat this as "do not guess". An unsynced device + // reports a plausible-looking 1970, which is the middle of the night, and + // would otherwise park every counter in the fleet indefinitely. + if (epoch == 0) return nightmode::MINUTES_PER_DAY; + + applyNightTimezone(); + struct tm local; + if (localtime_r(&epoch, &local) == nullptr) return nightmode::MINUTES_PER_DAY; + return (uint16_t)(local.tm_hour * 60 + local.tm_min); +} + String timestampNow() { if (rtcOk) { DateTime now = rtc.now(); diff --git a/server/dashboard/assets/views.js b/server/dashboard/assets/views.js index 423fd21..5c9960f 100644 --- a/server/dashboard/assets/views.js +++ b/server/dashboard/assets/views.js @@ -1888,6 +1888,64 @@ function renderDevice(root, state) { finally { fitBtn.disabled = false; } }); + // ── HiveTraffic night mode ────────────────────────────────────────────── + // A paired bee counter's 48 IR emitters dominate its power draw by an order + // of magnitude, and honey bees are diurnal, so the counter can be told to + // stop sensing overnight. Times are entered as local wall-clock and stored as + // minutes since midnight; the window may wrap midnight (20:00 -> 06:00). + const minutesToHhmm = (m) => { + const v = Number(m); + if (!Number.isFinite(v) || v < 0 || v > 1439) return ""; + return `${String(Math.floor(v / 60)).padStart(2, "0")}:${String(v % 60).padStart(2, "0")}`; + }; + const hhmmToMinutes = (text) => { + const m = /^\s*(\d{1,2}):(\d{2})\s*$/.exec(text || ""); + if (!m) return null; + const h = Number(m[1]), min = Number(m[2]); + if (h > 23 || min > 59) return null; + return h * 60 + min; + }; + + const nmEnabled = el("input", { type: "checkbox" }); + nmEnabled.checked = !!cfg.beecounter_night_mode_enabled; + const nmStart = el("input", { type: "time", value: minutesToHhmm(cfg.beecounter_night_start_minute ?? 1200) }); + const nmEnd = el("input", { type: "time", value: minutesToHhmm(cfg.beecounter_night_end_minute ?? 360) }); + const nmMaxTraffic = el("input", { + type: "number", min: "0", step: "1", + value: String(cfg.beecounter_night_max_traffic ?? 0), + }); + + // A POSIX TZ string rather than an IANA name, because the ESP32 has no tz + // database — newlib parses this form directly, DST rules included. The + // presets cover the common cases; "Custom" exposes the raw field for anyone + // who needs a zone that is not listed. + const TZ_PRESETS = [ + ["", "UTC (no local time)"], + ["CET-1CEST,M3.5.0,M10.5.0/3", "Central Europe (Berlin, Paris, Madrid)"], + ["GMT0BST,M3.5.0/1,M10.5.0", "United Kingdom / Ireland"], + ["EET-2EEST,M3.5.0/3,M10.5.0/4", "Eastern Europe (Helsinki, Athens)"], + ["EST5EDT,M3.2.0,M11.1.0", "US Eastern"], + ["CST6CDT,M3.2.0,M11.1.0", "US Central"], + ["MST7MDT,M3.2.0,M11.1.0", "US Mountain"], + ["PST8PDT,M3.2.0,M11.1.0", "US Pacific"], + ]; + const nmTzCustom = el("input", { + type: "text", placeholder: "CET-1CEST,M3.5.0,M10.5.0/3", + value: cfg.timezone || "", + }); + const storedTz = cfg.timezone || ""; + const isPreset = TZ_PRESETS.some(([value]) => value === storedTz); + const nmTzSelect = el("select", { class: "full" }, + ...TZ_PRESETS.map(([value, label]) => + el("option", { value, selected: isPreset && value === storedTz ? true : null }, label)), + el("option", { value: "__custom__", selected: isPreset ? null : true }, "Custom…")); + const nmTzCustomRow = fieldRow("POSIX TZ string", nmTzCustom); + const syncTzRow = () => { nmTzCustomRow.hidden = nmTzSelect.value !== "__custom__"; }; + nmTzSelect.addEventListener("change", syncTzRow); + syncTzRow(); + const selectedTz = () => + (nmTzSelect.value === "__custom__" ? nmTzCustom.value.trim() : nmTzSelect.value); + 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", {}, @@ -1912,7 +1970,36 @@ function renderDevice(root, state) { el("div", { class: "fit-row" }, fieldRow("Fit lookback (days)", lookbackInput), el("div", { class: "form-actions" }, fitBtn)), - fitOut)), + fitOut), + el("div", { class: "config-block" }, + el("h3", {}, "Bee counter night mode"), + el("p", { class: "note" }, + "Honey bees are diurnal — flight needs light, and stops below about " + + "10 °C regardless — while a HiveTraffic counter's 48 IR emitters are " + + "the largest item in its power budget. In this window the counter is " + + "told to stop sensing, which is what makes it fit an off-grid supply. " + + "It keeps advertising, so readings and firmware relays still work."), + el("div", { class: "form-row" }, + el("label", {}, el("span", {}, "Enable night mode "), nmEnabled)), + fieldRow("Night starts (local)", nmStart), + fieldRow("Night ends (local)", nmEnd), + el("p", { class: "note" }, + "The window may cross midnight. Setting both to the same time " + + "disables it rather than covering the whole day. Leave a margin " + + "either side of dusk and dawn: activity peaks just before sunset " + + "and just before sunrise, so a window that starts too early loses " + + "real foraging on long summer evenings."), + fieldRow("Timezone", nmTzSelect), + nmTzCustomRow, + el("p", { class: "note" }, + "The device clock is UTC. Without a timezone the window drifts by " + + "an hour at each daylight-saving change."), + fieldRow("Postpone above (crossings per cycle)", nmMaxTraffic), + el("p", { class: "note" }, + "If more than this many bees crossed in the last upload cycle, night " + + "mode waits for the next one — so a hive still flying at 20:00 in " + + "June is not cut off mid-flight. 0 disables the check and goes by " + + "the clock alone."))), el("p", { class: "note" }, "Saving bumps the config version; the device applies it on its next check-in."), el("div", { class: "form-actions" }, cfgSaveBtn)); showScale(); @@ -1947,6 +2034,40 @@ function renderDevice(root, state) { if (hiveScales.length) patch.hive_scales = hiveScales; if (tcEnabled.checked !== !!cfg.tempco_enabled) patch.tempco_enabled = tcEnabled.checked; if (tcSource.value !== cfg.tempco_source) patch.tempco_source = tcSource.value; + + // Night mode. Times are sent as minutes since midnight; an unparseable or + // empty time field is left out entirely rather than sent as 0, which is a + // real and very wrong window (midnight) rather than a missing value. + if (nmEnabled.checked !== !!cfg.beecounter_night_mode_enabled) { + patch.beecounter_night_mode_enabled = nmEnabled.checked; + } + const startMin = hhmmToMinutes(nmStart.value); + if (startMin !== null && startMin !== cfg.beecounter_night_start_minute) { + patch.beecounter_night_start_minute = startMin; + } + const endMin = hhmmToMinutes(nmEnd.value); + if (endMin !== null && endMin !== cfg.beecounter_night_end_minute) { + patch.beecounter_night_end_minute = endMin; + } + const maxTraffic = parseInt(nmMaxTraffic.value, 10); + if (Number.isFinite(maxTraffic) && maxTraffic >= 0 && + maxTraffic !== cfg.beecounter_night_max_traffic) { + patch.beecounter_night_max_traffic = maxTraffic; + } + const tz = selectedTz(); + if (tz !== (cfg.timezone || "")) patch.timezone = tz; + + // Enabling a window whose ends are equal would be stored happily and then + // refused by the firmware, so the counter would simply never sleep and + // nothing anywhere would say why. Catch it at the only point a human is + // looking. + const finalStart = patch.beecounter_night_start_minute ?? cfg.beecounter_night_start_minute; + const finalEnd = patch.beecounter_night_end_minute ?? cfg.beecounter_night_end_minute; + if (nmEnabled.checked && finalStart === finalEnd) { + state.toast("Night mode start and end are the same — the window would never apply", "error"); + return; + } + if (!Object.keys(patch).length) { state.toast("No changes to save"); return; } cfgSaveBtn.disabled = true; // full: true — a light reload carries the *previous* config over instead of diff --git a/server/db.py b/server/db.py index 311d912..2aae5cb 100644 --- a/server/db.py +++ b/server/db.py @@ -442,6 +442,30 @@ def init_db(): ALTER TABLE device_configs ADD COLUMN IF NOT EXISTS tempco_by_hive JSONB; ALTER TABLE device_configs ADD COLUMN IF NOT EXISTS scale_offsets_by_hive JSONB; + -- HiveTraffic night mode (migration 025). Per device, not per + -- hive: every counter on one hub shares an apiary and a sunset. + -- The window is LOCAL minutes since midnight and may wrap + -- (20:00-06:00 is 1200 -> 360); `timezone` is a POSIX TZ string + -- and is load-bearing, because the device clock is UTC and + -- without it the window drifts an hour at each DST change. + ALTER TABLE device_configs ADD COLUMN IF NOT EXISTS beecounter_night_mode_enabled BOOLEAN NOT NULL DEFAULT false; + ALTER TABLE device_configs ADD COLUMN IF NOT EXISTS beecounter_night_start_minute INTEGER NOT NULL DEFAULT 1200; + ALTER TABLE device_configs ADD COLUMN IF NOT EXISTS beecounter_night_end_minute INTEGER NOT NULL DEFAULT 360; + ALTER TABLE device_configs ADD COLUMN IF NOT EXISTS beecounter_night_max_traffic INTEGER NOT NULL DEFAULT 0; + ALTER TABLE device_configs ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT ''; + -- An out-of-range minute makes the firmware refuse the window + -- outright, which presents as "night mode never happened" with + -- nothing saying why. Reject it where a human can see it. + ALTER TABLE device_configs DROP CONSTRAINT IF EXISTS device_configs_night_start_check; + ALTER TABLE device_configs ADD CONSTRAINT device_configs_night_start_check + CHECK (beecounter_night_start_minute BETWEEN 0 AND 1439); + ALTER TABLE device_configs DROP CONSTRAINT IF EXISTS device_configs_night_end_check; + ALTER TABLE device_configs ADD CONSTRAINT device_configs_night_end_check + CHECK (beecounter_night_end_minute BETWEEN 0 AND 1439); + ALTER TABLE device_configs DROP CONSTRAINT IF EXISTS device_configs_night_max_traffic_check; + ALTER TABLE device_configs ADD CONSTRAINT device_configs_night_max_traffic_check + CHECK (beecounter_night_max_traffic >= 0); + 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 c5338a7..ad72066 100644 --- a/server/devices.py +++ b/server/devices.py @@ -88,7 +88,9 @@ def ensure_device_config( scale2_offset, scale2_factor, config_version, tempco_enabled, tempco_source, tempco_ref_temp_c, scale1_tempco_kg_per_c, scale2_tempco_kg_per_c, - scale_offsets_by_hive + scale_offsets_by_hive, + beecounter_night_mode_enabled, beecounter_night_start_minute, + beecounter_night_end_minute, beecounter_night_max_traffic, timezone """ @@ -163,6 +165,11 @@ def device_config_row_to_model(r) -> DeviceConfig: tempco_ref_temp_c=r[9], scale1_tempco_kg_per_c=r[10], scale2_tempco_kg_per_c=r[11], hive_scales=hive_scales_from_json(r[12]), + beecounter_night_mode_enabled=r[13], + beecounter_night_start_minute=r[14], + beecounter_night_end_minute=r[15], + beecounter_night_max_traffic=r[16], + timezone=r[17], ) diff --git a/server/migrations/025_bee_counter_night_mode.sql b/server/migrations/025_bee_counter_night_mode.sql new file mode 100644 index 0000000..ca3a1f1 --- /dev/null +++ b/server/migrations/025_bee_counter_night_mode.sql @@ -0,0 +1,59 @@ +-- 025_bee_counter_night_mode.sql — HiveTraffic night mode, per device. +-- +-- The counter's 48 IR emitters dominate its power draw by an order of +-- magnitude, and European honey bees are diurnal: flight needs light, and stops +-- below roughly 10 C regardless. So HiveHub can now tell a paired HiveTraffic +-- counter to stop sensing through a nightly window. The counter has no clock — +-- it is told a bounded DURATION and re-armed each cycle — so the schedule lives +-- here, alongside the send interval the device already polls for. +-- +-- Per DEVICE rather than per hive: every counter on one hub is at one apiary, +-- under one sunset. A per-hive window would be a JSONB column and a second +-- editor for a distinction nobody has asked for. +-- +-- beecounter_night_mode_enabled master switch; OFF, so nothing changes for +-- an existing device until it is turned on +-- beecounter_night_start_minute window start, LOCAL minutes since midnight +-- beecounter_night_end_minute window end; start > end wraps midnight, +-- which the 20:00-06:00 default does +-- beecounter_night_max_traffic crossings in the last cycle above which +-- night mode is postponed to the next one; +-- 0 disables the check +-- timezone POSIX TZ string. Load-bearing: the device +-- clock is UTC, so without it "20:00" drifts +-- by an hour twice a year and quietly discards +-- an hour of foraging on long summer evenings +-- +-- 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_night_mode_enabled BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE device_configs + ADD COLUMN IF NOT EXISTS beecounter_night_start_minute INTEGER NOT NULL DEFAULT 1200; +ALTER TABLE device_configs + ADD COLUMN IF NOT EXISTS beecounter_night_end_minute INTEGER NOT NULL DEFAULT 360; +ALTER TABLE device_configs + ADD COLUMN IF NOT EXISTS beecounter_night_max_traffic INTEGER NOT NULL DEFAULT 0; +ALTER TABLE device_configs + ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT ''; + +-- Range checks rather than trust: an out-of-range minute makes the firmware +-- refuse the whole window (night_mode.h::decide returns INVALID_WINDOW), which +-- presents as "night mode never happened" with nothing saying why. Rejecting it +-- at the write is the only place a human sees the mistake. +ALTER TABLE device_configs + DROP CONSTRAINT IF EXISTS device_configs_night_start_check; +ALTER TABLE device_configs + ADD CONSTRAINT device_configs_night_start_check + CHECK (beecounter_night_start_minute BETWEEN 0 AND 1439); +ALTER TABLE device_configs + DROP CONSTRAINT IF EXISTS device_configs_night_end_check; +ALTER TABLE device_configs + ADD CONSTRAINT device_configs_night_end_check + CHECK (beecounter_night_end_minute BETWEEN 0 AND 1439); +ALTER TABLE device_configs + DROP CONSTRAINT IF EXISTS device_configs_night_max_traffic_check; +ALTER TABLE device_configs + ADD CONSTRAINT device_configs_night_max_traffic_check + CHECK (beecounter_night_max_traffic >= 0); diff --git a/server/schemas.py b/server/schemas.py index f108676..d7c4093 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -87,8 +87,8 @@ 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 — are - # deliberately NOT declared. extra="allow" carries them through into + # 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 # alerts on them, so a column each would be dead weight. # @@ -97,6 +97,12 @@ class HiveBeeCounterIn(BaseModel): # to this key from either wire revision; rows stored before HiveTraffic wire # revision 3 carry it as `gates_healthy`, with the same meaning under a name # that invited exactly the wrong reading. + # + # idle_s (wire revision 4) is the night-mode countdown: non-zero means the + # flat interval on this row is deliberate — HiveHub asked the counter to + # 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. class HiveHeartIn(BaseModel): @@ -482,6 +488,31 @@ class DeviceConfig(BaseModel): tempco_ref_temp_c: float = DEFAULT_REF_TEMP_C scale1_tempco_kg_per_c: float = 0.0 scale2_tempco_kg_per_c: float = 0.0 + # ── HiveTraffic night mode (see firmware/include/night_mode.h) ─────────── + # A paired bee counter's 48 IR emitters dominate its power draw by an order + # of magnitude, and honey bees are diurnal, so HiveHub can tell the counter + # to stop sensing overnight. Per device: every counter on one hub shares an + # apiary and a sunset. OFF by default — an existing device is unaffected + # until this is deliberately turned on. + beecounter_night_mode_enabled: bool = False + # LOCAL minutes since midnight. start > end wraps midnight, which the + # 20:00-06:00 default does; start == end is an EMPTY window, never a 24-hour + # one (the firmware refuses it), so a single mis-set field cannot stop a + # counter for good. + beecounter_night_start_minute: int = 20 * 60 + beecounter_night_end_minute: int = 6 * 60 + # Crossings (in + out) in the last upload cycle above which night mode is + # postponed to the next cycle — the "the hive is still flying, wait" rule. + # 0 disables the check and enters the window on the clock alone. + beecounter_night_max_traffic: int = 0 + # POSIX TZ string, e.g. "CET-1CEST,M3.5.0,M10.5.0/3". Empty means UTC. + # + # Load-bearing, not cosmetic: the device clock is UTC (configTime(0, 0, …)), + # so without this 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. 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 = "" class DeviceConfigUpdate(BaseModel): @@ -498,6 +529,15 @@ class DeviceConfigUpdate(BaseModel): tempco_ref_temp_c: Optional[float] = None scale1_tempco_kg_per_c: Optional[float] = None scale2_tempco_kg_per_c: Optional[float] = None + beecounter_night_mode_enabled: Optional[bool] = None + # Bounded here as well as by the database CHECK. An out-of-range minute + # makes the firmware refuse the whole window, which presents as "night mode + # never happened" with nothing anywhere saying why — so the write is the + # only place a human finds out, and it has to reject rather than clamp. + beecounter_night_start_minute: Optional[int] = Field(default=None, ge=0, le=1439) + 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) # The project was renamed HiveScale -> HiveHub, but the canonical OTA target diff --git a/test-data/test_bee_counter_wire.cpp b/test-data/test_bee_counter_wire.cpp index 4842ed6..a67dbd1 100644 --- a/test-data/test_bee_counter_wire.cpp +++ b/test-data/test_bee_counter_wire.cpp @@ -65,6 +65,22 @@ static const char* kV3 = "\"num_gates\":24,\"mcps_healthy\":3,\"total_in\":100," "\"total_out\":95,\"glitches\":2}"; +// The same device again after the night-mode update, counting normally: every +// v3 key unchanged plus idle_s, which is 0 whenever the counter is sensing. +static const char* kV4 = + "{\"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}"; + +// A counter mid-suspension at 02:00: status carries bit 0x80 on top of the +// usual 15, the totals are frozen, and idle_s counts the grant down. This is +// the document that has to be distinguishable from a counter whose emitters +// have failed — which would look identical without the last two fields. +static const char* kV4Idle = + "{\"fw\":4,\"ver\":\"0.2.0\",\"uptime_s\":50000,\"status\":143," + "\"num_gates\":24,\"mcps_healthy\":3,\"total_in\":8123," + "\"total_out\":8090,\"glitches\":2,\"idle_s\":1187}"; + // --------------------------------------------------------------------------- static void test_v2_document() { @@ -97,6 +113,58 @@ static void test_v3_document() { eqi("glitch_count", m.glitch_count, 2); } +static void test_v4_document() { + g_case = "fw:4"; + std::printf("fw:4 document (counter counting):\n"); + const Measurement m = parseOrDie(kV4); + eqi("protocol_version", m.protocol_version, 4); + eqi("mcps_healthy", m.mcps_healthy, 3); + eqi("idle_s", m.idle_s, 0); + check("not night idle", !isNightIdle(m)); + check("night bit clear", (m.status_flags & STATUS_NIGHT_IDLE) == 0); +} + +static void test_v4_suspended_document() { + // Why the field exists. Totals frozen and no crossings this interval is + // exactly what a dead emitter bank produces; idle_s and bit 0x80 are the only + // things separating "deliberately not looking" from "broken". + g_case = "fw:4 suspended"; + std::printf("fw:4 document (counter in night mode):\n"); + const Measurement m = parseOrDie(kV4Idle); + eqi("protocol_version", m.protocol_version, 4); + eqi("idle_s", m.idle_s, 1187); + eqi("status_flags", m.status_flags, 143); // 15 | 0x80 + check("is night idle", isNightIdle(m)); + check("night bit set", (m.status_flags & STATUS_NIGHT_IDLE) != 0); + // The rest of the document still decodes: a suspended counter is still + // reporting its health, and mcps_healthy staying at 3 is what says the + // expanders are fine and the silence is deliberate. + eqi("mcps_healthy", m.mcps_healthy, 3); + eqi("total_in", m.total_in, 8123); +} + +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 + // — on this side, where the consequence is misreading every suspended device. + g_case = "status bit 0x80"; + check("STATUS_NIGHT_IDLE is bit 7", STATUS_NIGHT_IDLE == 0x80); + check("REV_NIGHT_MODE is 4", REV_NIGHT_MODE == 4); +} + +static void test_older_counters_report_no_suspension() { + // A counter that predates night mode cannot be suspended, and must not look + // suspended: idle_s defaults to 0 and isNightIdle() is false for both older + // revisions, whatever their status byte happens to carry. + g_case = "pre-v4 counters"; + const Measurement v2 = parseOrDie(kV2); + const Measurement v3 = parseOrDie(kV3); + eqi("v2 idle_s", v2.idle_s, 0); + eqi("v3 idle_s", v3.idle_s, 0); + check("v2 not idle", !isNightIdle(v2)); + check("v3 not idle", !isNightIdle(v3)); +} + static void test_both_revisions_agree() { // The rename carried no change of meaning, so the two documents above // describe the same device state. If this ever diverges, a fleet mid-rollout @@ -112,6 +180,16 @@ static void test_both_revisions_agree() { check("status", a.status_flags == b.status_flags); check("num_gates", a.num_gates == b.num_gates); check("fw differs", a.protocol_version != b.protocol_version); + + // v4 describes the same device once more: the revision is purely additive, so + // nothing a v3 consumer already read may have moved underneath it. + const Measurement c = parseOrDie(kV4); + check("v3/v4 mcps_healthy", b.mcps_healthy == c.mcps_healthy); + check("v3/v4 uptime_s", b.uptime_s == c.uptime_s); + check("v3/v4 totals", + b.total_in == c.total_in && b.total_out == c.total_out); + check("v3/v4 glitches", b.glitch_count == c.glitch_count); + check("v3/v4 status", b.status_flags == c.status_flags); } static void test_v3_wide_fields() { @@ -247,6 +325,10 @@ static void test_mismatched_revision_and_field_name() { int main() { test_v2_document(); test_v3_document(); + test_v4_document(); + test_v4_suspended_document(); + test_night_status_bit_matches_the_counter(); + test_older_counters_report_no_suspension(); test_both_revisions_agree(); test_v3_wide_fields(); test_out_of_range_saturates(); diff --git a/test-data/test_night_mode.cpp b/test-data/test_night_mode.cpp new file mode 100644 index 0000000..71b3296 --- /dev/null +++ b/test-data/test_night_mode.cpp @@ -0,0 +1,284 @@ +// ============================================================================ +// Host tests for firmware/include/night_mode.h +// ============================================================================ +// +// Night mode decides, once per upload cycle, whether to blind a bee counter. It +// is therefore a feature whose bugs are quiet in both directions: suspend when +// you should not and a day's foraging vanishes into a row of zeros that looks +// like bad weather; fail to suspend and nothing happens at all except a flat +// battery three weeks later. Neither shows up in a bench session, and the +// interesting inputs — midnight, an unsynced clock, a hive still flying at +// 21:00 in June — are not ones anyone will sit through on hardware. +// +// g++ -std=gnu++17 -Wall -Wextra -Werror -I firmware/include +// -o /tmp/t test-data/test_night_mode.cpp && /tmp/t +// +// Runs in CI on a plain host compiler — no ESP32, no counter, no hive. +// ============================================================================ + +#include "night_mode.h" + +#include +#include + +using namespace nightmode; + +static int g_failures = 0; +static const char* g_case = ""; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf(" FAIL %s:%d [%s] %s\n", __FILE__, __LINE__, \ + g_case, #cond); \ + ++g_failures; \ + } \ + } while (0) + +static constexpr uint16_t hm(int h, int m) { + return static_cast(h * 60 + m); +} + +static constexpr uint32_t CYCLE_S = 600; // HiveHub's default 10 minutes + +// A configuration that is on, with the window the dashboard defaults to. +static Config enabled() { + Config c; + c.enabled = true; + c.start_minute = hm(20, 0); + c.end_minute = hm(6, 0); + c.max_traffic = 0; + return c; +} + +static Traffic quiet(uint32_t crossings = 0) { + Traffic t; + t.known = true; + t.crossings = crossings; + return t; +} + +// -------------------------------------------------------------------------- + +static void test_window_wraps_midnight() { + // The whole point of the default window is that it spans midnight, which is + // the arithmetic everyone gets wrong once. + g_case = "a 20:00-06:00 window wraps midnight"; + const uint16_t start = hm(20, 0), end = hm(6, 0); + CHECK(inWindow(hm(20, 0), start, end)); // inclusive at the start + CHECK(inWindow(hm(23, 59), start, end)); + CHECK(inWindow(hm(0, 0), start, end)); // midnight itself + CHECK(inWindow(hm(3, 30), start, end)); + CHECK(inWindow(hm(5, 59), start, end)); + CHECK(!inWindow(hm(6, 0), start, end)); // exclusive at the end + CHECK(!inWindow(hm(12, 0), start, end)); + CHECK(!inWindow(hm(19, 59), start, end)); +} + +static void test_window_within_one_day() { + g_case = "a window that does not cross midnight"; + const uint16_t start = hm(1, 0), end = hm(5, 0); + CHECK(!inWindow(hm(0, 59), start, end)); + CHECK(inWindow(hm(1, 0), start, end)); + CHECK(inWindow(hm(4, 59), start, end)); + CHECK(!inWindow(hm(5, 0), start, end)); + CHECK(!inWindow(hm(23, 0), start, end)); +} + +static void test_equal_bounds_are_empty_not_all_day() { + // Reading start == end as "all day" would let one mis-set field stop a + // counter permanently, an hour at a time, with nothing in the data saying + // why. There is no configuration that reading would serve. + g_case = "start == end is an empty window, never a 24-hour one"; + CHECK(!inWindow(hm(0, 0), hm(20, 0), hm(20, 0))); + CHECK(!inWindow(hm(20, 0), hm(20, 0), hm(20, 0))); + CHECK(!inWindow(hm(12, 0), hm(20, 0), hm(20, 0))); + + Config c = enabled(); + c.end_minute = c.start_minute; + const Decision d = decide(c, hm(23, 0), quiet(), CYCLE_S, false); + CHECK(!d.suspend); + CHECK(d.reason == Reason::INVALID_WINDOW); +} + +static void test_out_of_range_minutes_are_refused() { + g_case = "a nonsense window suspends nothing"; + CHECK(!inWindow(hm(23, 0), 1440, hm(6, 0))); + CHECK(!inWindow(hm(23, 0), hm(20, 0), 5000)); + + Config c = enabled(); + c.start_minute = 1440; + const Decision d = decide(c, hm(23, 0), quiet(), CYCLE_S, false); + CHECK(!d.suspend); + CHECK(d.reason == Reason::INVALID_WINDOW); +} + +static void test_disabled_by_default() { + // The feature ships off. A device that has never been configured must + // behave exactly as it did before night mode existed. + g_case = "an unconfigured device is never suspended"; + Config c; // defaults + CHECK(!c.enabled); + const Decision d = decide(c, hm(2, 0), quiet(), CYCLE_S, false); + CHECK(!d.suspend); + CHECK(d.duration_s == 0); + CHECK(d.reason == Reason::DISABLED); +} + +static void test_suspends_inside_the_window() { + g_case = "the ordinary night"; + const Decision d = decide(enabled(), hm(23, 30), quiet(), CYCLE_S, false); + CHECK(d.suspend); + CHECK(d.reason == Reason::SUSPEND); + CHECK(d.duration_s == CYCLE_S * GRANT_CYCLES); +} + +static void test_daytime_resumes() { + g_case = "daytime never suspends"; + const Decision d = decide(enabled(), hm(13, 0), quiet(), CYCLE_S, false); + CHECK(!d.suspend); + CHECK(d.duration_s == 0); + CHECK(d.reason == Reason::OUTSIDE_WINDOW); +} + +static void test_unknown_clock_never_suspends() { + // An RTC that lost power and an NTP sync that has not landed leave a + // plausible-looking 1970. Guessing would park every counter in the fleet. + g_case = "no valid local time means keep counting"; + const Decision d = decide(enabled(), MINUTES_PER_DAY, quiet(), CYCLE_S, false); + CHECK(!d.suspend); + CHECK(d.reason == Reason::NO_CLOCK); + + const Decision d2 = decide(enabled(), 60000, quiet(), CYCLE_S, false); + CHECK(!d2.suspend); + CHECK(d2.reason == Reason::NO_CLOCK); +} + +static void test_relay_pending_wins() { + // The relay needs the counter awake, and the standing advice is to relay at + // night precisely because a transfer costs counted bees — so without this + // the two features collide exactly when both are meant to run. + g_case = "a queued OTA relay keeps the counter awake"; + const Decision d = decide(enabled(), hm(1, 0), quiet(), CYCLE_S, true); + CHECK(!d.suspend); + CHECK(d.reason == Reason::RELAY_PENDING); +} + +static void test_traffic_gate_postpones_a_busy_hive() { + // The user-facing rule: inside the window, but still busy -> wait. A hot + // June evening at 20:30 is exactly this case. + g_case = "a hive still flying postpones night mode"; + Config c = enabled(); + c.max_traffic = 100; + + const Decision busy = decide(c, hm(20, 30), quiet(350), CYCLE_S, false); + CHECK(!busy.suspend); + CHECK(busy.reason == Reason::TRAFFIC_ABOVE_LIMIT); + + // At the threshold exactly: not "above", so it suspends. Documented here + // because "max 100 bees" reads either way to a human. + const Decision at_limit = decide(c, hm(20, 30), quiet(100), CYCLE_S, false); + CHECK(at_limit.suspend); + + const Decision settled = decide(c, hm(21, 30), quiet(12), CYCLE_S, false); + CHECK(settled.suspend); + CHECK(settled.reason == Reason::SUSPEND); +} + +static void test_traffic_gate_needs_a_baseline() { + // "I have no idea how busy this hive is" is not "it is quiet". Costs one + // cycle after a HiveHub reboot; the alternative closes the beam on a hive + // that may still be flying. + g_case = "no baseline postpones rather than assuming quiet"; + Config c = enabled(); + c.max_traffic = 100; + Traffic t; // known == false + const Decision d = decide(c, hm(23, 0), t, CYCLE_S, false); + CHECK(!d.suspend); + CHECK(d.reason == Reason::TRAFFIC_UNKNOWN); + + // With the gate off, a missing baseline is irrelevant — the clock decides. + Config no_gate = enabled(); + const Decision d2 = decide(no_gate, hm(23, 0), t, CYCLE_S, false); + CHECK(d2.suspend); +} + +static void test_grant_is_bounded() { + // Long enough to ride out one missed cycle, short enough that a HiveHub + // that dies mid-night frees the counter promptly. + g_case = "grants are clamped at both ends"; + CHECK(grantSeconds(600) == 1200); + CHECK(grantSeconds(30) == MIN_GRANT_S); // absurdly short interval + CHECK(grantSeconds(0) == MIN_GRANT_S); + CHECK(grantSeconds(7200) == MAX_GRANT_S); // absurdly long interval + CHECK(grantSeconds(0xFFFFFFFFUL) == MAX_GRANT_S); // no overflow wrap + CHECK(MAX_GRANT_S <= 3600); // matches the counter's cap +} + +static void test_crossings_handle_a_counter_reboot() { + // Same rule the backend applies when it differences the same totals, so the + // gate here and the interval charted later never disagree. + g_case = "a counter that restarted is not a negative interval"; + CHECK(crossingsBetween(100, 140) == 40); + CHECK(crossingsBetween(100, 100) == 0); + CHECK(crossingsBetween(5000, 12) == 12); // rebooted, 12 since restart + CHECK(crossingsBetween(0, 0) == 0); + CHECK(crossingsBetween(UINT32_MAX, UINT32_MAX) == 0); // saturated +} + +static void test_a_whole_night_of_cycles() { + // Walk the clock through a night at the real cadence and assert the shape + // of the whole thing rather than one instant: quiet evening, suspended + // overnight, counting again at 06:00 sharp. + g_case = "one simulated night, cycle by cycle"; + Config c = enabled(); + c.max_traffic = 100; + + int suspended = 0, counting = 0; + for (int minute = 0; minute < 1440; minute += 10) { + // A plausible day: busy from 08:00 to 20:00, nothing outside it. + const bool flying = minute >= hm(8, 0) && minute < hm(20, 0); + const Decision d = decide(c, static_cast(minute), + quiet(flying ? 400 : 0), CYCLE_S, false); + if (d.suspend) { + ++suspended; + CHECK(inWindow(static_cast(minute), c.start_minute, c.end_minute)); + } else { + ++counting; + } + } + // 20:00-06:00 is 600 minutes = 60 ten-minute cycles, all of them quiet. + CHECK(suspended == 60); + CHECK(counting == 84); + + // And the boundaries specifically. + CHECK(decide(c, hm(19, 50), quiet(400), CYCLE_S, false).suspend == false); + CHECK(decide(c, hm(20, 0), quiet(0), CYCLE_S, false).suspend == true); + CHECK(decide(c, hm(5, 50), quiet(0), CYCLE_S, false).suspend == true); + CHECK(decide(c, hm(6, 0), quiet(0), CYCLE_S, false).suspend == false); +} + +int main() { + std::printf("night_mode tests\n"); + test_window_wraps_midnight(); + test_window_within_one_day(); + test_equal_bounds_are_empty_not_all_day(); + test_out_of_range_minutes_are_refused(); + test_disabled_by_default(); + test_suspends_inside_the_window(); + test_daytime_resumes(); + test_unknown_clock_never_suspends(); + test_relay_pending_wins(); + test_traffic_gate_postpones_a_busy_hive(); + test_traffic_gate_needs_a_baseline(); + test_grant_is_bounded(); + test_crossings_handle_a_counter_reboot(); + test_a_whole_night_of_cycles(); + + if (g_failures == 0) { + std::printf("all tests passed\n"); + return EXIT_SUCCESS; + } + std::printf("%d check(s) failed\n", g_failures); + return EXIT_FAILURE; +} diff --git a/website/dashboard-demo/assets/api.js b/website/dashboard-demo/assets/api.js index 8bad50a..6b8a9a5 100644 --- a/website/dashboard-demo/assets/api.js +++ b/website/dashboard-demo/assets/api.js @@ -170,6 +170,9 @@ export const api = { scale2_offset: 12010, scale2_factor: 21.7, config_version: 4, tempco_enabled: true, tempco_source: "hive_1", 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, + timezone: "CET-1CEST,M3.5.0,M10.5.0/3", }), channels: (deviceId) => { diff --git a/website/dashboard-demo/assets/views.js b/website/dashboard-demo/assets/views.js index 3a89a34..b45d460 100644 --- a/website/dashboard-demo/assets/views.js +++ b/website/dashboard-demo/assets/views.js @@ -1679,6 +1679,59 @@ function renderDevice(root, state) { finally { fitBtn.disabled = false; } }); + // ── HiveTraffic night mode ────────────────────────────────────────────── + // Mirrors server/dashboard/assets/views.js. Times are entered as local + // wall-clock and stored as minutes since midnight; the window may wrap + // midnight (20:00 -> 06:00). + const minutesToHhmm = (m) => { + const v = Number(m); + if (!Number.isFinite(v) || v < 0 || v > 1439) return ""; + return `${String(Math.floor(v / 60)).padStart(2, "0")}:${String(v % 60).padStart(2, "0")}`; + }; + const hhmmToMinutes = (text) => { + const m = /^\s*(\d{1,2}):(\d{2})\s*$/.exec(text || ""); + if (!m) return null; + const h = Number(m[1]), min = Number(m[2]); + if (h > 23 || min > 59) return null; + return h * 60 + min; + }; + + const nmEnabled = el("input", { type: "checkbox" }); + nmEnabled.checked = !!cfg.beecounter_night_mode_enabled; + const nmStart = el("input", { type: "time", value: minutesToHhmm(cfg.beecounter_night_start_minute ?? 1200) }); + const nmEnd = el("input", { type: "time", value: minutesToHhmm(cfg.beecounter_night_end_minute ?? 360) }); + const nmMaxTraffic = el("input", { + type: "number", min: "0", step: "1", + value: String(cfg.beecounter_night_max_traffic ?? 0), + }); + + // A POSIX TZ string rather than an IANA name: the ESP32 carries no tz + // database, and newlib parses this form directly, DST rules included. + const TZ_PRESETS = [ + ["", "UTC (no local time)"], + ["CET-1CEST,M3.5.0,M10.5.0/3", "Central Europe (Berlin, Paris, Madrid)"], + ["GMT0BST,M3.5.0/1,M10.5.0", "United Kingdom / Ireland"], + ["EET-2EEST,M3.5.0/3,M10.5.0/4", "Eastern Europe (Helsinki, Athens)"], + ["EST5EDT,M3.2.0,M11.1.0", "US Eastern"], + ["CST6CDT,M3.2.0,M11.1.0", "US Central"], + ["MST7MDT,M3.2.0,M11.1.0", "US Mountain"], + ["PST8PDT,M3.2.0,M11.1.0", "US Pacific"], + ]; + const nmTzCustom = el("input", { + type: "text", placeholder: "CET-1CEST,M3.5.0,M10.5.0/3", + value: cfg.timezone || "", + }); + const storedTz = cfg.timezone || ""; + const isPreset = TZ_PRESETS.some(([value]) => value === storedTz); + const nmTzSelect = el("select", { class: "full" }, + ...TZ_PRESETS.map(([value, label]) => + el("option", { value, selected: isPreset && value === storedTz ? true : null }, label)), + el("option", { value: "__custom__", selected: isPreset ? null : true }, "Custom…")); + const nmTzCustomRow = fieldRow("POSIX TZ string", nmTzCustom); + const syncTzRow = () => { nmTzCustomRow.hidden = nmTzSelect.value !== "__custom__"; }; + nmTzSelect.addEventListener("change", syncTzRow); + syncTzRow(); + 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", {}, @@ -1703,7 +1756,33 @@ function renderDevice(root, state) { el("div", { class: "fit-row" }, fieldRow("Fit lookback (days)", lookbackInput), el("div", { class: "form-actions" }, fitBtn)), - fitOut)), + fitOut), + el("div", { class: "config-block" }, + el("h3", {}, "Bee counter night mode"), + el("p", { class: "note" }, + "Honey bees are diurnal — flight needs light, and stops below about " + + "10 °C regardless — while a HiveTraffic counter's 48 IR emitters are " + + "the largest item in its power budget. In this window the counter is " + + "told to stop sensing, which is what makes it fit an off-grid supply. " + + "It keeps advertising, so readings and firmware relays still work."), + el("div", { class: "form-row" }, + el("label", {}, el("span", {}, "Enable night mode "), nmEnabled)), + fieldRow("Night starts (local)", nmStart), + fieldRow("Night ends (local)", nmEnd), + el("p", { class: "note" }, + "The window may cross midnight. Setting both to the same time " + + "disables it rather than covering the whole day. Leave a margin " + + "either side of dusk and dawn: activity peaks just before sunset " + + "and just before sunrise."), + fieldRow("Timezone", nmTzSelect), + nmTzCustomRow, + el("p", { class: "note" }, + "The device clock is UTC. Without a timezone the window drifts by " + + "an hour at each daylight-saving change."), + fieldRow("Postpone above (crossings per cycle)", nmMaxTraffic), + el("p", { class: "note" }, + "If more than this many bees crossed in the last upload cycle, night " + + "mode waits for the next one. 0 goes by the clock alone."))), el("p", { class: "note" }, "Saving bumps the config version; the device applies it on its next check-in."), el("div", { class: "form-actions" }, cfgSaveBtn)); showScale(); From fc531a7f8d8c9932f21624907e90ea561e3af3cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 09:35:03 +0000 Subject: [PATCH 2/4] night_mode: keep the Reason enumerators out of Arduino's macro namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both firmware builds failed to compile: Arduino's esp32-hal-gpio.h carries `#define DISABLED 0x00`, and the preprocessor does not care that the enumerator lives in an enum class inside a namespace. The macro expanded inside the enum body, the whole nightmode namespace failed to parse, and every later use cascaded into "'struct Decision' has no member named 'reason'". Rename the enumerators to CamelCase — which is the house style already (ClaimStatus) and, more to the point, is outside the all-caps namespace Arduino and ESP-IDF share. The host test could not have caught this: it compiles night_mode.h against a bare host compiler, where none of those macros exist, so it passed while the firmware build failed. Fix that too — the test now defines the Arduino macros that actually bite, with their real values, before the include. A future identifier that strays into the macro namespace fails here in seconds instead of three minutes into a firmware build. Verified by reintroducing DISABLED and confirming the test stops compiling. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016gFpezSuXW3sHMd1xaSYnd --- firmware/include/night_mode.h | 58 ++++++++++++++++++++--------------- test-data/test_night_mode.cpp | 46 ++++++++++++++++++++------- 2 files changed, 68 insertions(+), 36 deletions(-) diff --git a/firmware/include/night_mode.h b/firmware/include/night_mode.h index 018898b..82288ea 100644 --- a/firmware/include/night_mode.h +++ b/firmware/include/night_mode.h @@ -60,15 +60,23 @@ constexpr uint32_t GRANT_CYCLES = 2; // Why decide() answered the way it did. Logged verbatim: "the counter kept // counting last night" is otherwise a question with six plausible answers. +// +// CamelCase, not SCREAMING_CASE, and not by taste: Arduino's esp32-hal-gpio.h +// carries `#define DISABLED 0x00`, and the preprocessor does not care that +// these live in an enum class inside a namespace — an all-caps enumerator is +// substituted anywhere it appears, and the whole namespace fails to parse. The +// firmware build caught exactly that. Any name added here must stay out of the +// all-caps macro namespace that Arduino and ESP-IDF share. (This also matches +// the house style already used by ClaimStatus in hivehub_network.h.) enum class Reason : uint8_t { - DISABLED, // night mode is off for this device - NO_CLOCK, // no valid local time — never guess at the schedule - INVALID_WINDOW, // start == end, or a value out of range - OUTSIDE_WINDOW, // daytime - RELAY_PENDING, // an OTA relay is queued for this counter - TRAFFIC_UNKNOWN, // no previous totals to difference yet - TRAFFIC_ABOVE_LIMIT, // still busy; postpone to the next cycle - SUSPEND, // all clear: stop sensing + Disabled, // night mode is off for this device + NoClock, // no valid local time — never guess at the schedule + InvalidWindow, // start == end, or a value out of range + OutsideWindow, // daytime + RelayPending, // an OTA relay is queued for this counter + TrafficUnknown, // no previous totals to difference yet + TrafficAboveLimit, // still busy; postpone to the next cycle + Suspend, // all clear: stop sensing }; // Per-device configuration, as edited in the dashboard and delivered by @@ -92,7 +100,7 @@ struct Traffic { struct Decision { bool suspend = false; uint32_t duration_s = 0; // 0 when suspend is false - Reason reason = Reason::DISABLED; + Reason reason = Reason::Disabled; }; // Is `minute` inside the window, which may wrap midnight? @@ -138,20 +146,20 @@ inline Decision decide(const Config& cfg, uint16_t minute_of_day, Decision d; if (!cfg.enabled) { - d.reason = Reason::DISABLED; + d.reason = Reason::Disabled; return d; } if (cfg.start_minute >= MINUTES_PER_DAY || cfg.end_minute >= MINUTES_PER_DAY || cfg.start_minute == cfg.end_minute) { - d.reason = Reason::INVALID_WINDOW; + d.reason = Reason::InvalidWindow; return d; } // An unsynced clock is the one input whose absence is silent: RTC missing // and NTP not yet reached leaves a plausible-looking 1970 that would put // every hive in the middle of the night forever. if (minute_of_day >= MINUTES_PER_DAY) { - d.reason = Reason::NO_CLOCK; + d.reason = Reason::NoClock; return d; } // Never suspend a counter we are about to stream an image to. The relay @@ -159,11 +167,11 @@ inline Decision decide(const Config& cfg, uint16_t minute_of_day, // because a transfer costs counted bees — so the two would collide exactly // when both are meant to run. if (relay_pending) { - d.reason = Reason::RELAY_PENDING; + d.reason = Reason::RelayPending; return d; } if (!inWindow(minute_of_day, cfg.start_minute, cfg.end_minute)) { - d.reason = Reason::OUTSIDE_WINDOW; + d.reason = Reason::OutsideWindow; return d; } if (cfg.max_traffic > 0) { @@ -172,31 +180,31 @@ inline Decision decide(const Config& cfg, uint16_t minute_of_day, // shutting the beam on a hive that is still flying, and "I don't know" // is not "it's quiet". Costs one cycle. if (!traffic.known) { - d.reason = Reason::TRAFFIC_UNKNOWN; + d.reason = Reason::TrafficUnknown; return d; } if (traffic.crossings > cfg.max_traffic) { - d.reason = Reason::TRAFFIC_ABOVE_LIMIT; + d.reason = Reason::TrafficAboveLimit; return d; } } d.suspend = true; d.duration_s = grantSeconds(cycle_seconds); - d.reason = Reason::SUSPEND; + d.reason = Reason::Suspend; return d; } inline const char* reasonName(Reason r) { switch (r) { - case Reason::DISABLED: return "disabled"; - case Reason::NO_CLOCK: return "no valid local time"; - case Reason::INVALID_WINDOW: return "invalid window"; - case Reason::OUTSIDE_WINDOW: return "outside the night window"; - case Reason::RELAY_PENDING: return "an OTA relay is queued"; - case Reason::TRAFFIC_UNKNOWN: return "no traffic baseline yet"; - case Reason::TRAFFIC_ABOVE_LIMIT: return "still busy"; - case Reason::SUSPEND: return "inside the night window"; + case Reason::Disabled: return "disabled"; + case Reason::NoClock: return "no valid local time"; + case Reason::InvalidWindow: return "invalid window"; + case Reason::OutsideWindow: return "outside the night window"; + case Reason::RelayPending: return "an OTA relay is queued"; + case Reason::TrafficUnknown: return "no traffic baseline yet"; + case Reason::TrafficAboveLimit: return "still busy"; + case Reason::Suspend: return "inside the night window"; } return "unknown"; } diff --git a/test-data/test_night_mode.cpp b/test-data/test_night_mode.cpp index 71b3296..ae63070 100644 --- a/test-data/test_night_mode.cpp +++ b/test-data/test_night_mode.cpp @@ -16,6 +16,30 @@ // Runs in CI on a plain host compiler — no ESP32, no counter, no hive. // ============================================================================ +// --------------------------------------------------------------------------- +// Arduino's macro soup, reproduced before the include on purpose. +// --------------------------------------------------------------------------- +// This header is built two ways: here, against a bare host compiler, and in the +// firmware, after Arduino.h has defined a few hundred all-caps macros. The +// second is far more hostile, and this test could not see it — a `Reason` +// enumerator named DISABLED collided with esp32-hal-gpio.h's +// `#define DISABLED 0x00` and broke both firmware builds while every check here +// passed. +// +// So define the ones that actually bite, with their real Arduino values, before +// including night_mode.h. Any future identifier that strays into the all-caps +// macro namespace now fails on a host compiler in seconds instead of three +// minutes into a firmware build. These are deliberately NOT #undef'd. +#define DISABLED 0x00 +#define INPUT 0x01 +#define OUTPUT 0x03 +#define PULLUP 0x04 +#define PULLDOWN 0x08 +#define HIGH 0x1 +#define LOW 0x0 +#define ANALOG 0xC0 +#define OPEN_DRAIN 0x10 + #include "night_mode.h" #include @@ -98,7 +122,7 @@ static void test_equal_bounds_are_empty_not_all_day() { c.end_minute = c.start_minute; const Decision d = decide(c, hm(23, 0), quiet(), CYCLE_S, false); CHECK(!d.suspend); - CHECK(d.reason == Reason::INVALID_WINDOW); + CHECK(d.reason == Reason::InvalidWindow); } static void test_out_of_range_minutes_are_refused() { @@ -110,7 +134,7 @@ static void test_out_of_range_minutes_are_refused() { c.start_minute = 1440; const Decision d = decide(c, hm(23, 0), quiet(), CYCLE_S, false); CHECK(!d.suspend); - CHECK(d.reason == Reason::INVALID_WINDOW); + CHECK(d.reason == Reason::InvalidWindow); } static void test_disabled_by_default() { @@ -122,14 +146,14 @@ static void test_disabled_by_default() { const Decision d = decide(c, hm(2, 0), quiet(), CYCLE_S, false); CHECK(!d.suspend); CHECK(d.duration_s == 0); - CHECK(d.reason == Reason::DISABLED); + CHECK(d.reason == Reason::Disabled); } static void test_suspends_inside_the_window() { g_case = "the ordinary night"; const Decision d = decide(enabled(), hm(23, 30), quiet(), CYCLE_S, false); CHECK(d.suspend); - CHECK(d.reason == Reason::SUSPEND); + CHECK(d.reason == Reason::Suspend); CHECK(d.duration_s == CYCLE_S * GRANT_CYCLES); } @@ -138,7 +162,7 @@ static void test_daytime_resumes() { const Decision d = decide(enabled(), hm(13, 0), quiet(), CYCLE_S, false); CHECK(!d.suspend); CHECK(d.duration_s == 0); - CHECK(d.reason == Reason::OUTSIDE_WINDOW); + CHECK(d.reason == Reason::OutsideWindow); } static void test_unknown_clock_never_suspends() { @@ -147,11 +171,11 @@ static void test_unknown_clock_never_suspends() { g_case = "no valid local time means keep counting"; const Decision d = decide(enabled(), MINUTES_PER_DAY, quiet(), CYCLE_S, false); CHECK(!d.suspend); - CHECK(d.reason == Reason::NO_CLOCK); + CHECK(d.reason == Reason::NoClock); const Decision d2 = decide(enabled(), 60000, quiet(), CYCLE_S, false); CHECK(!d2.suspend); - CHECK(d2.reason == Reason::NO_CLOCK); + CHECK(d2.reason == Reason::NoClock); } static void test_relay_pending_wins() { @@ -161,7 +185,7 @@ static void test_relay_pending_wins() { g_case = "a queued OTA relay keeps the counter awake"; const Decision d = decide(enabled(), hm(1, 0), quiet(), CYCLE_S, true); CHECK(!d.suspend); - CHECK(d.reason == Reason::RELAY_PENDING); + CHECK(d.reason == Reason::RelayPending); } static void test_traffic_gate_postpones_a_busy_hive() { @@ -173,7 +197,7 @@ static void test_traffic_gate_postpones_a_busy_hive() { const Decision busy = decide(c, hm(20, 30), quiet(350), CYCLE_S, false); CHECK(!busy.suspend); - CHECK(busy.reason == Reason::TRAFFIC_ABOVE_LIMIT); + CHECK(busy.reason == Reason::TrafficAboveLimit); // At the threshold exactly: not "above", so it suspends. Documented here // because "max 100 bees" reads either way to a human. @@ -182,7 +206,7 @@ static void test_traffic_gate_postpones_a_busy_hive() { const Decision settled = decide(c, hm(21, 30), quiet(12), CYCLE_S, false); CHECK(settled.suspend); - CHECK(settled.reason == Reason::SUSPEND); + CHECK(settled.reason == Reason::Suspend); } static void test_traffic_gate_needs_a_baseline() { @@ -195,7 +219,7 @@ static void test_traffic_gate_needs_a_baseline() { Traffic t; // known == false const Decision d = decide(c, hm(23, 0), t, CYCLE_S, false); CHECK(!d.suspend); - CHECK(d.reason == Reason::TRAFFIC_UNKNOWN); + CHECK(d.reason == Reason::TrafficUnknown); // With the gate off, a missing baseline is irrelevant — the clock decides. Config no_gate = enabled(); From 7c6089a8698cfabda5c7b7bad04aabff16fe9e0d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 09:46:11 +0000 Subject: [PATCH 3/4] Bump firmware to 0.24.17 and the backend to 0.3.1 for night mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were missed when the feature landed. FIRMWARE_VERSION matters functionally, not just for bookkeeping: HiveHub's own OTA path compares it to decide whether a release is newer, so a device carrying night mode while still reporting 0.24.16 would not be offered the update, and nothing would confirm afterwards that it took. Every firmware behaviour change in this repo's history bumps it; this one adds a feature. SERVER_VERSION moves to 0.3.1 because /config gained fields (beecounter_night_* and timezone) and /health is where an operator reads whether their backend understands them — a device talking to an older backend simply gets the defaults and stays in the pre-night-mode behaviour, which is safe but silent. Kept to a patch bump: 0.2.0 -> 0.3.0 was a documented release with docs/releases/v0.3.md behind it, and this is one additive feature rather than a release. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016gFpezSuXW3sHMd1xaSYnd --- docs/api.md | 2 +- firmware/src/globals.cpp | 2 +- server/config.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api.md b/docs/api.md index 56f62c9..34e7b82 100644 --- a/docs/api.md +++ b/docs/api.md @@ -78,7 +78,7 @@ Health check. No authentication required. `version` is the backend server version (`SERVER_VERSION` in `server/config.py`). ```json -{ "status": "ok", "version": "0.3.0" } +{ "status": "ok", "version": "0.3.1" } ``` ### `GET /api/v1/time` diff --git a/firmware/src/globals.cpp b/firmware/src/globals.cpp index dde8786..e9a45d9 100644 --- a/firmware/src/globals.cpp +++ b/firmware/src/globals.cpp @@ -3,7 +3,7 @@ #include -const char* const FIRMWARE_VERSION = "0.24.16"; +const char* const FIRMWARE_VERSION = "0.24.17"; #if ENABLE_HX711 HX711 scale1; diff --git a/server/config.py b/server/config.py index 58af4f9..217ce7f 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.0" +SERVER_VERSION = "0.3.1" DATABASE_URL = os.environ["DATABASE_URL"] API_KEY = os.environ["API_KEY"] From 88b9d52e6ae89074748bcd71776dc7d091defa47 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 09:39:24 +0000 Subject: [PATCH 4/4] dashboard: give night mode its own "HiveTraffic setup" drop-down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as "I can't see it", and that was accurate. The fields were real and wired up, but they were a third column inside the Configuration form's grid — and that panel is a
closed by default, so reaching them meant expanding Configuration and scrolling past scale calibration and the tempco fit tool. Nobody looking for a bee-counter setting would look there. Move them into a top-level collapsible of their own, next to Configuration, Publish data and Admin, with their own submit handler and Save button so the panel is self-contained rather than depending on a button in a different section. No change to what is sent: the same PATCH fields, the same "start == end would never apply" guard, the same only-changed-fields patch. Mirrored into the demo dashboard, and verified by rendering it in Chromium — the summary is visible while closed, the panel opens, and the controls read back the configured window. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016gFpezSuXW3sHMd1xaSYnd --- server/dashboard/assets/views.js | 142 +++++++++++++++---------- website/dashboard-demo/assets/views.js | 62 ++++++----- 2 files changed, 121 insertions(+), 83 deletions(-) diff --git a/server/dashboard/assets/views.js b/server/dashboard/assets/views.js index 5c9960f..2f313ab 100644 --- a/server/dashboard/assets/views.js +++ b/server/dashboard/assets/views.js @@ -1946,33 +1946,16 @@ function renderDevice(root, state) { const selectedTz = () => (nmTzSelect.value === "__custom__" ? nmTzCustom.value.trim() : nmTzSelect.value); - 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", {}, + // Its own form and its own save button, in its own top-level drop-down. + // Folded into the Configuration grid it was effectively invisible: that panel + // is a
closed by default, so the fields sat two levels down behind + // scale calibration, where nobody looking for a bee-counter setting would + // think to look. + const nmSaveBtn = el("button", { class: "btn", type: "submit" }, "Save night mode"); + const nmForm = el("form", {}, el("div", { class: "config-grid" }, el("div", { class: "config-block" }, - el("h3", {}, "General"), - el("div", { class: "rows" }, - metaRow("Device ID", state.device?.device_id || DASH), - metaRow("Config version", cfg.config_version ?? DASH)), - fieldRow("Send interval (s)", numInput("send_interval_seconds", true))), - el("div", { class: "config-block" }, - el("h3", {}, "Scale calibration & compensation"), - fieldRow("Scale", scaleSelect), - ...scaleGroups.values(), - el("div", { class: "form-row" }, el("label", {}, el("span", {}, "Enable temperature compensation "), tcEnabled)), - fieldRow("Tempco source", tcSource), - fieldRow("Tempco ref temp (°C)", numInput("tempco_ref_temp_c")), - el("p", { class: "note" }, - "The temperature at which this scale reads true — the one it was tared and " + - "spanned at. The correction is zero there and grows as the temperature moves " + - "away from it, so fitting a coefficient never changes it."), - el("div", { class: "fit-row" }, - fieldRow("Fit lookback (days)", lookbackInput), - el("div", { class: "form-actions" }, fitBtn)), - fitOut), - el("div", { class: "config-block" }, - el("h3", {}, "Bee counter night mode"), + el("h3", {}, "Night mode"), el("p", { class: "note" }, "Honey bees are diurnal — flight needs light, and stops below about " + "10 °C regardless — while a HiveTraffic counter's 48 IR emitters are " + @@ -2000,6 +1983,81 @@ function renderDevice(root, state) { "mode waits for the next one — so a hive still flying at 20:00 in " + "June is not cut off mid-flight. 0 disables the check and goes by " + "the clock alone."))), + el("p", { class: "note" }, + "Applies to every HiveTraffic counter paired to this device. Saving bumps " + + "the config version; the counter is told on the next upload cycle."), + el("div", { class: "form-actions" }, nmSaveBtn)); + + nmForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const patch = {}; + if (nmEnabled.checked !== !!cfg.beecounter_night_mode_enabled) { + patch.beecounter_night_mode_enabled = nmEnabled.checked; + } + // Times are sent as minutes since midnight. An unparseable or empty field is + // left out entirely rather than sent as 0, which is not a missing value but + // a real and very wrong window (midnight). + const startMin = hhmmToMinutes(nmStart.value); + if (startMin !== null && startMin !== cfg.beecounter_night_start_minute) { + patch.beecounter_night_start_minute = startMin; + } + const endMin = hhmmToMinutes(nmEnd.value); + if (endMin !== null && endMin !== cfg.beecounter_night_end_minute) { + patch.beecounter_night_end_minute = endMin; + } + const maxTraffic = parseInt(nmMaxTraffic.value, 10); + if (Number.isFinite(maxTraffic) && maxTraffic >= 0 && + maxTraffic !== cfg.beecounter_night_max_traffic) { + patch.beecounter_night_max_traffic = maxTraffic; + } + const tz = selectedTz(); + if (tz !== (cfg.timezone || "")) patch.timezone = tz; + + // Enabling a window whose ends are equal would be stored happily and then + // refused by the firmware, so the counter would simply never sleep and + // nothing anywhere would say why. Catch it at the only point a human is + // looking. + const finalStart = patch.beecounter_night_start_minute ?? cfg.beecounter_night_start_minute; + const finalEnd = patch.beecounter_night_end_minute ?? cfg.beecounter_night_end_minute; + if (nmEnabled.checked && finalStart === finalEnd) { + state.toast("Night mode start and end are the same — the window would never apply", "error"); + return; + } + + if (!Object.keys(patch).length) { state.toast("No changes to save"); return; } + nmSaveBtn.disabled = true; + try { + await state.actions.updateConfig(patch); + state.toast("Night mode saved", "success"); + state.reload({ full: true }); + } catch (err) { state.toast(err.message, "error"); nmSaveBtn.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", {}, + el("div", { class: "config-grid" }, + el("div", { class: "config-block" }, + el("h3", {}, "General"), + el("div", { class: "rows" }, + metaRow("Device ID", state.device?.device_id || DASH), + metaRow("Config version", cfg.config_version ?? DASH)), + fieldRow("Send interval (s)", numInput("send_interval_seconds", true))), + el("div", { class: "config-block" }, + el("h3", {}, "Scale calibration & compensation"), + fieldRow("Scale", scaleSelect), + ...scaleGroups.values(), + el("div", { class: "form-row" }, el("label", {}, el("span", {}, "Enable temperature compensation "), tcEnabled)), + fieldRow("Tempco source", tcSource), + fieldRow("Tempco ref temp (°C)", numInput("tempco_ref_temp_c")), + el("p", { class: "note" }, + "The temperature at which this scale reads true — the one it was tared and " + + "spanned at. The correction is zero there and grows as the temperature moves " + + "away from it, so fitting a coefficient never changes it."), + el("div", { class: "fit-row" }, + fieldRow("Fit lookback (days)", lookbackInput), + el("div", { class: "form-actions" }, fitBtn)), + fitOut)), el("p", { class: "note" }, "Saving bumps the config version; the device applies it on its next check-in."), el("div", { class: "form-actions" }, cfgSaveBtn)); showScale(); @@ -2035,39 +2093,6 @@ function renderDevice(root, state) { if (tcEnabled.checked !== !!cfg.tempco_enabled) patch.tempco_enabled = tcEnabled.checked; if (tcSource.value !== cfg.tempco_source) patch.tempco_source = tcSource.value; - // Night mode. Times are sent as minutes since midnight; an unparseable or - // empty time field is left out entirely rather than sent as 0, which is a - // real and very wrong window (midnight) rather than a missing value. - if (nmEnabled.checked !== !!cfg.beecounter_night_mode_enabled) { - patch.beecounter_night_mode_enabled = nmEnabled.checked; - } - const startMin = hhmmToMinutes(nmStart.value); - if (startMin !== null && startMin !== cfg.beecounter_night_start_minute) { - patch.beecounter_night_start_minute = startMin; - } - const endMin = hhmmToMinutes(nmEnd.value); - if (endMin !== null && endMin !== cfg.beecounter_night_end_minute) { - patch.beecounter_night_end_minute = endMin; - } - const maxTraffic = parseInt(nmMaxTraffic.value, 10); - if (Number.isFinite(maxTraffic) && maxTraffic >= 0 && - maxTraffic !== cfg.beecounter_night_max_traffic) { - patch.beecounter_night_max_traffic = maxTraffic; - } - const tz = selectedTz(); - if (tz !== (cfg.timezone || "")) patch.timezone = tz; - - // Enabling a window whose ends are equal would be stored happily and then - // refused by the firmware, so the counter would simply never sleep and - // nothing anywhere would say why. Catch it at the only point a human is - // looking. - const finalStart = patch.beecounter_night_start_minute ?? cfg.beecounter_night_start_minute; - const finalEnd = patch.beecounter_night_end_minute ?? cfg.beecounter_night_end_minute; - if (nmEnabled.checked && finalStart === finalEnd) { - state.toast("Night mode start and end are the same — the window would never apply", "error"); - return; - } - if (!Object.keys(patch).length) { state.toast("No changes to save"); return; } cfgSaveBtn.disabled = true; // full: true — a light reload carries the *previous* config over instead of @@ -2694,6 +2719,7 @@ function renderDevice(root, state) { node.append( topGrid, collapsible("Configuration", false, cfgForm, calCard), + collapsible("HiveTraffic setup", false, nmForm), // 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/website/dashboard-demo/assets/views.js b/website/dashboard-demo/assets/views.js index b45d460..bbe9fc1 100644 --- a/website/dashboard-demo/assets/views.js +++ b/website/dashboard-demo/assets/views.js @@ -1732,33 +1732,14 @@ function renderDevice(root, state) { nmTzSelect.addEventListener("change", syncTzRow); syncTzRow(); - 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", {}, + // Mirrors server/dashboard/assets/views.js: its own form in its own top-level + // drop-down, because folded into the Configuration grid it sat two levels down + // inside a closed
where nobody would find it. + const nmSaveBtn = el("button", { class: "btn", type: "submit" }, "Save night mode"); + const nmForm = el("form", {}, el("div", { class: "config-grid" }, el("div", { class: "config-block" }, - el("h3", {}, "General"), - el("div", { class: "rows" }, - metaRow("Device ID", state.device?.device_id || DASH), - metaRow("Config version", cfg.config_version ?? DASH)), - fieldRow("Send interval (s)", numInput("send_interval_seconds", true))), - el("div", { class: "config-block" }, - el("h3", {}, "Scale calibration & compensation"), - fieldRow("Scale", scaleSelect), - ...scaleGroups.values(), - el("div", { class: "form-row" }, el("label", {}, el("span", {}, "Enable temperature compensation "), tcEnabled)), - fieldRow("Tempco source", tcSource), - fieldRow("Tempco ref temp (°C)", numInput("tempco_ref_temp_c")), - el("p", { class: "note" }, - "The temperature at which this scale reads true — the one it was tared and " + - "spanned at. The correction is zero there and grows as the temperature moves " + - "away from it, so fitting a coefficient never changes it."), - el("div", { class: "fit-row" }, - fieldRow("Fit lookback (days)", lookbackInput), - el("div", { class: "form-actions" }, fitBtn)), - fitOut), - el("div", { class: "config-block" }, - el("h3", {}, "Bee counter night mode"), + el("h3", {}, "Night mode"), el("p", { class: "note" }, "Honey bees are diurnal — flight needs light, and stops below about " + "10 °C regardless — while a HiveTraffic counter's 48 IR emitters are " + @@ -1783,6 +1764,36 @@ function renderDevice(root, state) { el("p", { class: "note" }, "If more than this many bees crossed in the last upload cycle, night " + "mode waits for the next one. 0 goes by the clock alone."))), + el("p", { class: "note" }, + "Applies to every HiveTraffic counter paired to this device. Saving bumps " + + "the config version; the counter is told on the next upload cycle."), + el("div", { class: "form-actions" }, nmSaveBtn)); + + 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", {}, + el("div", { class: "config-grid" }, + el("div", { class: "config-block" }, + el("h3", {}, "General"), + el("div", { class: "rows" }, + metaRow("Device ID", state.device?.device_id || DASH), + metaRow("Config version", cfg.config_version ?? DASH)), + fieldRow("Send interval (s)", numInput("send_interval_seconds", true))), + el("div", { class: "config-block" }, + el("h3", {}, "Scale calibration & compensation"), + fieldRow("Scale", scaleSelect), + ...scaleGroups.values(), + el("div", { class: "form-row" }, el("label", {}, el("span", {}, "Enable temperature compensation "), tcEnabled)), + fieldRow("Tempco source", tcSource), + fieldRow("Tempco ref temp (°C)", numInput("tempco_ref_temp_c")), + el("p", { class: "note" }, + "The temperature at which this scale reads true — the one it was tared and " + + "spanned at. The correction is zero there and grows as the temperature moves " + + "away from it, so fitting a coefficient never changes it."), + el("div", { class: "fit-row" }, + fieldRow("Fit lookback (days)", lookbackInput), + el("div", { class: "form-actions" }, fitBtn)), + fitOut)), el("p", { class: "note" }, "Saving bumps the config version; the device applies it on its next check-in."), el("div", { class: "form-actions" }, cfgSaveBtn)); showScale(); @@ -2440,6 +2451,7 @@ function renderDevice(root, state) { node.append( topGrid, collapsible("Configuration", false, cfgForm, calCard), + collapsible("HiveTraffic setup", false, nmForm), // 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