diff --git a/.github/workflows/firmware-ci.yml b/.github/workflows/firmware-ci.yml index 0fbc3c06..690ceb0a 100644 --- a/.github/workflows/firmware-ci.yml +++ b/.github/workflows/firmware-ci.yml @@ -71,6 +71,7 @@ jobs: - { board: LC_Hotfire, project: Hotfire_Code/LC_Hotfire, pio_env: adafruit_feather_esp32s3 } - { board: Actuator_Hotfire, project: Hotfire_Code/Actuator_Hotfire, pio_env: adafruit_feather_esp32s3 } - { board: BallValve_Hotfire, project: Hotfire_Code/BallValve_Hotfire, pio_env: esp32s3 } + - { board: Bluetooth_Switch, project: Bluetooth_Switch, pio_env: bluetooth_switch } steps: - name: Checkout repository @@ -184,6 +185,33 @@ jobs: path: firmware/${{ matrix.project }}/.pio/build/${{ matrix.pio_env }}/firmware.bin retention-days: 30 + # The switch's image is built, gated and registered by build-firmware above; + # these are its host-side checks. + bluetooth-switch-host: + name: Bluetooth switch (LED pattern + web client, host only) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: LED pattern test + run: | + c++ -std=c++17 -Wall -Wextra -Werror -Iinclude test/host/led_pattern_test.cpp -o /tmp/led_pattern_test + /tmp/led_pattern_test + working-directory: firmware/Bluetooth_Switch + + # Decodes bytes packed by the firmware's StatusPacket and checks the UUIDs, + # STATUS_VERSION and registry board name match, so the page and the + # firmware can't drift apart. + - name: Web client protocol test + run: node web/test/protocol.test.mjs + working-directory: firmware/Bluetooth_Switch + run-tests: name: Run Unit Tests runs-on: windows-latest diff --git a/firmware/Bluetooth_Switch/README.md b/firmware/Bluetooth_Switch/README.md new file mode 100644 index 00000000..74efb439 --- /dev/null +++ b/firmware/Bluetooth_Switch/README.md @@ -0,0 +1,125 @@ +# Bluetooth Switch + +Latched high-side power switch for commercial altimeters, on the WifiSwitch +board (ESP32-C3FH4, TPS22810 switch, INA260 on the supply input). Controlled +and monitored over BLE; the "are you sure" confirmation lives in the web client. + +```bash +pio run # build +pio run -t upload # flash over the USB-C port +pio device monitor # console: arm | disarm | status +``` + +## Output policy + +- **Latched.** ARMED (output on) stays armed until an explicit DISARM. Nothing times it out. +- **A BLE disconnect changes nothing.** Losing the phone at the pad keeps the altimeters powered. +- **MCU reset keeps the output.** The EN pad is held with `gpio_hold`, and the state is + re-applied from RTC memory after a watchdog, panic or brownout reset (`FLAG_RESTORED`). +- **Power cycle comes up DISARMED.** + +## BLE + +Advertises as `STAR-Switch-XXXX` (last two bytes of the BT MAC), every 1 s. +Open link: no pairing or encryption, anyone in range can connect and switch it. +Up to 4 phones can be connected at once (`CONFIG_BT_NIMBLE_MAX_CONNECTIONS`): the +switch keeps advertising while a slot is free, every connected phone gets the +notifications, and the last write wins. + +| | UUID | Access | +|---|---|---| +| Service | `53544152-0001-4b8a-9d3e-7a1c2f6b0e10` | | +| Output | `53544152-0002-4b8a-9d3e-7a1c2f6b0e10` | read, write, notify. `0x01` = ARM (output on), `0x00` = DISARM (output off) | +| Status | `53544152-0003-4b8a-9d3e-7a1c2f6b0e10` | read, notify (every 2 s while connected, and after every switch) | +| Firmware | `53544152-0004-4b8a-9d3e-7a1c2f6b0e10` | read. 32 bytes: the SHA-256 ESP-IDF appended to the running image | + +Status is 17 bytes, little-endian (`include/status.h`): + +| Offset | Type | Field | +|---|---|---| +| 0 | u8 | version (1) | +| 1 | u8 | output, 0/1 | +| 2 | u8 | sense: 0 unknown, 1 load present, 2 open, 3 live | +| 3 | u8 | flags: bit0 INA260 ok, bit1 reading valid, bit2 restored after reset | +| 4 | u16 | supply voltage, mV (J1-3) | +| 6 | i16 | supply current, mA (load + board draw when not on USB) | +| 8 | u16 | power, mW | +| 10 | u16 | raw sense ADC, mV | +| 12 | u32 | uptime, s | +| 16 | u8 | reset reason (`esp_reset_reason_t`) | + +**Sense** reads VOUT through the continuity divider. With the output off, CONT_EN +pulls VOUT up through 1 MΩ, so a connected altimeter reads "load present" and nothing +connected reads "open". With the output on it should read "live". If it reads anything +else, the switch or the supply has failed. The thresholds in `main.cpp` still need +bench calibration. + +## Firmware hash + +The board reports the SHA-256 that ESP-IDF appended to its image (`firmware_hash.h`, +shared with the hotfire boards): on the Firmware characteristic, at boot on the +console, and with `status`. The build runs `firmware/tools/reproducible_build.py`, so +the same commit hashes the same on every machine. Firmware CI builds it in the same +matrix as the hotfire boards, and every push to `main` records the image on the +`firmware-registry` branch under `Bluetooth_Switch/.json`. The web page looks +the hash up there and shows the commit. To do it by hand: + +```bash +firmware/tools/firmware_digest.py .pio/build/bluetooth_switch/firmware.bin # what a board running this build reports +firmware/tools/which_firmware.py --registry # which commit built it +``` + +A hash that isn't in the registry is a local or PR build. + +## Status LED (SK6805, GPIO5) + +| State | LED | +|---|---| +| Disarmed, altimeter detected | dim green blip every 3 s | +| Disarmed, nothing connected | dim amber blip every 3 s | +| **Armed**, output live | red blink, 1 Hz | +| Armed, restored after an MCU reset | red blink with an amber blip between, until the next arm/disarm | +| **Fault**: armed but VOUT not live, or disarmed but live | red/amber alternating, 5 Hz | +| INA260 missing | amber double blip every 3 s, on top of the above | +| Phone connects | one blue blip | +| Arm / disarm command | one bright flash, red for armed, green for disarmed | + +The pattern is a pure function in `include/led_pattern.h`, tested on the host: + +```bash +c++ -std=c++17 -Iinclude test/host/led_pattern_test.cpp -o /tmp/led && /tmp/led +``` + +The SK6805 draws roughly 1 mA even when dark. That's comparable to the other +power savings below, so it's the first thing to cut if a pad sit runs the battery down. + +## Web client (`web/`) + +A static page with no build step: `index.html` plus `protocol.js` (the UUIDs and the +status decoder). It uses Web Bluetooth, so: + +- **Android / laptop:** Chrome or Edge. +- **iPhone / iPad:** Safari has no Web Bluetooth. Install + [Bluefy](https://apps.apple.com/app/bluefy-web-ble-browser/id1492822055) and open the page in it. +- It must be served over **HTTPS** (or `localhost`). Any static host works. + +```bash +cd web && python3 -m http.server 8000 # then open http://localhost:8000 on this machine +node web/test/protocol.test.mjs # decoder vs the C StatusPacket (needs c++) +``` + +Several switches can be connected on one page, each on its own card. Arm and Disarm +both ask for confirmation. If the link drops, the page reconnects by itself. The card +shows the last known state and marks it stale; the output is not touched. The +card cross-checks the output against the sense line ("ARMED but sense reads +open" means the switch or the supply has failed). + +When the status layout changes, bump `STATUS_VERSION` in `include/status.h` *and* +`web/protocol.js`. The page refuses a version it doesn't know, and the test fails +until both match. + +## Not done yet + +- Light sleep. The prebuilt Arduino core doesn't enable power management or the + 32 kHz crystal clock source. The current savings are an 80 MHz CPU, 1 s + advertising, a 300–400 ms connection interval and the INA260 in triggered mode. diff --git a/firmware/Bluetooth_Switch/include/ble_link.h b/firmware/Bluetooth_Switch/include/ble_link.h new file mode 100644 index 00000000..a89019ec --- /dev/null +++ b/firmware/Bluetooth_Switch/include/ble_link.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include "status.h" + +// BLE peripheral: one Switch service with an Output characteristic +// (read/write/notify) and a Status characteristic (read/notify). Open: no +// pairing or encryption, any central in range can connect. Up to +// CONFIG_BT_NIMBLE_MAX_CONNECTIONS centrals at once; notifications go to all. +namespace ble_link { + +using OutputRequestFn = void (*)(bool on); + +void begin(const char *name, OutputRequestFn on_output_request); +bool connected(); +uint32_t connectCount(); // connections accepted since boot +void publishFirmwareHash(const uint8_t *sha256); // 32 bytes, read-only characteristic +void publishOutput(bool on); +void publishStatus(const StatusPacket &pkt); + +} // namespace ble_link diff --git a/firmware/Bluetooth_Switch/include/ina260.h b/firmware/Bluetooth_Switch/include/ina260.h new file mode 100644 index 00000000..1fb0ba70 --- /dev/null +++ b/firmware/Bluetooth_Switch/include/ina260.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +// TI INA260 on the supply input (J1-3 -> shunt -> +9V). Measures the supply +// voltage and total current: switched load plus the board's own draw when not +// on USB. Run in triggered mode so it sits idle between readings. +class INA260 { +public: + struct Reading { + uint16_t bus_mV; + int32_t current_mA; + uint32_t power_mW; + }; + + bool begin(TwoWire &wire, uint8_t addr); + bool read(Reading &out); // one triggered conversion, ~40 ms + void shutdown(); + +private: + bool write16(uint8_t reg, uint16_t val); + bool read16(uint8_t reg, uint16_t &val); + + TwoWire *wire_ = nullptr; + uint8_t addr_ = 0; +}; diff --git a/firmware/Bluetooth_Switch/include/led_pattern.h b/firmware/Bluetooth_Switch/include/led_pattern.h new file mode 100644 index 00000000..53503061 --- /dev/null +++ b/firmware/Bluetooth_Switch/include/led_pattern.h @@ -0,0 +1,83 @@ +#pragma once + +// What the SK6805 shows, as a pure function of the switch's state and time, so +// the pattern can be tested on the host (test/led_pattern_test.cpp). +// +// disarmed, altimeter seen dim green blip every 3 s +// disarmed, nothing connected dim amber blip every 3 s +// ARMED, output live red blink, 1 Hz +// ARMED, restored after reset red blink with an amber blip between +// FAULT red/amber alternating, 5 Hz +// (armed but VOUT not live, or disarmed but live) +// INA260 missing amber double blip once per 3 s, on top of the above +// phone connected one blue blip +// arm / disarm command one bright flash in the new state's colour + +#include + +#include "status.h" + +namespace led { + +struct Rgb { + uint8_t r, g, b; + bool operator==(const Rgb &o) const { return r == o.r && g == o.g && b == o.b; } +}; + +constexpr Rgb OFF{0, 0, 0}; +constexpr Rgb DIM_GREEN{0, 10, 0}; +constexpr Rgb DIM_AMBER{10, 5, 0}; +constexpr Rgb RED{60, 0, 0}; +constexpr Rgb AMBER{50, 22, 0}; +constexpr Rgb BLUE{0, 0, 40}; +constexpr Rgb FLASH_RED{160, 0, 0}; +constexpr Rgb FLASH_GREEN{0, 140, 0}; + +constexpr uint32_t EVENT_MS = 250; // connect blip and command flash + +enum class Mode : uint8_t { DisarmedLoad, DisarmedOpen, DisarmedUnknown, Armed, Fault }; + +struct Inputs { + bool armed; + Sense sense; // Unknown while the sense line settles after a switch + bool restored; // armed state re-applied after an MCU reset + bool inaOk; + uint32_t sinceCommandMs; // since the last arm/disarm; large if none + uint32_t sinceConnectMs; // since a phone last connected; large if none +}; + +inline Mode mode(const Inputs &in) { + if (in.sense == Sense::Unknown) return in.armed ? Mode::Armed : Mode::DisarmedUnknown; + if (in.armed) return in.sense == Sense::Live ? Mode::Armed : Mode::Fault; + if (in.sense == Sense::Live) return Mode::Fault; + return in.sense == Sense::LoadPresent ? Mode::DisarmedLoad : Mode::DisarmedOpen; +} + +inline Rgb color(const Inputs &in, uint32_t nowMs) { + if (in.sinceCommandMs < EVENT_MS) return in.armed ? FLASH_RED : FLASH_GREEN; + if (in.sinceConnectMs < EVENT_MS) return BLUE; + + const uint32_t t1 = nowMs % 1000; + const uint32_t t3 = nowMs % 3000; + const Mode m = mode(in); + + if (m == Mode::Fault) return (nowMs / 100) % 2 ? AMBER : RED; + + // INA260 missing: two amber blips in the off half of the armed blink. + if (!in.inaOk && ((t3 >= 2550 && t3 < 2610) || (t3 >= 2850 && t3 < 2910))) return AMBER; + + switch (m) { + case Mode::Armed: + if (t1 < 500) return RED; + if (in.restored && t1 >= 700 && t1 < 800) return AMBER; + return OFF; + case Mode::DisarmedLoad: + return t3 < 60 ? DIM_GREEN : OFF; + case Mode::DisarmedOpen: + return t3 < 60 ? DIM_AMBER : OFF; + default: + return OFF; // settling: say nothing rather than guess + } +} + +} // namespace led diff --git a/firmware/Bluetooth_Switch/include/pins.h b/firmware/Bluetooth_Switch/include/pins.h new file mode 100644 index 00000000..13b728b5 --- /dev/null +++ b/firmware/Bluetooth_Switch/include/pins.h @@ -0,0 +1,12 @@ +#pragma once + +// WifiSwitch.SchDoc (ESP32-C3FH4) +constexpr int PIN_OUT_EN = 4; // MTMS -> TPS22810 EN/UVLO, high = VOUT live +constexpr int PIN_LED = 5; // SK6805 DIN (include/led_pattern.h) +constexpr int PIN_I2C_SDA = 6; // INA260 SDA (MTCK) +constexpr int PIN_I2C_SCL = 7; // INA260 SCL (MTDO) +constexpr int PIN_BUTTON = 9; // SW2, 10k pull-up, active low (also boot strap) +constexpr int PIN_CONT_EN = 10; // continuity drive: D1 + 1M onto VOUT +constexpr int PIN_CONT_ADC = 3; // continuity sense: VOUT -> D2 -> 1M/1M divider, 2.5V zener clamp + +constexpr uint8_t INA260_ADDR = 0x40; // A0 = A1 = GND diff --git a/firmware/Bluetooth_Switch/include/status.h b/firmware/Bluetooth_Switch/include/status.h new file mode 100644 index 00000000..ab3ad7d9 --- /dev/null +++ b/firmware/Bluetooth_Switch/include/status.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +// What the continuity/sense divider on VOUT says. +enum class Sense : uint8_t { + Unknown = 0, + LoadPresent = 1, // output off, something low-impedance across VOUT-GND + Open = 2, // output off, nothing connected + Live = 3, // VOUT is at supply (output on, or back-fed) +}; + +enum StatusFlags : uint8_t { + FLAG_INA_OK = 1 << 0, // INA260 answered with the right IDs at boot + FLAG_READING_VALID = 1 << 1, // supply/current fields are from the last conversion + FLAG_RESTORED = 1 << 2, // output was re-applied after a non-power-on reset +}; + +// Status characteristic payload, little-endian, 17 bytes (fits the default +// 20-byte ATT payload). Bump STATUS_VERSION whenever the layout changes. +constexpr uint8_t STATUS_VERSION = 1; + +struct __attribute__((packed)) StatusPacket { + uint8_t version; + uint8_t output; // 0 = off, 1 = on + uint8_t sense; // Sense + uint8_t flags; // StatusFlags + uint16_t supply_mV; // INA260 bus voltage (J1-3) + int16_t current_mA; // INA260 current from the supply input + uint16_t power_mW; + uint16_t sense_mV; // raw CONT_ADC reading + uint32_t uptime_s; + uint8_t reset_reason; // esp_reset_reason_t of the last boot +}; +static_assert(sizeof(StatusPacket) == 17, "status packet layout changed"); diff --git a/firmware/Bluetooth_Switch/platformio.ini b/firmware/Bluetooth_Switch/platformio.ini new file mode 100644 index 00000000..d5f4891d --- /dev/null +++ b/firmware/Bluetooth_Switch/platformio.ini @@ -0,0 +1,20 @@ +[platformio] +extra_configs = ../platformio-common.ini + +[env:bluetooth_switch] +extends = esp32c3_usb_base +board_build.f_cpu = 80000000L +; Keeps the image free of absolute build paths, so the same commit hashes the +; same on every machine and CI's firmware registry can name it. +extra_scripts = pre:../tools/reproducible_build.py +lib_deps = + ; 1.4.x matches arduino-esp32 2.x (espressif32@7.1.3); 2.x changed the callback API. + h2zero/NimBLE-Arduino@1.4.3 +build_flags = + ${esp32c3_usb_base.build_flags} + -Iinclude + ; Peripheral role only; drops the central/observer code and its RAM. + -DCONFIG_BT_NIMBLE_ROLE_CENTRAL_DISABLED + -DCONFIG_BT_NIMBLE_ROLE_OBSERVER_DISABLED + ; Several phones at once (pad crew + range safety). Each link costs ~1 KB of RAM. + -DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=4 diff --git a/firmware/Bluetooth_Switch/src/ble_link.cpp b/firmware/Bluetooth_Switch/src/ble_link.cpp new file mode 100644 index 00000000..462396c1 --- /dev/null +++ b/firmware/Bluetooth_Switch/src/ble_link.cpp @@ -0,0 +1,104 @@ +#include "ble_link.h" + +#include + +namespace ble_link { +namespace { + +// Base UUID 5354_4152-xxxx ("STAR"); website must use the same values. +const char *SERVICE_UUID = "53544152-0001-4b8a-9d3e-7a1c2f6b0e10"; +const char *OUTPUT_UUID = "53544152-0002-4b8a-9d3e-7a1c2f6b0e10"; +const char *STATUS_UUID = "53544152-0003-4b8a-9d3e-7a1c2f6b0e10"; +const char *FIRMWARE_UUID = "53544152-0004-4b8a-9d3e-7a1c2f6b0e10"; + +// Advertising every 1 s while idle; connection interval 300-400 ms with a 6 s +// supervision timeout. Units: adv 0.625 ms, conn interval 1.25 ms, timeout 10 ms. +constexpr uint16_t ADV_INTERVAL = 1600; +constexpr uint16_t CONN_INTERVAL_MIN = 240; +constexpr uint16_t CONN_INTERVAL_MAX = 320; +constexpr uint16_t CONN_LATENCY = 0; +constexpr uint16_t CONN_TIMEOUT = 600; + +NimBLECharacteristic *outputChr = nullptr; +NimBLECharacteristic *statusChr = nullptr; +NimBLECharacteristic *firmwareChr = nullptr; +volatile uint32_t connects = 0; +OutputRequestFn outputRequest = nullptr; + +NimBLEServer *server = nullptr; + +// Several centrals may be connected at once; every one gets the notifications +// and any one of them can switch the output (last write wins). +class ServerCallbacks : public NimBLEServerCallbacks { + void onConnect(NimBLEServer *s, ble_gap_conn_desc *desc) override { + connects = connects + 1; + s->updateConnParams(desc->conn_handle, CONN_INTERVAL_MIN, CONN_INTERVAL_MAX, + CONN_LATENCY, CONN_TIMEOUT); + // A connection ends connectable advertising; keep advertising while + // there is a free slot so the next phone can still find the switch. + if (s->getConnectedCount() < CONFIG_BT_NIMBLE_MAX_CONNECTIONS) { + NimBLEDevice::startAdvertising(); + } + } + + // Disconnecting never touches the output: losing a phone at the pad must + // not power down the altimeters. Advertising restarts by itself + // (advertiseOnDisconnect). + void onDisconnect(NimBLEServer *, ble_gap_conn_desc *) override {} +}; + +class OutputCallbacks : public NimBLECharacteristicCallbacks { + void onWrite(NimBLECharacteristic *chr) override { + NimBLEAttValue v = chr->getValue(); + if (v.length() != 1 || v[0] > 1) return; + if (outputRequest) outputRequest(v[0] == 1); + } +}; + +} // namespace + +void begin(const char *name, OutputRequestFn on_output_request) { + outputRequest = on_output_request; + + NimBLEDevice::init(name); + NimBLEDevice::setPower(ESP_PWR_LVL_P9); + + server = NimBLEDevice::createServer(); + server->setCallbacks(new ServerCallbacks()); + server->advertiseOnDisconnect(true); + + NimBLEService *svc = server->createService(SERVICE_UUID); + outputChr = svc->createCharacteristic( + OUTPUT_UUID, NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::NOTIFY); + outputChr->setCallbacks(new OutputCallbacks()); + statusChr = svc->createCharacteristic(STATUS_UUID, + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); + firmwareChr = svc->createCharacteristic(FIRMWARE_UUID, NIMBLE_PROPERTY::READ); + svc->start(); + + NimBLEAdvertising *adv = NimBLEDevice::getAdvertising(); + adv->addServiceUUID(SERVICE_UUID); + adv->setScanResponse(true); + adv->setMinInterval(ADV_INTERVAL); + adv->setMaxInterval(ADV_INTERVAL); + adv->start(); +} + +uint32_t connectCount() { return connects; } + +void publishFirmwareHash(const uint8_t *sha256) { firmwareChr->setValue(sha256, 32); } + +bool connected() { return server && server->getConnectedCount() > 0; } + +void publishOutput(bool on) { + uint8_t v = on ? 1 : 0; + outputChr->setValue(&v, 1); + if (connected()) outputChr->notify(); +} + +void publishStatus(const StatusPacket &pkt) { + statusChr->setValue(reinterpret_cast(&pkt), sizeof(pkt)); + if (connected()) statusChr->notify(); +} + +} // namespace ble_link diff --git a/firmware/Bluetooth_Switch/src/ina260.cpp b/firmware/Bluetooth_Switch/src/ina260.cpp new file mode 100644 index 00000000..42393d7c --- /dev/null +++ b/firmware/Bluetooth_Switch/src/ina260.cpp @@ -0,0 +1,78 @@ +#include "ina260.h" + +namespace { +constexpr uint8_t REG_CONFIG = 0x00; +constexpr uint8_t REG_CURRENT = 0x01; +constexpr uint8_t REG_BUS_V = 0x02; +constexpr uint8_t REG_MASK_EN = 0x06; +constexpr uint8_t REG_MFR_ID = 0xFE; +constexpr uint8_t REG_DIE_ID = 0xFF; + +constexpr uint16_t MFR_ID_TI = 0x5449; +constexpr uint16_t DIE_ID_MASK = 0xFFF0; +constexpr uint16_t DIE_ID_INA260 = 0x2270; + +// AVG = 16, VBUSCT = ISHCT = 1.1 ms -> ~35 ms per triggered pair. +constexpr uint16_t CFG_AVG16 = 0b010 << 9; +constexpr uint16_t CFG_VBUSCT_1100US = 0b100 << 6; +constexpr uint16_t CFG_ISHCT_1100US = 0b100 << 3; +constexpr uint16_t MODE_SHUTDOWN = 0b000; +constexpr uint16_t MODE_TRIG_BOTH = 0b011; +constexpr uint16_t CFG_BASE = CFG_AVG16 | CFG_VBUSCT_1100US | CFG_ISHCT_1100US; + +constexpr uint16_t MASK_CVRF = 1 << 3; // conversion ready +constexpr uint32_t CONVERSION_TIMEOUT_MS = 100; +} // namespace + +bool INA260::begin(TwoWire &wire, uint8_t addr) { + wire_ = &wire; + addr_ = addr; + uint16_t mfr = 0, die = 0; + if (!read16(REG_MFR_ID, mfr) || !read16(REG_DIE_ID, die)) return false; + if (mfr != MFR_ID_TI || (die & DIE_ID_MASK) != DIE_ID_INA260) return false; + shutdown(); + return true; +} + +bool INA260::read(Reading &out) { + if (!write16(REG_CONFIG, CFG_BASE | MODE_TRIG_BOTH)) return false; + + // Reading Mask/Enable clears CVRF, so poll it until the pair completes. + uint32_t start = millis(); + uint16_t mask = 0; + do { + delay(5); + if (!read16(REG_MASK_EN, mask)) return false; + } while (!(mask & MASK_CVRF) && millis() - start < CONVERSION_TIMEOUT_MS); + if (!(mask & MASK_CVRF)) return false; + + uint16_t raw_i = 0, raw_v = 0; + if (!read16(REG_CURRENT, raw_i) || !read16(REG_BUS_V, raw_v)) return false; + + // 1.25 mA/LSB signed, 1.25 mV/LSB + out.current_mA = (static_cast(raw_i) * 5) / 4; + out.bus_mV = static_cast((static_cast(raw_v) * 5) / 4); + int32_t mag = out.current_mA < 0 ? -out.current_mA : out.current_mA; + out.power_mW = (static_cast(mag) * out.bus_mV) / 1000; + return true; +} + +void INA260::shutdown() { write16(REG_CONFIG, CFG_BASE | MODE_SHUTDOWN); } + +bool INA260::write16(uint8_t reg, uint16_t val) { + wire_->beginTransmission(addr_); + wire_->write(reg); + wire_->write(static_cast(val >> 8)); + wire_->write(static_cast(val & 0xFF)); + return wire_->endTransmission() == 0; +} + +bool INA260::read16(uint8_t reg, uint16_t &val) { + wire_->beginTransmission(addr_); + wire_->write(reg); + if (wire_->endTransmission(false) != 0) return false; + if (wire_->requestFrom(addr_, static_cast(2)) != 2) return false; + val = static_cast(wire_->read()) << 8; + val |= static_cast(wire_->read()); + return true; +} diff --git a/firmware/Bluetooth_Switch/src/main.cpp b/firmware/Bluetooth_Switch/src/main.cpp new file mode 100644 index 00000000..5ce1c032 --- /dev/null +++ b/firmware/Bluetooth_Switch/src/main.cpp @@ -0,0 +1,239 @@ +// Bluetooth switch: latched high-side power switch for commercial altimeters +// (WifiSwitch board, ESP32-C3). Controlled and monitored over BLE only; the +// "are you sure" confirmation lives in the web client. +// +// Output policy: +// - Latched: once on, it stays on until an explicit OFF (disable) command. +// - A BLE disconnect never changes the output. +// - Across a non-power-on reset (watchdog, panic, brownout) the pad stays +// held by gpio_hold and the state is re-applied from RTC memory, so an MCU +// reset in flight does not drop altimeter power. +// - A full power cycle comes up OFF. + +#include +#include +#include +#include +#include + +#include "../../Hotfire_Code/common/firmware_hash.h" +#include "ble_link.h" +#include "ina260.h" +#include "led_pattern.h" +#include "pins.h" +#include "status.h" + +namespace { + +// Sense thresholds on the CONT_ADC node (half of VOUT after D2). Open reads +// ~0.9 V with CONT_EN driving; the zener clamps a live output near 2.5 V. +// Bench-calibrate these against a real altimeter. +constexpr uint32_t SENSE_LOAD_MAX_MV = 150; +constexpr uint32_t SENSE_LIVE_MIN_MV = 1800; + +constexpr uint32_t STATUS_PERIOD_CONNECTED_MS = 2000; +constexpr uint32_t STATUS_PERIOD_IDLE_MS = 30000; +constexpr uint32_t SENSE_SETTLE_MS = 500; // 1M into C5 + C23, tau ~100 ms + +constexpr uint32_t LOOP_PERIOD_MS = 20; +constexpr uint32_t LED_SENSE_PERIOD_MS = 250; // how often the LED re-reads the sense line + +constexpr uint32_t RTC_MAGIC_ON = 0x53574F4E; // "SWON" +constexpr uint32_t RTC_MAGIC_OFF = 0x53574F46; // "SWOF" +RTC_NOINIT_ATTR uint32_t rtcOutputState; + +INA260 ina; +bool inaOk = false; +bool outputOn = false; +bool restoredAfterReset = false; +esp_reset_reason_t resetReason = ESP_RST_UNKNOWN; + +// Written from the NimBLE host task, applied in loop(). +volatile int8_t pendingOutput = -1; + +uint32_t lastStatusMs = 0; +uint32_t senseValidAtMs = 0; + +// LED state. lastCommandMs is 0 until the first arm/disarm of this boot. +uint32_t lastCommandMs = 0; +uint32_t lastConnectMs = 0; +uint32_t seenConnects = 0; +uint32_t lastLedSenseMs = 0; +Sense ledSense = Sense::Unknown; +led::Rgb ledShown{1, 1, 1}; // not a real colour, so the first write always lands + +void driveOutputPad(bool on) { + gpio_hold_dis(static_cast(PIN_OUT_EN)); + gpio_set_level(static_cast(PIN_OUT_EN), on ? 1 : 0); + gpio_set_direction(static_cast(PIN_OUT_EN), GPIO_MODE_OUTPUT); + gpio_hold_en(static_cast(PIN_OUT_EN)); +} + +void setOutput(bool on) { + rtcOutputState = on ? RTC_MAGIC_ON : RTC_MAGIC_OFF; + driveOutputPad(on); + // Continuity drive only while off; D1 blocks the supply from back-feeding. + digitalWrite(PIN_CONT_EN, on ? LOW : HIGH); + outputOn = on; + restoredAfterReset = false; + senseValidAtMs = millis() + SENSE_SETTLE_MS; + lastStatusMs = 0; // force a status update once the sense line settles + lastCommandMs = millis() ? millis() : 1; + ledSense = Sense::Unknown; + Serial.printf("[out] %s\n", on ? "ARMED" : "DISARMED"); + ble_link::publishOutput(on); +} + +// Runs first thing in setup(), before anything else can stall. +void restoreOutput() { + resetReason = esp_reset_reason(); + bool fromPowerOn = resetReason == ESP_RST_POWERON || resetReason == ESP_RST_UNKNOWN; + bool on = !fromPowerOn && rtcOutputState == RTC_MAGIC_ON; + if (fromPowerOn) rtcOutputState = RTC_MAGIC_OFF; + restoredAfterReset = on; + outputOn = on; + driveOutputPad(on); +} + +// Armed and not live reads as load/open, not Unknown: that is the fault the web +// client and the LED flag. Unknown means only "still settling". +Sense classifySense(uint32_t mv) { + if (mv >= SENSE_LIVE_MIN_MV) return Sense::Live; + return mv <= SENSE_LOAD_MAX_MV ? Sense::LoadPresent : Sense::Open; +} + +Sense readSense(uint32_t mv) { + return static_cast(millis() - senseValidAtMs) >= 0 ? classifySense(mv) : Sense::Unknown; +} + +uint32_t since(uint32_t thenMs, uint32_t nowMs) { return thenMs ? nowMs - thenMs : UINT32_MAX; } + +void updateLed(uint32_t now) { + uint32_t connects = ble_link::connectCount(); + if (connects != seenConnects) { + seenConnects = connects; + lastConnectMs = now ? now : 1; + } + if (now - lastLedSenseMs >= LED_SENSE_PERIOD_MS) { + lastLedSenseMs = now; + ledSense = readSense(analogReadMilliVolts(PIN_CONT_ADC)); + } + led::Inputs in{outputOn, ledSense, restoredAfterReset, inaOk, + since(lastCommandMs, now), since(lastConnectMs, now)}; + led::Rgb c = led::color(in, now); + if (!(c == ledShown)) { + neopixelWrite(PIN_LED, c.r, c.g, c.b); // SK6805 is GRB; neopixelWrite reorders + ledShown = c; + } +} + +StatusPacket buildStatus() { + StatusPacket pkt = {}; + pkt.version = STATUS_VERSION; + pkt.output = outputOn ? 1 : 0; + pkt.uptime_s = millis() / 1000; + pkt.reset_reason = static_cast(resetReason); + if (inaOk) pkt.flags |= FLAG_INA_OK; + if (restoredAfterReset) pkt.flags |= FLAG_RESTORED; + + INA260::Reading r; + if (inaOk && ina.read(r)) { + pkt.flags |= FLAG_READING_VALID; + pkt.supply_mV = r.bus_mV; + pkt.current_mA = static_cast(constrain(r.current_mA, INT16_MIN, INT16_MAX)); + pkt.power_mW = static_cast(min(r.power_mW, UINT16_MAX)); + } + + uint32_t mv = analogReadMilliVolts(PIN_CONT_ADC); + pkt.sense_mV = static_cast(mv); + pkt.sense = static_cast(readSense(mv)); + return pkt; +} + +void printStatus(const StatusPacket &p) { + static const char *SENSE_NAMES[] = {"unknown", "load", "open", "live"}; + Serial.printf("[status] out=%s sense=%s (%u mV) supply=%u mV I=%d mA P=%u mW%s%s\n", + p.output ? "ARMED" : "DISARMED", SENSE_NAMES[p.sense < 4 ? p.sense : 0], p.sense_mV, + p.supply_mV, p.current_mA, p.power_mW, + (p.flags & FLAG_INA_OK) ? "" : " [INA260 missing]", + (p.flags & FLAG_RESTORED) ? " [restored after reset]" : ""); +} + +void onOutputRequest(bool on) { pendingOutput = on ? 1 : 0; } + +// Bench console over USB CDC: arm | disarm | status (on/off still accepted) +void handleSerial() { + static String line; + while (Serial.available()) { + char c = static_cast(Serial.read()); + if (c != '\n' && c != '\r') { + if (line.length() < 32) line += c; + continue; + } + line.trim(); + if (line == "arm" || line == "on") { + setOutput(true); + } else if (line == "disarm" || line == "off") { + setOutput(false); + } else if (line == "status") { + printStatus(buildStatus()); + FirmwareHash::print(); + } else if (line.length()) { + Serial.println("commands: arm | disarm | status"); + } + line = ""; + } +} + +} // namespace + +void setup() { + restoreOutput(); + + pinMode(PIN_CONT_EN, OUTPUT); + digitalWrite(PIN_CONT_EN, outputOn ? LOW : HIGH); + analogSetPinAttenuation(PIN_CONT_ADC, ADC_11db); + senseValidAtMs = millis() + SENSE_SETTLE_MS; + + Serial.begin(115200); + Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL, 100000); + inaOk = ina.begin(Wire, INA260_ADDR); + + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_BT); + char name[24]; + snprintf(name, sizeof(name), "STAR-Switch-%02X%02X", mac[4], mac[5]); + ble_link::begin(name, onOutputRequest); + ble_link::publishOutput(outputOn); + // SHA-256 ESP-IDF appended to this image: what CI's firmware-registry is keyed on. + ble_link::publishFirmwareHash(FirmwareHash::get()); + + Serial.printf("[boot] %s reset=%d output=%s ina260=%s\n", name, + static_cast(resetReason), outputOn ? "ARMED (restored)" : "DISARMED", + inaOk ? "ok" : "MISSING"); + FirmwareHash::print(); +} + +void loop() { + int8_t req = pendingOutput; + if (req >= 0) { + pendingOutput = -1; + if ((req == 1) != outputOn) setOutput(req == 1); + else ble_link::publishOutput(outputOn); + } + + handleSerial(); + updateLed(millis()); + + uint32_t period = ble_link::connected() ? STATUS_PERIOD_CONNECTED_MS : STATUS_PERIOD_IDLE_MS; + uint32_t now = millis(); + bool settledUpdateDue = lastStatusMs == 0 && static_cast(now - senseValidAtMs) >= 0; + if (settledUpdateDue || now - lastStatusMs >= period) { + lastStatusMs = now ? now : 1; + StatusPacket pkt = buildStatus(); + ble_link::publishStatus(pkt); + if (ble_link::connected()) printStatus(pkt); + } + + delay(LOOP_PERIOD_MS); +} diff --git a/firmware/Bluetooth_Switch/test/host/led_pattern_test.cpp b/firmware/Bluetooth_Switch/test/host/led_pattern_test.cpp new file mode 100644 index 00000000..62557701 --- /dev/null +++ b/firmware/Bluetooth_Switch/test/host/led_pattern_test.cpp @@ -0,0 +1,93 @@ +// Host test for include/led_pattern.h. Plain C++, no framework: +// c++ -std=c++17 -Iinclude test/host/led_pattern_test.cpp -o /tmp/led && /tmp/led +#include +#include + +#include "led_pattern.h" + +using namespace led; + +static int failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \ + failures++; \ + } \ + } while (0) + +constexpr uint32_t LONG_AGO = 0xFFFFFFFF; + +static Inputs base(bool armed, Sense s) { return {armed, s, false, true, LONG_AGO, LONG_AGO}; } + +// How many ms in [from, to) the LED shows `c`. +static int msOf(const Inputs &in, Rgb c, uint32_t from, uint32_t to) { + int n = 0; + for (uint32_t t = from; t < to; t++) n += color(in, t) == c; + return n; +} + +int main() { + // Modes. + CHECK(mode(base(true, Sense::Live)) == Mode::Armed); + CHECK(mode(base(true, Sense::Open)) == Mode::Fault); + CHECK(mode(base(true, Sense::LoadPresent)) == Mode::Fault); + CHECK(mode(base(false, Sense::Live)) == Mode::Fault); + CHECK(mode(base(false, Sense::LoadPresent)) == Mode::DisarmedLoad); + CHECK(mode(base(false, Sense::Open)) == Mode::DisarmedOpen); + CHECK(mode(base(true, Sense::Unknown)) == Mode::Armed); // settling: no false fault + + // Armed: red half of every second, nothing else. + auto armed = base(true, Sense::Live); + CHECK(msOf(armed, RED, 0, 3000) == 1500); + CHECK(msOf(armed, OFF, 0, 3000) == 1500); + + // Restored: the same blink plus 100 ms amber per second. + auto restored = armed; + restored.restored = true; + CHECK(msOf(restored, RED, 0, 3000) == 1500); + CHECK(msOf(restored, AMBER, 0, 3000) == 300); + + // Disarmed: one 60 ms dim blip per 3 s, green with a load, amber without. + CHECK(msOf(base(false, Sense::LoadPresent), DIM_GREEN, 0, 6000) == 120); + CHECK(msOf(base(false, Sense::LoadPresent), OFF, 0, 6000) == 6000 - 120); + CHECK(msOf(base(false, Sense::Open), DIM_AMBER, 0, 6000) == 120); + CHECK(msOf(base(false, Sense::Unknown), OFF, 0, 3000) == 3000); + + // Fault: never dark, both colours, switching every 100 ms. + auto fault = base(true, Sense::Open); + CHECK(msOf(fault, OFF, 0, 3000) == 0); + CHECK(msOf(fault, RED, 0, 1000) == 500); + CHECK(msOf(fault, AMBER, 0, 1000) == 500); + + // INA260 missing: two 60 ms amber blips per 3 s, and they don't cut into the red. + auto noIna = armed; + noIna.inaOk = false; + CHECK(msOf(noIna, AMBER, 0, 3000) == 120); + CHECK(msOf(noIna, RED, 0, 3000) == 1500); + auto noInaDisarmed = base(false, Sense::LoadPresent); + noInaDisarmed.inaOk = false; + CHECK(msOf(noInaDisarmed, AMBER, 0, 3000) == 120); + + // Events win over everything, briefly. + auto cmd = base(true, Sense::Unknown); + cmd.sinceCommandMs = 0; + CHECK(color(cmd, 600) == FLASH_RED); + cmd.armed = false; + CHECK(color(cmd, 600) == FLASH_GREEN); + cmd.sinceCommandMs = EVENT_MS; + CHECK(color(cmd, 600) == OFF); + auto conn = base(false, Sense::Open); + conn.sinceConnectMs = 10; + CHECK(color(conn, 1500) == BLUE); + auto connFault = fault; + connFault.sinceConnectMs = 10; + CHECK(color(connFault, 1500) == BLUE); + + if (failures) { + std::printf("%d failure(s)\n", failures); + return EXIT_FAILURE; + } + std::printf("led pattern ok\n"); + return 0; +} diff --git a/firmware/Bluetooth_Switch/web/index.html b/firmware/Bluetooth_Switch/web/index.html new file mode 100644 index 00000000..3e2dee7f --- /dev/null +++ b/firmware/Bluetooth_Switch/web/index.html @@ -0,0 +1,385 @@ + + + + + + +STAR Switch + + + +
+
+

