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
5 changes: 4 additions & 1 deletion deploy/caddy/tests/check_gate_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@
#: checks a STARProject bearer token on every /mcp request itself
#: (design-mcp/star_design_mcp/server.py, BearerAuth) and serves /files/* only
#: on a signed, expiring link; the site strips X-Auth-* before proxying.
PUBLIC_HOSTS = {"auth", "mcp"}
#: switch -- (EC2 Caddyfile) the Bluetooth switch's static web client. It
#: proxies nothing; the board it drives is open to anyone in radio range, and
#: the page must load at a launch site without a login.
PUBLIC_HOSTS = {"auth", "mcp", "switch"}

#: Path prefixes that are public by design on an otherwise gated host, keyed by
#: the host's first label. A route is exempt only if EVERY path it matches lies
Expand Down
13 changes: 13 additions & 0 deletions deploy/ec2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ OpenProject vars in `.env` (`OPENPROJECT_SECRET_KEY_BASE`, SES creds), then
`docker compose --profile legacy up -d openproject`. Its data is intact on the
external `openproject_pgdata` / `openproject_assets` volumes (+ S3 backups).

## Bluetooth switch web client

`switch.starberkeley.org` serves `firmware/Bluetooth_Switch/web/` (the altimeter
switch's Web Bluetooth page) from the checkout, through Caddy, with no login. No
image: auto-update's git sync is the deploy, so a page change on `main` is live
within one timer tick (5 min). The tick that first brings in the mount also runs
`docker compose up -d`, which recreates Caddy with it. One-time setup:

1. Tunnel → **Add published application**: `switch.starberkeley.org` → `http://caddy:80`.

**Verify:** `curl -sI https://switch.starberkeley.org/` returns `200` (no redirect
to auth), and `/test/protocol.test.mjs` returns `404`.

## Server analytics

`analytics.starberkeley.org` (services `analytics` and `analytics-agent`) shows
Expand Down
15 changes: 15 additions & 0 deletions deploy/ec2/caddy/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,18 @@
reverse_proxy analytics:8080
}
}

# ── Bluetooth switch web client ──────────────────────────────────────────────
# Static Web Bluetooth page for the altimeter switch (firmware/Bluetooth_Switch/
# web, mounted read-only). Public on purpose: the board itself takes commands
# from anyone in radio range, so a login would protect nothing, and it would
# stop the page loading at a pad with no signal. It proxies nothing, so no
# X-Auth-* can reach anything. web/test/ is the decoder's test, not the page.
{$SCHEME:http}://switch.{$BASE_DOMAIN:starberkeley.org} {
@tests path /test /test/*
respond @tests 404
# Revalidate every load, so a firmware PR's page change shows up at once.
header Cache-Control "no-cache"
root * /srv/switch
file_server
}
3 changes: 3 additions & 0 deletions deploy/ec2/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ services:
AUTH_UPSTREAM: auth:5000
volumes:
- ./caddy:/etc/caddy:ro
# Bluetooth switch web client (switch.* site), served straight from the
# checkout, so auto-update's git sync is its deploy.
- ../../firmware/Bluetooth_Switch/web:/srv/switch:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
Expand Down
1 change: 1 addition & 0 deletions firmware/Bluetooth_Switch/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
sdkconfig.bluetooth_switch
3 changes: 3 additions & 0 deletions firmware/Bluetooth_Switch/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
cmake_minimum_required(VERSION 3.16.0)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(Bluetooth_Switch)
47 changes: 32 additions & 15 deletions firmware/Bluetooth_Switch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,20 @@ Status is 17 bytes, little-endian (`include/status.h`):
| 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 |
| 3 | u8 | flags: bit0 INA260 ok, bit1 reading valid, bit2 restored after reset, bit3 no battery (J1-3 < 2.5 V) |
| 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.
**Sense** reads VOUT through the continuity divider. With the output on it should
read "live"; anything else means the switch or the supply has failed, and disarmed
but live means the switch is stuck on. The firmware still reports load/open while
disarmed, but neither the LED nor the web page shows it: the 1 MΩ continuity drive
holds VOUT near 0.47 V, which can't push current through the bridge board's body
diodes, so an altimeter behind it reads the same as nothing at all.

## Firmware hash

Expand All @@ -73,14 +74,17 @@ A hash that isn't in the registry is a local or PR build.

## Status LED (SK6805, GPIO5)

Pure red, green or blue only. The SK6805 mixes colours badly (amber reads as red
and green at once), so states differ by pattern.

| State | LED |
|---|---|
| Disarmed, altimeter detected | dim green blip every 3 s |
| Disarmed, nothing connected | dim amber blip every 3 s |
| Disarmed, **no battery** on J1-3 (below 2.5 V) | one long (400 ms) dim blue pulse every 3 s |
| Disarmed | one dim green 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 |
| Armed, restored after an MCU reset | red blink plus a short red blip between, until the next arm/disarm |
| **Fault**: armed but VOUT not live, or disarmed but live | red flicker, 5 Hz |
| INA260 missing | blue 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 |

Expand Down Expand Up @@ -118,8 +122,21 @@ 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
## Power

Built as ESP-IDF 4.4 with Arduino as a component (`framework = arduino, espidf`),
because the prebuilt Arduino core has power management and BLE modem sleep off,
which held the radio on at ~80 mA. `sdkconfig.defaults` is arduino-esp32 2.0.17's
own C3 config with the overrides at the end: automatic light sleep, BLE modem
sleep clocked from the main crystal, and a fixed app version so the digest doesn't
follow `git describe`. IDF floats every pin during light sleep on the C3, so
`main.cpp` exempts the pins it drives; GPIO4 is the arm line.

The first build downloads IDF and takes ~10 minutes. After editing
`sdkconfig.defaults`, delete the generated `sdkconfig.bluetooth_switch`: an
existing sdkconfig wins over the defaults.

- 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.
The chip only light-sleeps on battery: while USB is plugged in it holds itself
awake so the console and flashing work. The console's `status` line says which
(`power=light sleep` / `awake for USB`). Other savings: 80 MHz CPU, 1 s
advertising, 300–400 ms connection interval, INA260 in triggered mode.
47 changes: 27 additions & 20 deletions firmware/Bluetooth_Switch/include/led_pattern.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
// 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
// Pure red, green and blue only: the SK6805 mixes poorly, so amber reads as
// red-and-green. States differ by pattern instead of colour.
//
// disarmed, no battery one long dim blue pulse every 3 s
// disarmed one dim green blip every 3 s
// ARMED, output live red blink, 1 Hz
// ARMED, restored after reset red blink plus a short red blip between
// FAULT red flicker, 5 Hz
// (armed but VOUT not live, or disarmed but live)
// INA260 missing blue double blip once per 3 s, on top of the above
// phone connected one blue blip
// arm / disarm command one bright flash, red for armed, green for disarmed

#include <stdint.h>

Expand All @@ -26,16 +29,15 @@ struct Rgb {

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 DIM_BLUE{0, 0, 12};
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 };
enum class Mode : uint8_t { DisarmedNoBattery, Disarmed, DisarmedUnknown, Armed, Fault };

struct Inputs {
bool armed;
Expand All @@ -44,13 +46,17 @@ struct Inputs {
bool inaOk;
uint32_t sinceCommandMs; // since the last arm/disarm; large if none
uint32_t sinceConnectMs; // since a phone last connected; large if none
bool noBattery = false; // J1-3 reads below the battery-present threshold
};

// Armed with no battery is left to the sense line: VOUT can't be live, so it
// reads as a fault.
inline Mode mode(const Inputs &in) {
if (!in.armed && in.noBattery) return Mode::DisarmedNoBattery;
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;
return Mode::Disarmed; // continuity can't see through the bridge board, so it isn't shown
}

inline Rgb color(const Inputs &in, uint32_t nowMs) {
Expand All @@ -61,20 +67,21 @@ inline Rgb color(const Inputs &in, uint32_t nowMs) {
const uint32_t t3 = nowMs % 3000;
const Mode m = mode(in);

if (m == Mode::Fault) return (nowMs / 100) % 2 ? AMBER : RED;
if (m == Mode::Fault) return (nowMs / 100) % 2 ? OFF : 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;
// INA260 missing: two blue blips in the off half of the armed blink, clear
// of the disarmed green blips at the start of the period.
if (!in.inaOk && ((t3 >= 2550 && t3 < 2610) || (t3 >= 2850 && t3 < 2910))) return BLUE;

switch (m) {
case Mode::Armed:
if (t1 < 500) return RED;
if (in.restored && t1 >= 700 && t1 < 800) return AMBER;
if (in.restored && t1 >= 700 && t1 < 800) return RED;
return OFF;
case Mode::DisarmedLoad:
case Mode::DisarmedNoBattery:
return t3 < 400 ? DIM_BLUE : OFF;
case Mode::Disarmed:
return t3 < 60 ? DIM_GREEN : OFF;
case Mode::DisarmedOpen:
return t3 < 60 ? DIM_AMBER : OFF;
default:
return OFF; // settling: say nothing rather than guess
}
Expand Down
1 change: 1 addition & 0 deletions firmware/Bluetooth_Switch/include/status.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ 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
FLAG_NO_BATTERY = 1 << 3, // J1-3 below BATTERY_PRESENT_MIN_MV (main.cpp)
};

// Status characteristic payload, little-endian, 17 bytes (fits the default
Expand Down
7 changes: 6 additions & 1 deletion firmware/Bluetooth_Switch/platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ extra_configs = ../platformio-common.ini

[env:bluetooth_switch]
extends = esp32c3_usb_base
; Arduino as an ESP-IDF component, so sdkconfig.defaults can turn on light sleep
; and BLE modem sleep; the prebuilt Arduino SDK has both off (~80 mA idle).
framework = arduino, espidf
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
extra_scripts =
pre:../tools/reproducible_build.py
post:../tools/stripped_elf_image.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
Expand Down
Loading
Loading