Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
84309a7
Fix WiFi CRUD for hidden networks, add stepper wizard and advanced mode
hhvrc Mar 3, 2026
e69aed1
Merge remote-tracking branch 'origin/develop' into feat/wifi-crud-fix…
hhvrc Mar 6, 2026
05c6666
Fix errors
hhvrc Mar 6, 2026
1cf5026
feat(captive-portal): migrate simple WS commands to HTTP REST endpoints
hhvrc Mar 6, 2026
fd0c837
fix(wifi): use creds.id instead of undeclared credsId in Forget()
hhvrc Mar 6, 2026
a36fa99
feat(captive-portal): migrate all LocalToHub WS commands to HTTP REST
hhvrc Mar 6, 2026
0f9f992
Document necessary changes
hhvrc Mar 6, 2026
158bedb
Update schemas
hhvrc Mar 9, 2026
a3b204e
Merge branch 'develop' into feat/wifi-crud-fixes-and-advanced-mode
hhvrc Mar 23, 2026
6a9d58d
Massive improvements
hhvrc Mar 23, 2026
4dfc18a
More progress
hhvrc Mar 23, 2026
21accc8
More progress
hhvrc Mar 23, 2026
673d6ae
Add shocker test step
hhvrc Mar 23, 2026
cd21aa8
Clean up Advanced tab
hhvrc Mar 23, 2026
2979965
Update Advanced.svelte
hhvrc Mar 23, 2026
c98231c
docs: update CLAUDE.md with full architecture docs, update FLATBUFFER…
hhvrc Mar 23, 2026
69e3de9
feat(wifi): broadcast WifiGotIpEvent on DHCP, show IP toast in frontend
hhvrc Mar 23, 2026
334a91e
fix(frontend): restore eslint ignores for generated and vendored code
hhvrc Mar 24, 2026
2e6f9ea
fix: null checks on cJSON, response validation in frontend, config re…
hhvrc Mar 24, 2026
1959c0e
feat(security): add rate limiting to account link REST endpoint
hhvrc Mar 24, 2026
cee53db
Remove disconnect button from saved networks list
hhvrc Mar 24, 2026
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
1 change: 1 addition & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[submodule "schemas"]
path = schemas
url = https://github.com/OpenShock/flatbuffers-schemas
branch = local-comms-revamp
82 changes: 75 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,26 @@ pio run -e Wemos-D1-Mini-ESP32
# Build all board variants
pio run

# Upload to connected board
# Upload firmware to connected board
pio run -e Wemos-D1-Mini-ESP32 -t upload

# Upload LittleFS filesystem (frontend) to connected board
pio run -e Wemos-D1-Mini-ESP32 -t uploadfs

# Serial monitor (115200 baud)
pio device monitor

# Static analysis
pio check -e ci-build

# Regenerate FlatBuffers headers from schemas/ submodule
python scripts/generate_schemas.py