STAR Switch

+ +
+ +
No switch connected. Power the board, then tap Connect switch and pick a STAR-Switch-XXXX.
+
+
+ Arming is latched: the altimeters stay powered until someone disarms. Closing this page or + losing Bluetooth does not disarm. Anyone in range can connect. +
+
+ + + + + + diff --git a/firmware/Bluetooth_Switch/web/protocol.js b/firmware/Bluetooth_Switch/web/protocol.js new file mode 100644 index 00000000..5e8e9005 --- /dev/null +++ b/firmware/Bluetooth_Switch/web/protocol.js @@ -0,0 +1,77 @@ +// BLE contract with the firmware. Keep in step with src/ble_link.cpp and +// include/status.h; test/protocol.test.mjs checks the layout against the C struct. + +export const SERVICE_UUID = "53544152-0001-4b8a-9d3e-7a1c2f6b0e10"; +export const OUTPUT_UUID = "53544152-0002-4b8a-9d3e-7a1c2f6b0e10"; +export const STATUS_UUID = "53544152-0003-4b8a-9d3e-7a1c2f6b0e10"; +export const FIRMWARE_UUID = "53544152-0004-4b8a-9d3e-7a1c2f6b0e10"; // 32-byte image SHA-256, read-only +export const NAME_PREFIX = "STAR-Switch"; + +export const STATUS_VERSION = 1; +export const STATUS_LENGTH = 17; + +export const SENSE = ["unknown", "load present", "open", "live"]; + +export const FLAG_INA_OK = 1 << 0; +export const FLAG_READING_VALID = 1 << 1; +export const FLAG_RESTORED = 1 << 2; + +// esp_reset_reason_t +const RESET_REASONS = [ + "unknown", "power-on", "external pin", "software", "panic", "interrupt watchdog", + "task watchdog", "other watchdog", "deep sleep", "brownout", "SDIO", "USB", + "JTAG", "eFuse", "power glitch", "CPU lockup", +]; +export const resetReasonName = (r) => RESET_REASONS[r] ?? `reason ${r}`; + +// CI writes a record per image to the firmware-registry branch, keyed on the +// digest the board reports (.github/workflows/firmware-ci.yml). +export const REGISTRY_BOARD = "Bluetooth_Switch"; +export const registryUrl = (digestHex) => + `https://raw.githubusercontent.com/calstar/STAR/firmware-registry/${REGISTRY_BOARD}/${digestHex}.json`; + +// DataView -> lowercase hex, the way the registry names files. +export function digestHex(dv) { + if (dv.byteLength !== 32) throw new Error(`firmware hash is ${dv.byteLength} bytes, expected 32`); + let s = ""; + for (let i = 0; i < 32; i++) s += dv.getUint8(i).toString(16).padStart(2, "0"); + return s; +} + +// DataView -> status object, or throws on a packet this page can't read. +export function decodeStatus(dv) { + if (dv.byteLength < STATUS_LENGTH) throw new Error(`status packet is ${dv.byteLength} bytes, expected ${STATUS_LENGTH}`); + const version = dv.getUint8(0); + if (version !== STATUS_VERSION) throw new Error(`status version ${version}; this page reads ${STATUS_VERSION}`); + const flags = dv.getUint8(3); + return { + version, + output: dv.getUint8(1) === 1, + sense: dv.getUint8(2), + flags, + inaOk: !!(flags & FLAG_INA_OK), + readingValid: !!(flags & FLAG_READING_VALID), + restored: !!(flags & FLAG_RESTORED), + supply_mV: dv.getUint16(4, true), + current_mA: dv.getInt16(6, true), + power_mW: dv.getUint16(8, true), + sense_mV: dv.getUint16(10, true), + uptime_s: dv.getUint32(12, true), + resetReason: dv.getUint8(16), + }; +} + +// What the sense line says about the output, given what the output should be. +// Returns {level: "ok" | "warn" | "bad", text}. +export function senseVerdict(s) { + const name = SENSE[s.sense] ?? `sense ${s.sense}`; + if (s.sense === 0) return { level: "warn", text: "Sense settling" }; + if (s.output) { + return s.sense === 3 + ? { level: "ok", text: "Armed: altimeter power live" } + : { level: "bad", text: `ARMED but sense reads ${name}: altimeters may be unpowered (switch or supply fault)` }; + } + if (s.sense === 1) return { level: "ok", text: "Altimeter connected" }; + if (s.sense === 2) return { level: "warn", text: "Nothing connected (open)" }; + return { level: "bad", text: "DISARMED but altimeter power is live: switch stuck or back-fed" }; +} diff --git a/firmware/Bluetooth_Switch/web/test/pack_status.cpp b/firmware/Bluetooth_Switch/web/test/pack_status.cpp new file mode 100644 index 00000000..2d6ee662 --- /dev/null +++ b/firmware/Bluetooth_Switch/web/test/pack_status.cpp @@ -0,0 +1,18 @@ +// Fills the firmware's StatusPacket and writes its raw bytes to stdout, so the +// page's decoder is checked against the C struct rather than against itself. +#include +#include "status.h" +int main() { + StatusPacket p{}; + p.version = STATUS_VERSION; + p.output = 1; + p.sense = static_cast(Sense::Live); + p.flags = FLAG_INA_OK | FLAG_READING_VALID | FLAG_RESTORED; + p.supply_mV = 8412; + p.current_mA = -123; + p.power_mW = 1035; + p.sense_mV = 2471; + p.uptime_s = 0x01020304; + p.reset_reason = 9; + fwrite(&p, sizeof p, 1, stdout); +} diff --git a/firmware/Bluetooth_Switch/web/test/protocol.test.mjs b/firmware/Bluetooth_Switch/web/test/protocol.test.mjs new file mode 100644 index 00000000..fe8846b1 --- /dev/null +++ b/firmware/Bluetooth_Switch/web/test/protocol.test.mjs @@ -0,0 +1,49 @@ +// node web/test/protocol.test.mjs (needs a host C++ compiler) +import { execFileSync } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import * as P from "../protocol.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const fw = join(here, "..", ".."); +const exe = join(mkdtempSync(join(tmpdir(), "sw-")), "pack"); +execFileSync("c++", ["-std=c++17", "-I", join(fw, "include"), join(here, "pack_status.cpp"), "-o", exe]); +const bytes = execFileSync(exe); +assert.equal(bytes.length, P.STATUS_LENGTH); + +const s = P.decodeStatus(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)); +assert.deepEqual(s, { + version: 1, output: true, sense: 3, flags: 7, inaOk: true, readingValid: true, restored: true, + supply_mV: 8412, current_mA: -123, power_mW: 1035, sense_mV: 2471, uptime_s: 0x01020304, resetReason: 9, +}); +assert.equal(P.resetReasonName(9), "brownout"); +assert.equal(P.senseVerdict(s).level, "ok"); +assert.equal(P.senseVerdict({ ...s, sense: 2 }).level, "bad"); +assert.equal(P.senseVerdict({ ...s, output: false, sense: 3 }).level, "bad"); +assert.equal(P.senseVerdict({ ...s, output: false, sense: 1 }).level, "ok"); + +const bad = new Uint8Array(bytes); bad[0] = 2; +assert.throws(() => P.decodeStatus(new DataView(bad.buffer)), /version 2/); +assert.throws(() => P.decodeStatus(new DataView(new ArrayBuffer(16))), /16 bytes/); + +// UUIDs must match the firmware's. +const src = readFileSync(join(fw, "src", "ble_link.cpp"), "utf8"); +for (const u of [P.SERVICE_UUID, P.OUTPUT_UUID, P.STATUS_UUID, P.FIRMWARE_UUID]) assert.ok(src.includes(`"${u}"`), u); +const status = readFileSync(join(fw, "include", "status.h"), "utf8"); +assert.match(status, new RegExp(`STATUS_VERSION = ${P.STATUS_VERSION};`)); + +// Firmware hash: raw bytes -> the registry's lowercase file name. +const h = new Uint8Array(32).map((_, i) => i * 8 + 1); +assert.equal(P.digestHex(new DataView(h.buffer)), Array.from(h, (b) => b.toString(16).padStart(2, "0")).join("")); +assert.match(P.digestHex(new DataView(h.buffer)), /^[0-9a-f]{64}$/); +assert.throws(() => P.digestHex(new DataView(new ArrayBuffer(31))), /31 bytes/); +assert.ok(P.registryUrl("ab").endsWith("/firmware-registry/Bluetooth_Switch/ab.json")); +// The board name must match the one CI records it under. +const ci = readFileSync(join(fw, "..", "..", ".github", "workflows", "firmware-ci.yml"), "utf8"); +assert.match(ci, new RegExp(`board: ${P.REGISTRY_BOARD},\\s+project: Bluetooth_Switch`)); + +console.log("protocol ok");