Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/firmware-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
125 changes: 125 additions & 0 deletions firmware/Bluetooth_Switch/README.md
Original file line number Diff line number Diff line change
@@ -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/<digest>.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 <digest> --registry <firmware-registry checkout> # 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.
22 changes: 22 additions & 0 deletions firmware/Bluetooth_Switch/include/ble_link.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#pragma once

#include <Arduino.h>

#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
27 changes: 27 additions & 0 deletions firmware/Bluetooth_Switch/include/ina260.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#pragma once

#include <Arduino.h>
#include <Wire.h>

// 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;
};
83 changes: 83 additions & 0 deletions firmware/Bluetooth_Switch/include/led_pattern.h
Original file line number Diff line number Diff line change
@@ -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 <stdint.h>

#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
12 changes: 12 additions & 0 deletions firmware/Bluetooth_Switch/include/pins.h
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions firmware/Bluetooth_Switch/include/status.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#pragma once

#include <stdint.h>

// 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");
20 changes: 20 additions & 0 deletions firmware/Bluetooth_Switch/platformio.ini
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading