From 0a4bfa1cd3e9c11b036810e2597f32a5df490958 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Thu, 12 Nov 2026 13:08:39 -0800 Subject: [PATCH 1/4] Add Bluetooth switch firmware for the WifiSwitch board --- firmware/Bluetooth_Switch/README.md | 58 ++++++ firmware/Bluetooth_Switch/include/ble_link.h | 19 ++ firmware/Bluetooth_Switch/include/ina260.h | 27 +++ firmware/Bluetooth_Switch/include/pins.h | 12 ++ firmware/Bluetooth_Switch/include/status.h | 35 ++++ firmware/Bluetooth_Switch/platformio.ini | 16 ++ firmware/Bluetooth_Switch/src/ble_link.cpp | 87 ++++++++ firmware/Bluetooth_Switch/src/ina260.cpp | 78 ++++++++ firmware/Bluetooth_Switch/src/main.cpp | 196 +++++++++++++++++++ 9 files changed, 528 insertions(+) create mode 100644 firmware/Bluetooth_Switch/README.md create mode 100644 firmware/Bluetooth_Switch/include/ble_link.h create mode 100644 firmware/Bluetooth_Switch/include/ina260.h create mode 100644 firmware/Bluetooth_Switch/include/pins.h create mode 100644 firmware/Bluetooth_Switch/include/status.h create mode 100644 firmware/Bluetooth_Switch/platformio.ini create mode 100644 firmware/Bluetooth_Switch/src/ble_link.cpp create mode 100644 firmware/Bluetooth_Switch/src/ina260.cpp create mode 100644 firmware/Bluetooth_Switch/src/main.cpp diff --git a/firmware/Bluetooth_Switch/README.md b/firmware/Bluetooth_Switch/README.md new file mode 100644 index 00000000..dfd4707a --- /dev/null +++ b/firmware/Bluetooth_Switch/README.md @@ -0,0 +1,58 @@ +# 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: on | off | status +``` + +## Output policy + +- **Latched.** ON stays on until an explicit OFF (disable). 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 OFF.** + +## 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. + +| | UUID | Access | +|---|---|---| +| Service | `53544152-0001-4b8a-9d3e-7a1c2f6b0e10` | | +| Output | `53544152-0002-4b8a-9d3e-7a1c2f6b0e10` | read, write, notify. `0x01` = ON, `0x00` = OFF (disable) | +| Status | `53544152-0003-4b8a-9d3e-7a1c2f6b0e10` | read, notify (every 2 s while connected, and after every switch) | + +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. + +## Not done yet + +- SK6805 status LED (GPIO5). +- 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..ed64b948 --- /dev/null +++ b/firmware/Bluetooth_Switch/include/ble_link.h @@ -0,0 +1,19 @@ +#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. +namespace ble_link { + +using OutputRequestFn = void (*)(bool on); + +void begin(const char *name, OutputRequestFn on_output_request); +bool connected(); +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/pins.h b/firmware/Bluetooth_Switch/include/pins.h new file mode 100644 index 00000000..965d6384 --- /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 (unused for now) +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..e4d021a7 --- /dev/null +++ b/firmware/Bluetooth_Switch/platformio.ini @@ -0,0 +1,16 @@ +[platformio] +extra_configs = ../platformio-common.ini + +[env:bluetooth_switch] +extends = esp32c3_usb_base +board_build.f_cpu = 80000000L +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 + -DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=1 diff --git a/firmware/Bluetooth_Switch/src/ble_link.cpp b/firmware/Bluetooth_Switch/src/ble_link.cpp new file mode 100644 index 00000000..f5ae2784 --- /dev/null +++ b/firmware/Bluetooth_Switch/src/ble_link.cpp @@ -0,0 +1,87 @@ +#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"; + +// 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; +OutputRequestFn outputRequest = nullptr; + +volatile bool isConnected = false; + +class ServerCallbacks : public NimBLEServerCallbacks { + void onConnect(NimBLEServer *s, ble_gap_conn_desc *desc) override { + isConnected = true; + s->updateConnParams(desc->conn_handle, CONN_INTERVAL_MIN, CONN_INTERVAL_MAX, + CONN_LATENCY, CONN_TIMEOUT); + } + + // Disconnecting never touches the output: losing the phone at the pad + // must not power down the altimeters. + void onDisconnect(NimBLEServer *, ble_gap_conn_desc *) override { isConnected = false; } +}; + +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); + + NimBLEServer *server = NimBLEDevice::createServer(); + server->setCallbacks(new ServerCallbacks()); + + 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); + svc->start(); + + NimBLEAdvertising *adv = NimBLEDevice::getAdvertising(); + adv->addServiceUUID(SERVICE_UUID); + adv->setScanResponse(true); + adv->setMinInterval(ADV_INTERVAL); + adv->setMaxInterval(ADV_INTERVAL); + adv->start(); +} + +bool connected() { return isConnected; } + +void publishOutput(bool on) { + uint8_t v = on ? 1 : 0; + outputChr->setValue(&v, 1); + if (isConnected) outputChr->notify(); +} + +void publishStatus(const StatusPacket &pkt) { + statusChr->setValue(reinterpret_cast(&pkt), sizeof(pkt)); + if (isConnected) 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..14d5e3ed --- /dev/null +++ b/firmware/Bluetooth_Switch/src/main.cpp @@ -0,0 +1,196 @@ +// 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 "ble_link.h" +#include "ina260.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 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; + +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 + Serial.printf("[out] %s\n", on ? "ON" : "OFF (disabled)"); + 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); +} + +Sense classifySense(uint32_t mv) { + if (mv >= SENSE_LIVE_MIN_MV) return Sense::Live; + if (outputOn) return Sense::Unknown; // on but not live: switch or supply fault + return mv <= SENSE_LOAD_MAX_MV ? Sense::LoadPresent : Sense::Open; +} + +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( + static_cast(millis() - senseValidAtMs) >= 0 ? classifySense(mv) : Sense::Unknown); + 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 ? "ON" : "OFF", 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: on | off | status +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 == "on") { + setOutput(true); + } else if (line == "off") { + setOutput(false); + } else if (line == "status") { + printStatus(buildStatus()); + } else if (line.length()) { + Serial.println("commands: on | off | 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); + + Serial.printf("[boot] %s reset=%d output=%s ina260=%s\n", name, + static_cast(resetReason), outputOn ? "ON (restored)" : "OFF", + inaOk ? "ok" : "MISSING"); +} + +void loop() { + int8_t req = pendingOutput; + if (req >= 0) { + pendingOutput = -1; + if ((req == 1) != outputOn) setOutput(req == 1); + else ble_link::publishOutput(outputOn); + } + + handleSerial(); + + 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); +} From 0ec7c6c9271502914df434c963f021cd6dc1c5e8 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Thu, 12 Nov 2026 14:09:46 -0800 Subject: [PATCH 2/4] Bluetooth switch: web client, and several phones at once --- firmware/Bluetooth_Switch/README.md | 28 ++ firmware/Bluetooth_Switch/include/ble_link.h | 3 +- firmware/Bluetooth_Switch/platformio.ini | 3 +- firmware/Bluetooth_Switch/src/ble_link.cpp | 26 +- firmware/Bluetooth_Switch/web/index.html | 345 ++++++++++++++++++ firmware/Bluetooth_Switch/web/protocol.js | 62 ++++ .../Bluetooth_Switch/web/test/pack_status.cpp | 18 + .../web/test/protocol.test.mjs | 39 ++ 8 files changed, 513 insertions(+), 11 deletions(-) create mode 100644 firmware/Bluetooth_Switch/web/index.html create mode 100644 firmware/Bluetooth_Switch/web/protocol.js create mode 100644 firmware/Bluetooth_Switch/web/test/pack_status.cpp create mode 100644 firmware/Bluetooth_Switch/web/test/protocol.test.mjs diff --git a/firmware/Bluetooth_Switch/README.md b/firmware/Bluetooth_Switch/README.md index dfd4707a..77eea009 100644 --- a/firmware/Bluetooth_Switch/README.md +++ b/firmware/Bluetooth_Switch/README.md @@ -22,6 +22,9 @@ pio device monitor # console: on | off | status 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 | |---|---|---| @@ -50,6 +53,31 @@ connected reads "open". With the output on it should read "live". If it reads an else, the switch or the supply has failed. The thresholds in `main.cpp` still need bench calibration. +## 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. ON and OFF +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 ("Output ON 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 - SK6805 status LED (GPIO5). diff --git a/firmware/Bluetooth_Switch/include/ble_link.h b/firmware/Bluetooth_Switch/include/ble_link.h index ed64b948..d7afc14a 100644 --- a/firmware/Bluetooth_Switch/include/ble_link.h +++ b/firmware/Bluetooth_Switch/include/ble_link.h @@ -6,7 +6,8 @@ // 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. +// 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); diff --git a/firmware/Bluetooth_Switch/platformio.ini b/firmware/Bluetooth_Switch/platformio.ini index e4d021a7..7ba0de3a 100644 --- a/firmware/Bluetooth_Switch/platformio.ini +++ b/firmware/Bluetooth_Switch/platformio.ini @@ -13,4 +13,5 @@ build_flags = ; Peripheral role only; drops the central/observer code and its RAM. -DCONFIG_BT_NIMBLE_ROLE_CENTRAL_DISABLED -DCONFIG_BT_NIMBLE_ROLE_OBSERVER_DISABLED - -DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=1 + ; 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 index f5ae2784..49e58a5f 100644 --- a/firmware/Bluetooth_Switch/src/ble_link.cpp +++ b/firmware/Bluetooth_Switch/src/ble_link.cpp @@ -22,18 +22,25 @@ NimBLECharacteristic *outputChr = nullptr; NimBLECharacteristic *statusChr = nullptr; OutputRequestFn outputRequest = nullptr; -volatile bool isConnected = false; +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 { - isConnected = true; 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 the phone at the pad - // must not power down the altimeters. - void onDisconnect(NimBLEServer *, ble_gap_conn_desc *) override { isConnected = false; } + // 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 { @@ -52,8 +59,9 @@ void begin(const char *name, OutputRequestFn on_output_request) { NimBLEDevice::init(name); NimBLEDevice::setPower(ESP_PWR_LVL_P9); - NimBLEServer *server = NimBLEDevice::createServer(); + server = NimBLEDevice::createServer(); server->setCallbacks(new ServerCallbacks()); + server->advertiseOnDisconnect(true); NimBLEService *svc = server->createService(SERVICE_UUID); outputChr = svc->createCharacteristic( @@ -71,17 +79,17 @@ void begin(const char *name, OutputRequestFn on_output_request) { adv->start(); } -bool connected() { return isConnected; } +bool connected() { return server && server->getConnectedCount() > 0; } void publishOutput(bool on) { uint8_t v = on ? 1 : 0; outputChr->setValue(&v, 1); - if (isConnected) outputChr->notify(); + if (connected()) outputChr->notify(); } void publishStatus(const StatusPacket &pkt) { statusChr->setValue(reinterpret_cast(&pkt), sizeof(pkt)); - if (isConnected) statusChr->notify(); + if (connected()) statusChr->notify(); } } // namespace ble_link diff --git a/firmware/Bluetooth_Switch/web/index.html b/firmware/Bluetooth_Switch/web/index.html new file mode 100644 index 00000000..bc8365c9 --- /dev/null +++ b/firmware/Bluetooth_Switch/web/index.html @@ -0,0 +1,345 @@ + + + + + + +STAR Switch + + + +
+
+