# Frontend (from frontend/ directory)
pnpm install
pnpm run build
pnpm run lint # Prettier + ESLint
pnpm run check # SvelteKit type checking
pnpm run build # Production build (single-file HTML)
pnpm run lint # Prettier + ESLint
pnpm run check # Svelte type checking (svelte-check)
```

Board environments are defined in `platformio.ini`. Common ones: `Wemos-D1-Mini-ESP32`, `Wemos-Lolin-S3`, `OpenShock-Core-V2`, `Seeed-Xiao-ESP32S3`, `ci-build` (for analysis).
Expand Down Expand Up @@ -66,41 +72,103 @@ Source files define `const char* const TAG = "ModuleName";` at the top for the l

### Key Subsystems
- **Config** (`src/config/`, `include/config/`) — Thread-safe persistent storage on LittleFS. Uses `ReadWriteMutex` with RAII locks. Dual format: JSON (REST API) and FlatBuffers (binary storage). Sub-configs: WiFi, Backend, RF, OTA, Serial, CaptivePortal, EStop.
- **GatewayConnectionManager** / **GatewayClient** — WebSocket connection to cloud backend. JSON for text messages, FlatBuffers for binary. Rate limiting on auth failures (5 min cooldown).
- **GatewayConnectionManager** / **GatewayClient** — WebSocket connection to cloud backend. JSON for text messages, FlatBuffers for binary. Rate limiting on auth failures (5 min cooldown). Broadcasts `AccountLinkStatusEvent` when auth token is validated.
- **CommandHandler** — Routes shocker commands to RF transmission. Protocols: Petrainer, CaiXianlin, etc.
- **Radio** (`src/radio/rmt/`) — Uses ESP32 RMT peripheral for precise 433 MHz signal timing.
- **CaptivePortal** — RFC-8908 compliant. Serves the SvelteKit frontend for device configuration.
- **CaptivePortal** — RFC-8908 compliant. Serves the SvelteKit frontend for device configuration. REST API endpoints for WiFi, account, GPIO, and OTA configuration. WebSocket for real-time events (scan results, network changes, connection status).
- **OtaUpdateManager** — Partition-based A/B updates with validation and rollback.
- **WiFiManager** / **WiFiScanManager** — Connection management with credential rotation.
- **WiFiManager** / **WiFiScanManager** — Connection management with SSID-based connecting (no BSSID required). Supports hidden networks. Auth mode pinning prevents evil twin attacks. Optional BSSID pinning for advanced users.
- **Serial** (`src/serial/command_handlers/`) — 17 UART commands for debugging/configuration.
- **TaskUtils** (`src/util/TaskUtils.cpp`) — FreeRTOS task creation helpers with core affinity. `StopTask()` for cooperative task shutdown with bounded timeout and force-kill fallback.

### Captive Portal Architecture
The captive portal has two interfaces:
- **REST API** (`/api/*`) — HTTP endpoints for configuration actions (WiFi save/forget/connect, account link/unlink, GPIO pin changes, OTA settings). Responses use HTTP status codes for success/failure with JSON error bodies `{"error":"..."}`. Success with no data returns 200 with empty body.
- **WebSocket** (port 81) — FlatBuffers binary protocol for real-time events (network discovery, scan status, connection/disconnection, account link status) and shocker commands (`ShockerCommandList`).

Portal lifecycle:
- Opens when device is not fully configured (no WiFi credentials or no auth token)
- Stays open during setup regardless of WiFi/gateway connection state
- Closes via `/api/portal/close` (soft signal — waits for gateway connection) or `ForceClose()` (immediate)
- Auto-closes AP after 5 minutes with no WebSocket clients when gateway is connected
- 30-second startup grace period for already-configured devices

### Message Flow
```
Gateway WebSocket → message_handlers/websocket/gateway/ → command dispatch
Local WebSocket → message_handlers/websocket/local/ → config/control
Serial UART → serial/command_handlers/ → debug/config
REST HTTP → captiveportal/CaptivePortalInstance → config/control
```

Shocker command processing is shared between gateway and local WebSocket handlers via `message_handlers/ShockerCommandList.cpp`.

Serialization adapters in `src/serialization/`: `JsonAPI`, `JsonSerial`, `WSGateway`, `WSLocal`.

### Frontend Structure (`frontend/`)
Svelte 5 SPA built as a single-file HTML (vite-plugin-singlefile) and served from LittleFS.

```
src/
App.svelte — Root: routing between Landing, Guided, Advanced, Success views
lib/
views/ — Page-level components (Landing, Guided, Advanced, Success)
components/ — Reusable UI components
steps/ — Wizard step components (HardwareStep, WiFiStep, TestStep, AccountStep)
sections/ — Advanced mode section components
ui/ — shadcn-svelte primitives (button, input, dialog, stepper, etc.)
Layout/ — Header, Footer
stores/ — Svelte 5 reactive state (HubStateStore, ViewModeStore, ColorScheme)
MessageHandlers/ — WebSocket binary message handlers
api.ts — REST API client functions
_fbs/ — Generated FlatBuffers TypeScript bindings
mappers/ — Config data mapping from FlatBuffers to TS types
```

State management uses Svelte 5 `$state`/`$derived` runes. Note: `Map` mutations require reassignment for reactivity (create a new Map).

### Threading
FreeRTOS tasks with explicit core affinity. `TaskUtils::TaskCreateExpensive()` avoids the WiFi core. Thread safety via `SimpleMutex` (binary semaphore) and `ReadWriteMutex` with `ScopedReadLock`/`ScopedWriteLock` RAII guards.

### GPIO Safety
`include/Chipset.h` defines unsafe pins (strapping, UART, SPI flash) per ESP32 variant. Runtime validation prevents configuring dangerous pins. Board-specific GPIO assignments are compile-time defines (`OPENSHOCK_LED_GPIO`, `OPENSHOCK_RF_TX_GPIO`, `OPENSHOCK_ESTOP_PIN`).

## FlatBuffers Schemas

Schemas live in the `schemas/` submodule (tracks `github.com/OpenShock/flatbuffers-schemas`). The sibling repo at `../flatbuffers-schemas/` is for making schema changes — edit there, copy to the submodule, then regenerate:

```bash
cp ../flatbuffers-schemas/HubConfig.fbs schemas/
python scripts/generate_schemas.py
```

Generated C++ headers go to `include/serialization/_fbs/`, TypeScript bindings to `frontend/src/lib/_fbs/`.

Key schema files:
- `HubConfig.fbs` — Persistent configuration (WiFi credentials with auth mode/BSSID pinning, RF, EStop, OTA)
- `HubToLocalMessage.fbs` — Events sent from firmware to frontend (ReadyMessage, WiFi events, AccountLinkStatusEvent)
- `LocalToHubMessage.fbs` — Commands from frontend to firmware (ShockerCommandList)
- `GatewayToHubMessage.fbs` — Commands from cloud backend to firmware
- `Common/ShockerCommand.fbs` — Shared shocker command types used by both local and gateway

## Migration Goals

**Arduino → ESP-IDF**: We are actively migrating away from the Arduino framework toward native ESP-IDF. When touching code that uses Arduino APIs, prefer replacing them with ESP-IDF equivalents where practical. Avoid introducing new Arduino dependencies.

**Per-board → Per-chip builds**: The current per-board build matrix (each board is a separate PlatformIO environment) is being phased out in favor of per-chip compiles (ESP32, ESP32-S2, ESP32-S3, ESP32-C3) with runtime or config-time pin assignment. This reduces CI/CD load and simplifies maintenance. When making changes, prefer chip-level abstractions over board-specific `#ifdef`s and avoid adding new per-board environments.

**WS → REST**: Local WebSocket commands are being migrated to HTTP REST endpoints on the captive portal. The WebSocket channel is reserved for real-time events and shocker commands (binary FlatBuffers). New configuration endpoints should be REST, not WS.

## Conventions

- C++20 with `-fno-exceptions` — use return values for error handling, not try/catch
- Logging: `OS_LOGI(TAG, "format %s", arg)` — levels: `OS_LOGV/D/I/W/E`, panics: `OS_PANIC/OS_PANIC_OTA/OS_PANIC_INSTANT`
- Macros from `Common.h`: `DISABLE_COPY(T)`, `DISABLE_MOVE(T)`, `DISABLE_DEFAULT(T)`
- HTTP content types: use `HTTP::ContentType::JSON`, `TextPlain`, `TextHTML` from `include/http/ContentTypes.h`
- REST error responses: `{"error":"ErrorCode"}` with appropriate HTTP status. Success with no data: 200 empty body. Use `static const char*` constants for repeated JSON error strings.
- Dynamic JSON in REST handlers: use `cJSON` (ESP-IDF built-in), not Arduino `String` concatenation
- Environment variables embedded at build time via `scripts/embed_env_vars.py` (see `.env`, `.env.development`, `.env.production`)
- FlatBuffers schemas live in `schemas/` submodule; generated headers go to `include/serialization/_fbs/`
- Custom board JSON definitions in `boards/`, partition tables in `chips/`
- Libraries are pinned to specific git commits in `platformio.ini` `lib_deps`
- Frontend: Svelte 5 runes (`$state`, `$derived`, `$effect`). Avoid `<script module>` unless exporting types. Use `Component<any>` from `svelte` for dynamic component types (not lucide's `Icon` type).
32 changes: 32 additions & 0 deletions FLATBUFFERS_CHANGES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# FlatBuffers Schema Changes

**Repository:** https://github.com/OpenShock/flatbuffers-schemas
**Branch:** `local-comms-revamp`

These schema changes have been applied to the submodule and are tracked by this branch. They are breaking changes that accompany the WS-to-REST migration.

## Applied Changes

### `HubToLocalMessage.fbs`
- Removed all command result types (`AccountLinkCommandResult`, `SetRfTxPinCommandResult`, `SetEstopPinCommandResult`, `SetEstopEnabledCommandResult`, `SetGPIOResultCode`, `AccountLinkResultCode`)
- Removed all WS command types from `HubToLocalMessagePayload` union that were migrated to REST
- Added `AccountLinkStatusEvent` — broadcast when auth token is validated on gateway connect
- Added `has_standardized_pins` field to `ReadyMessage`

### `LocalToHubMessage.fbs`
- Removed all command types except `Common_ShockerCommandList` (WiFi, OTA, Account, GPIO commands moved to REST)
- `ShockerCommandList` moved to `Common` namespace, shared between gateway and local

### `HubConfig.fbs`
- Added `MacAddress` struct (6-byte fixed-size array)
- Added `auth_mode` (WifiAuthMode enum) and `bssid` (MacAddress) fields to `WiFiCredentials` for security pinning

### `Common/ShockerCommand.fbs` (new)
- Extracted `ShockerCommand` and `ShockerCommandList` tables into shared Common namespace

### `GatewayToHubMessage.fbs`
- Updated `ShockerCommandList` reference to `Common_ShockerCommandList`

## Pending
- Push `local-comms-revamp` branch to schemas repo and create PR
- After merge, update submodule pointer in firmware repo
6 changes: 6 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Copy this file to .env and adjust as needed.

# When the frontend is served locally (localhost / 127.0.0.1), HTTP and WebSocket
# requests are redirected to this host so they reach the real device.
# Defaults to 4.3.2.1 if not set.
VITE_LOCAL_DEVICE_HOST=4.3.2.1
20 changes: 20 additions & 0 deletions frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ export default defineConfig(
},
},
},
{
files: ['**/*.svelte'],

languageOptions: {
parserOptions: {
parser: ts.parser,
ecmaVersion: 2020,
},
},
},
Comment thread
hhvrc marked this conversation as resolved.
{
files: ['**/*.svelte.ts', '**/*.svelte.js'],

languageOptions: {
parser: ts.parser,
parserOptions: {
ecmaVersion: 2020,
},
},
},
{
ignores: [
'.DS_Store',
Expand Down
96 changes: 24 additions & 72 deletions frontend/src/App.svelte
Original file line number Diff line number Diff line change
@@ -1,88 +1,40 @@
<script lang="ts">
import { SerializeAccountLinkCommand } from '$lib/Serializers/AccountLinkCommand';
import { SerializeSetEstopPinCommand } from '$lib/Serializers/SetEstopPinCommand';
import { SerializeSetRfTxPinCommand } from '$lib/Serializers/SetRfTxPinCommand';
import { WebSocketClient } from '$lib/WebSocketClient';
import GpioPinSelector from '$lib/components/GpioPinSelector.svelte';
import Footer from '$lib/components/Layout/Footer.svelte';
import Header from '$lib/components/Layout/Header.svelte';
import WiFiList from '$lib/components/WiFiList.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import Landing from '$lib/views/Landing.svelte';
import Guided from '$lib/views/Guided.svelte';
import Advanced from '$lib/views/Advanced.svelte';
import Success from '$lib/views/Success.svelte';
import { Toaster } from '$lib/components/ui/sonner';
import { hubState, initializeDarkModeStore } from '$lib/stores';
import { initializeDarkModeStore, ViewModeStore } from '$lib/stores';
import { closePortal } from '$lib/portalClose';
import { fetchBoardInfo } from '$lib/api';
import { onMount } from 'svelte';

onMount(() => {
initializeDarkModeStore();
fetchBoardInfo();
WebSocketClient.Instance.Connect();
});

function isValidLinkCode(str: string) {
if (typeof str != 'string') return false;

for (var i = 0; i < str.length; i++) {
if (str[i] < '0' || str[i] > '9') return false;
}

return true;
}

let linkCode: string = $state('');
let linkCodeValid = $derived(isValidLinkCode(linkCode));

function linkAccount() {
if (!linkCodeValid) return;
const data = SerializeAccountLinkCommand(linkCode!);
WebSocketClient.Instance.Send(data);
}
let showSuccess = $state(false);
</script>

<Toaster position="top-center" />

<div class="flex min-h-screen flex-col">
<Header />

<div class="flex flex-1 flex-col items-center justify-center px-2">
<div class="w-full max-w-md flex-col space-y-5">
<WiFiList />

<div class="flex flex-col space-y-2">
<Label for="account-link-code" class="scroll-m-20 text-xl font-semibold tracking-tight">
Account Linking
</Label>
<div class="flex space-x-2">
<Input
class={linkCodeValid ? '' : 'input-error'}
type="text"
id="account-link-code"
inputmode="numeric"
pattern="[0-9]*"
placeholder="Link Code"
bind:value={linkCode}
/>
<Button onclick={linkAccount} disabled={!linkCodeValid || linkCode.length < 6}>
Link
</Button>
</div>
</div>

<GpioPinSelector
name="RF TX Pin"
currentPin={hubState.config?.rf?.txPin ?? null}
serializer={SerializeSetRfTxPinCommand}
/>

<!-- TODO: Add EStop Enable/Disable toggle -->

<GpioPinSelector
name="EStop Pin"
currentPin={hubState.config?.estop?.gpioPin ?? null}
serializer={SerializeSetEstopPinCommand}
/>
</div>
{#if showSuccess}
<Success onClose={closePortal} />
{:else}
<div class="flex min-h-screen flex-col">
{#if $ViewModeStore === 'landing'}
<Landing />
{:else}
<Header />
{#if $ViewModeStore === 'advanced'}
<Advanced />
{:else}
<Guided onComplete={() => (showSuccess = true)} />
{/if}
{/if}
</div>

<Footer />
</div>
{/if}
52 changes: 38 additions & 14 deletions frontend/src/lib/MessageHandlers/WifiNetworkEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,33 +61,57 @@ function handleLostEvent(fbsNetwork: FbsWifiNetwork) {
}
function handleSavedEvent(fbsNetwork: FbsWifiNetwork) {
const ssid = fbsNetwork.ssid();
const bssid = fbsNetwork.bssid();

if (!ssid || !bssid) {
if (!ssid) {
console.warn('[WS] Received invalid network saved event');
return;
}

hubState.updateWifiNetwork(bssid, (network) => {
network.saved = true;
return network;
});
const bssid = fbsNetwork.bssid();
if (bssid) {
hubState.updateWifiNetwork(bssid, (network) => {
network.saved = true;
return network;
});
}

// Update config credentials so savedOnlySSIDs stays in sync
if (hubState.config && !hubState.config.wifi.credentials.some((c) => c.ssid === ssid)) {
hubState.config = {
...hubState.config,
wifi: {
...hubState.config.wifi,
credentials: [...hubState.config.wifi.credentials, { id: 0, ssid, password: null }],
},
};
}

toast.success('WiFi network saved: ' + ssid);
}
function handleRemovedEvent(fbsNetwork: FbsWifiNetwork) {
const ssid = fbsNetwork.ssid();
const bssid = fbsNetwork.bssid();

if (!ssid || !bssid) {
if (!ssid) {
console.warn('[WS] Received invalid network forgotten event');
return;
}

hubState.updateWifiNetwork(bssid, (network) => {
network.saved = false;
return network;
});
const bssid = fbsNetwork.bssid();
if (bssid) {
hubState.updateWifiNetwork(bssid, (network) => {
network.saved = false;
return network;
});
}

// Update config credentials so savedOnlySSIDs stays in sync
if (hubState.config) {
hubState.config = {
...hubState.config,
wifi: {
...hubState.config.wifi,
credentials: hubState.config.wifi.credentials.filter((c) => c.ssid !== ssid),
},
};
}

toast.success('WiFi network forgotten: ' + ssid);
}
Expand Down
Loading
Loading