STAR Switch

+ +
+ +
No switch connected. Power the board, then tap Connect switch and pick a STAR-Switch-XXXX.
+
+
+ The output is latched: it stays as set until you change it. Closing this page or losing + Bluetooth does not turn anything off. 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..68570cd5 --- /dev/null +++ b/firmware/Bluetooth_Switch/web/protocol.js @@ -0,0 +1,62 @@ +// 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 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}`; + +// 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: "Output live" } + : { level: "bad", text: `Output ON but sense reads ${name}: 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: "Output OFF but VOUT is live: back-fed or switch fault" }; +} 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..f7b30f35 --- /dev/null +++ b/firmware/Bluetooth_Switch/web/test/protocol.test.mjs @@ -0,0 +1,39 @@ +// 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]) 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};`)); + +console.log("protocol ok"); From 80632f0d02eb3c3ad4b22789502daba39ee17014 Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Thu, 12 Nov 2026 14:09:47 -0800 Subject: [PATCH 3/4] Bluetooth switch: say armed/disarmed, and build it in CI --- .github/workflows/firmware-ci.yml | 39 +++++++++++++++++++++++ firmware/Bluetooth_Switch/README.md | 12 +++---- firmware/Bluetooth_Switch/src/main.cpp | 14 ++++---- firmware/Bluetooth_Switch/web/index.html | 22 ++++++------- firmware/Bluetooth_Switch/web/protocol.js | 6 ++-- 5 files changed, 66 insertions(+), 27 deletions(-) diff --git a/.github/workflows/firmware-ci.yml b/.github/workflows/firmware-ci.yml index 0fbc3c06..6a46fb71 100644 --- a/.github/workflows/firmware-ci.yml +++ b/.github/workflows/firmware-ci.yml @@ -184,6 +184,45 @@ jobs: path: firmware/${{ matrix.project }}/.pio/build/${{ matrix.pio_env }}/firmware.bin retention-days: 30 + # Not in the build-firmware matrix: that matrix gates on a path-independent + # digest and feeds the registry, and the switch reports no digest. + bluetooth-switch: + name: Bluetooth switch (firmware + web client) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install PlatformIO + run: pip install platformio + + - name: Compile firmware + run: pio run -e bluetooth_switch + working-directory: firmware/Bluetooth_Switch + + # Decodes bytes packed by the firmware's StatusPacket and checks the UUIDs + # and STATUS_VERSION 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 + + - name: Upload the image + uses: actions/upload-artifact@v4 + with: + name: firmware-image-Bluetooth_Switch + path: firmware/Bluetooth_Switch/.pio/build/bluetooth_switch/firmware.bin + retention-days: 30 + run-tests: name: Run Unit Tests runs-on: windows-latest diff --git a/firmware/Bluetooth_Switch/README.md b/firmware/Bluetooth_Switch/README.md index 77eea009..26995e85 100644 --- a/firmware/Bluetooth_Switch/README.md +++ b/firmware/Bluetooth_Switch/README.md @@ -7,16 +7,16 @@ 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: on | off | status +pio device monitor # console: arm | disarm | status ``` ## Output policy -- **Latched.** ON stays on until an explicit OFF (disable). Nothing times it out. +- **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 OFF.** +- **Power cycle comes up DISARMED.** ## BLE @@ -29,7 +29,7 @@ notifications, and the last write wins. | | UUID | Access | |---|---|---| | Service | `53544152-0001-4b8a-9d3e-7a1c2f6b0e10` | | -| Output | `53544152-0002-4b8a-9d3e-7a1c2f6b0e10` | read, write, notify. `0x01` = ON, `0x00` = OFF (disable) | +| 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) | Status is 17 bytes, little-endian (`include/status.h`): @@ -68,10 +68,10 @@ cd web && python3 -m http.server 8000 # then open http://localhost:8000 on t 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. ON and OFF +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 ("Output ON but sense reads +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* diff --git a/firmware/Bluetooth_Switch/src/main.cpp b/firmware/Bluetooth_Switch/src/main.cpp index 14d5e3ed..f309cf56 100644 --- a/firmware/Bluetooth_Switch/src/main.cpp +++ b/firmware/Bluetooth_Switch/src/main.cpp @@ -67,7 +67,7 @@ void setOutput(bool on) { restoredAfterReset = false; senseValidAtMs = millis() + SENSE_SETTLE_MS; lastStatusMs = 0; // force a status update once the sense line settles - Serial.printf("[out] %s\n", on ? "ON" : "OFF (disabled)"); + Serial.printf("[out] %s\n", on ? "ARMED" : "DISARMED"); ble_link::publishOutput(on); } @@ -115,7 +115,7 @@ StatusPacket buildStatus() { 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 ? "ON" : "OFF", SENSE_NAMES[p.sense < 4 ? p.sense : 0], p.sense_mV, + 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]" : ""); @@ -123,7 +123,7 @@ void printStatus(const StatusPacket &p) { void onOutputRequest(bool on) { pendingOutput = on ? 1 : 0; } -// Bench console over USB CDC: on | off | status +// Bench console over USB CDC: arm | disarm | status (on/off still accepted) void handleSerial() { static String line; while (Serial.available()) { @@ -133,14 +133,14 @@ void handleSerial() { continue; } line.trim(); - if (line == "on") { + if (line == "arm" || line == "on") { setOutput(true); - } else if (line == "off") { + } else if (line == "disarm" || line == "off") { setOutput(false); } else if (line == "status") { printStatus(buildStatus()); } else if (line.length()) { - Serial.println("commands: on | off | status"); + Serial.println("commands: arm | disarm | status"); } line = ""; } @@ -168,7 +168,7 @@ void setup() { ble_link::publishOutput(outputOn); Serial.printf("[boot] %s reset=%d output=%s ina260=%s\n", name, - static_cast(resetReason), outputOn ? "ON (restored)" : "OFF", + static_cast(resetReason), outputOn ? "ARMED (restored)" : "DISARMED", inaOk ? "ok" : "MISSING"); } diff --git a/firmware/Bluetooth_Switch/web/index.html b/firmware/Bluetooth_Switch/web/index.html index bc8365c9..7d7f0941 100644 --- a/firmware/Bluetooth_Switch/web/index.html +++ b/firmware/Bluetooth_Switch/web/index.html @@ -69,8 +69,8 @@

STAR Switch

No switch connected. Power the board, then tap Connect switch and pick a STAR-Switch-XXXX.
- The output is latched: it stays as set until you change it. Closing this page or losing - Bluetooth does not turn anything off. Anyone in range can connect. + Arming is latched: the altimeters stay powered until someone disarms. Closing this page or + losing Bluetooth does not disarm. Anyone in range can connect.
@@ -201,11 +201,11 @@

} async requestOutput(on) { - const verb = on ? "ON" : "OFF"; + const verb = on ? "Arm" : "Disarm"; const body = on - ? "This powers the altimeters. It stays on until someone turns it off; closing this page or losing Bluetooth will not turn it off." - : "This cuts power to the altimeters. If the rocket is armed on the pad, they will stop recording."; - const ok = await confirmAction(`Turn ${this.name} ${verb}?`, body, `Turn ${verb}`, on ? "go-on" : "go-off"); + ? "This powers the altimeters. They stay armed until someone disarms; closing this page or losing Bluetooth will not disarm." + : "This cuts power to the altimeters. They will stop recording and will not fire charges."; + const ok = await confirmAction(`${verb} ${this.name}?`, body, verb, on ? "go-on" : "go-off"); if (!ok || !this.outputChr) return; this.busy = true; this.update(); @@ -216,7 +216,7 @@

// The firmware notifies the output characteristic once it has switched. this.onOutput(await this.outputChr.readValue()); } catch (err) { - alert(`Could not switch ${this.name}: ${err.message}`); + alert(`Could not ${on ? "arm" : "disarm"} ${this.name}: ${err.message}`); } finally { this.busy = false; this.update(); @@ -245,8 +245,8 @@

- - + +
`; el.querySelector(".name").textContent = this.name; @@ -269,7 +269,7 @@

const pill = el.querySelector(".pill"); if (s) { - pill.textContent = s.output ? "ON" : "OFF"; + pill.textContent = s.output ? "ARMED" : "DISARMED"; pill.className = `pill ${s.output ? "on" : ""}`; if (!live) pill.textContent += " (last seen)"; } @@ -294,7 +294,7 @@

const meta = [`up ${fmtUptime(s.uptime_s)}`, `last boot: ${resetReasonName(s.resetReason)}`, `sense ${s.sense_mV} mV`]; let html = meta.map(esc).join(" · "); if (!s.inaOk) html += ` · INA260 missing`; - if (s.restored) html += ` · output restored after MCU reset`; + if (s.restored) html += ` · armed state restored after MCU reset`; el.querySelector(".meta").innerHTML = html; } diff --git a/firmware/Bluetooth_Switch/web/protocol.js b/firmware/Bluetooth_Switch/web/protocol.js index 68570cd5..48176c88 100644 --- a/firmware/Bluetooth_Switch/web/protocol.js +++ b/firmware/Bluetooth_Switch/web/protocol.js @@ -53,10 +53,10 @@ export function senseVerdict(s) { if (s.sense === 0) return { level: "warn", text: "Sense settling" }; if (s.output) { return s.sense === 3 - ? { level: "ok", text: "Output live" } - : { level: "bad", text: `Output ON but sense reads ${name}: switch or supply fault` }; + ? { 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: "Output OFF but VOUT is live: back-fed or switch fault" }; + return { level: "bad", text: "DISARMED but altimeter power is live: switch stuck or back-fed" }; } From f3da7e5ab4f69c3f078a8926ddd6432748d3838a Mon Sep 17 00:00:00 2001 From: swissskimmilk Date: Thu, 12 Nov 2026 14:09:47 -0800 Subject: [PATCH 4/4] Bluetooth switch: status LED, firmware hash over BLE, registry --- .github/workflows/firmware-ci.yml | 35 +++---- firmware/Bluetooth_Switch/README.md | 41 +++++++- firmware/Bluetooth_Switch/include/ble_link.h | 2 + .../Bluetooth_Switch/include/led_pattern.h | 83 +++++++++++++++++ firmware/Bluetooth_Switch/include/pins.h | 2 +- firmware/Bluetooth_Switch/platformio.ini | 3 + firmware/Bluetooth_Switch/src/ble_link.cpp | 9 ++ firmware/Bluetooth_Switch/src/main.cpp | 49 +++++++++- .../test/host/led_pattern_test.cpp | 93 +++++++++++++++++++ firmware/Bluetooth_Switch/web/index.html | 50 +++++++++- firmware/Bluetooth_Switch/web/protocol.js | 15 +++ .../web/test/protocol.test.mjs | 12 ++- 12 files changed, 360 insertions(+), 34 deletions(-) create mode 100644 firmware/Bluetooth_Switch/include/led_pattern.h create mode 100644 firmware/Bluetooth_Switch/test/host/led_pattern_test.cpp diff --git a/.github/workflows/firmware-ci.yml b/.github/workflows/firmware-ci.yml index 6a46fb71..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,45 +185,33 @@ jobs: path: firmware/${{ matrix.project }}/.pio/build/${{ matrix.pio_env }}/firmware.bin retention-days: 30 - # Not in the build-firmware matrix: that matrix gates on a path-independent - # digest and feeds the registry, and the switch reports no digest. - bluetooth-switch: - name: Bluetooth switch (firmware + web client) + # 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 Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - name: Set up Node uses: actions/setup-node@v4 with: node-version: "20" - - name: Install PlatformIO - run: pip install platformio - - - name: Compile firmware - run: pio run -e bluetooth_switch + - 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 - # and STATUS_VERSION match, so the page and the firmware can't drift apart. + # 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 - - name: Upload the image - uses: actions/upload-artifact@v4 - with: - name: firmware-image-Bluetooth_Switch - path: firmware/Bluetooth_Switch/.pio/build/bluetooth_switch/firmware.bin - retention-days: 30 - run-tests: name: Run Unit Tests runs-on: windows-latest diff --git a/firmware/Bluetooth_Switch/README.md b/firmware/Bluetooth_Switch/README.md index 26995e85..74efb439 100644 --- a/firmware/Bluetooth_Switch/README.md +++ b/firmware/Bluetooth_Switch/README.md @@ -31,6 +31,7 @@ notifications, and the last write wins. | 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`): @@ -53,6 +54,45 @@ connected reads "open". With the output on it should read "live". If it reads an 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 @@ -80,7 +120,6 @@ until both match. ## Not done yet -- SK6805 status LED (GPIO5). - 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 index d7afc14a..a89019ec 100644 --- a/firmware/Bluetooth_Switch/include/ble_link.h +++ b/firmware/Bluetooth_Switch/include/ble_link.h @@ -14,6 +14,8 @@ 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); 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 index 965d6384..13b728b5 100644 --- a/firmware/Bluetooth_Switch/include/pins.h +++ b/firmware/Bluetooth_Switch/include/pins.h @@ -2,7 +2,7 @@ // WifiSwitch.SchDoc (ESP32-C3FH4) constexpr int PIN_OUT_EN = 4; // MTMS -> TPS22810 EN/UVLO, high = VOUT live -constexpr int PIN_LED = 5; // SK6805 DIN (unused for now) +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) diff --git a/firmware/Bluetooth_Switch/platformio.ini b/firmware/Bluetooth_Switch/platformio.ini index 7ba0de3a..d5f4891d 100644 --- a/firmware/Bluetooth_Switch/platformio.ini +++ b/firmware/Bluetooth_Switch/platformio.ini @@ -4,6 +4,9 @@ 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 diff --git a/firmware/Bluetooth_Switch/src/ble_link.cpp b/firmware/Bluetooth_Switch/src/ble_link.cpp index 49e58a5f..462396c1 100644 --- a/firmware/Bluetooth_Switch/src/ble_link.cpp +++ b/firmware/Bluetooth_Switch/src/ble_link.cpp @@ -9,6 +9,7 @@ namespace { 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. @@ -20,6 +21,8 @@ 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; @@ -28,6 +31,7 @@ NimBLEServer *server = nullptr; // 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 @@ -69,6 +73,7 @@ void begin(const char *name, OutputRequestFn on_output_request) { 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(); @@ -79,6 +84,10 @@ void begin(const char *name, OutputRequestFn on_output_request) { 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) { diff --git a/firmware/Bluetooth_Switch/src/main.cpp b/firmware/Bluetooth_Switch/src/main.cpp index f309cf56..5ce1c032 100644 --- a/firmware/Bluetooth_Switch/src/main.cpp +++ b/firmware/Bluetooth_Switch/src/main.cpp @@ -16,8 +16,10 @@ #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" @@ -34,6 +36,7 @@ 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" @@ -51,6 +54,14 @@ 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); @@ -67,6 +78,8 @@ void setOutput(bool 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); } @@ -82,12 +95,38 @@ void restoreOutput() { 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; - if (outputOn) return Sense::Unknown; // on but not live: switch or supply fault 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; @@ -107,8 +146,7 @@ StatusPacket buildStatus() { uint32_t mv = analogReadMilliVolts(PIN_CONT_ADC); pkt.sense_mV = static_cast(mv); - pkt.sense = static_cast( - static_cast(millis() - senseValidAtMs) >= 0 ? classifySense(mv) : Sense::Unknown); + pkt.sense = static_cast(readSense(mv)); return pkt; } @@ -139,6 +177,7 @@ void handleSerial() { setOutput(false); } else if (line == "status") { printStatus(buildStatus()); + FirmwareHash::print(); } else if (line.length()) { Serial.println("commands: arm | disarm | status"); } @@ -166,10 +205,13 @@ void setup() { 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() { @@ -181,6 +223,7 @@ void loop() { } handleSerial(); + updateLed(millis()); uint32_t period = ble_link::connected() ? STATUS_PERIOD_CONNECTED_MS : STATUS_PERIOD_IDLE_MS; uint32_t now = millis(); 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 index 7d7f0941..3e2dee7f 100644 --- a/firmware/Bluetooth_Switch/web/index.html +++ b/firmware/Bluetooth_Switch/web/index.html @@ -32,7 +32,7 @@ .link.down { color: var(--warn); } .state { margin: 14px 0 10px; display: flex; align-items: center; gap: 12px; } .pill { font-size: 28px; font-weight: 700; letter-spacing: .5px; padding: 6px 16px; border-radius: 999px; background: var(--off); } - .pill.on { background: var(--on); } + .pill.on { background: var(--bad); } /* armed is the hazardous state: red, like the LED */ .pill.unknown { background: var(--line); color: var(--dim); } .verdict { font-size: 14px; padding: 8px 10px; border-radius: 8px; margin-bottom: 12px; border-left: 4px solid var(--dim); background: #ffffff08; } .verdict.ok { border-color: var(--on); } .verdict.warn { border-color: var(--warn); } .verdict.bad { border-color: var(--bad); } @@ -42,7 +42,7 @@ .meta { font-size: 12px; color: var(--dim); margin-bottom: 12px; } .meta .flag { color: var(--warn); } .actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } - .actions .on { background: var(--on); } .actions .off { background: var(--bad); } + .actions .on { background: var(--bad); } .actions .off { background: var(--on); } .actions button { padding: 16px; font-size: 18px; font-weight: 600; } .forget { margin-top: 10px; width: 100%; background: transparent; color: var(--dim); padding: 6px; font-size: 13px; } .modal { position: fixed; inset: 0; background: #000a; display: none; align-items: center; justify-content: center; padding: 16px; z-index: 10; } @@ -50,7 +50,7 @@ .modal .box { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 20px; max-width: 420px; width: 100%; } .modal h2 { margin: 0 0 8px; font-size: 20px; } .modal p { color: var(--dim); margin: 0 0 18px; } - .modal .actions button.go-on { background: var(--on); } .modal .actions button.go-off { background: var(--bad); } + .modal .actions button.go-on { background: var(--bad); } .modal .actions button.go-off { background: var(--on); } footer { margin-top: 20px; font-size: 12px; color: var(--dim); } @@ -87,8 +87,8 @@