Skip to content
Open
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
25 changes: 18 additions & 7 deletions cmd/radar/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,28 @@ func (cp *capabilitiesProvider) SetLidarDisabled() {
}

// Capabilities returns the current sensor state snapshot.
// Radar is always present as "default". LiDAR appears as "default"
// when enabled (state != "disabled"), otherwise the map is empty.
func (cp *capabilitiesProvider) Capabilities() api.Capabilities {
cp.mu.RLock()
defer cp.mu.RUnlock()

enabled := cp.lidarState != "disabled"
return api.Capabilities{
Radar: true,
Lidar: api.LidarCapability{
Enabled: enabled,
State: cp.lidarState,
caps := api.Capabilities{
Radar: map[string]api.SensorStatus{
"default": {Enabled: true, Status: "receiving"},
},
LidarSweep: cp.lidarSweep,
Lidar: map[string]api.LidarSensorStatus{},
}

if cp.lidarState != "disabled" {
caps.Lidar["default"] = api.LidarSensorStatus{
SensorStatus: api.SensorStatus{
Enabled: true,
Status: cp.lidarState,
},
Sweep: cp.lidarSweep,
}
}

return caps
}
92 changes: 57 additions & 35 deletions cmd/radar/capabilities_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,16 @@ func TestCapabilitiesProvider_DefaultState(t *testing.T) {
cp := newCapabilitiesProvider()
caps := cp.Capabilities()

if !caps.Radar {
t.Error("Expected radar to be true")
radarDefault, ok := caps.Radar["default"]
if !ok {
t.Fatal("Expected radar.default to exist")
}
if caps.Lidar.Enabled {
t.Error("Expected lidar.enabled to be false by default")
if !radarDefault.Enabled {
t.Error("Expected radar.default.enabled to be true")
}
if caps.Lidar.State != "disabled" {
t.Errorf("Expected lidar.state 'disabled', got %q", caps.Lidar.State)
}
if caps.LidarSweep {
t.Error("Expected lidar_sweep to be false by default")

if len(caps.Lidar) != 0 {
t.Errorf("Expected empty lidar map by default, got %d entries", len(caps.Lidar))
}
}

Expand All @@ -29,14 +28,35 @@ func TestCapabilitiesProvider_LidarReady(t *testing.T) {
cp.SetLidarReady(true)
caps := cp.Capabilities()

if !caps.Lidar.Enabled {
t.Error("Expected lidar.enabled to be true after SetLidarReady")
lidarDefault, ok := caps.Lidar["default"]
if !ok {
t.Fatal("Expected lidar.default to exist after SetLidarReady")
}
if caps.Lidar.State != "ready" {
t.Errorf("Expected lidar.state 'ready', got %q", caps.Lidar.State)
if !lidarDefault.Enabled {
t.Error("Expected lidar.default.enabled to be true")
}
if !caps.LidarSweep {
t.Error("Expected lidar_sweep to be true")
if lidarDefault.Status != "ready" {
t.Errorf("Expected lidar.default.status 'ready', got %q", lidarDefault.Status)
}
if !lidarDefault.Sweep {
t.Error("Expected lidar.default.sweep to be true")
}
}

func TestCapabilitiesProvider_LidarReadyNoSweep(t *testing.T) {
cp := newCapabilitiesProvider()
cp.SetLidarReady(false)
caps := cp.Capabilities()

lidarDefault, ok := caps.Lidar["default"]
if !ok {
t.Fatal("Expected lidar.default to exist after SetLidarReady(false)")
}
if lidarDefault.Status != "ready" {
t.Errorf("Expected lidar.default.status 'ready', got %q", lidarDefault.Status)
}
if lidarDefault.Sweep {
t.Error("Expected lidar.default.sweep to be false when sweep disabled")
}
}

Expand All @@ -48,14 +68,18 @@ func TestCapabilitiesProvider_LidarStarting(t *testing.T) {
cp.SetLidarStarting()
caps := cp.Capabilities()

if !caps.Lidar.Enabled {
t.Error("Expected lidar.enabled to be true during starting")
lidarDefault, ok := caps.Lidar["default"]
if !ok {
t.Fatal("Expected lidar.default to exist during starting")
}
if !lidarDefault.Enabled {
t.Error("Expected lidar.default.enabled to be true during starting")
}
if caps.Lidar.State != "starting" {
t.Errorf("Expected lidar.state 'starting', got %q", caps.Lidar.State)
if lidarDefault.Status != "starting" {
t.Errorf("Expected lidar.default.status 'starting', got %q", lidarDefault.Status)
}
if caps.LidarSweep {
t.Error("Expected lidar_sweep to be false during starting")
if lidarDefault.Sweep {
t.Error("Expected lidar.default.sweep to be false during starting")
}
}

Expand All @@ -67,14 +91,18 @@ func TestCapabilitiesProvider_LidarError(t *testing.T) {
cp.SetLidarError()
caps := cp.Capabilities()

if !caps.Lidar.Enabled {
t.Error("Expected lidar.enabled to be true in error state")
lidarDefault, ok := caps.Lidar["default"]
if !ok {
t.Fatal("Expected lidar.default to exist in error state")
}
if caps.Lidar.State != "error" {
t.Errorf("Expected lidar.state 'error', got %q", caps.Lidar.State)
if !lidarDefault.Enabled {
t.Error("Expected lidar.default.enabled to be true in error state")
}
if caps.LidarSweep {
t.Error("Expected lidar_sweep to be false in error state")
if lidarDefault.Status != "error" {
t.Errorf("Expected lidar.default.status 'error', got %q", lidarDefault.Status)
}
if lidarDefault.Sweep {
t.Error("Expected lidar.default.sweep to be false in error state")
}
}

Expand All @@ -84,14 +112,8 @@ func TestCapabilitiesProvider_LidarDisabled(t *testing.T) {
cp.SetLidarDisabled()
caps := cp.Capabilities()

if caps.Lidar.Enabled {
t.Error("Expected lidar.enabled to be false after SetLidarDisabled")
}
if caps.Lidar.State != "disabled" {
t.Errorf("Expected lidar.state 'disabled', got %q", caps.Lidar.State)
}
if caps.LidarSweep {
t.Error("Expected lidar_sweep to be false after disable")
if len(caps.Lidar) != 0 {
t.Errorf("Expected empty lidar map after SetLidarDisabled, got %d entries", len(caps.Lidar))
}
}

Expand Down
219 changes: 219 additions & 0 deletions docs/plans/api-multi-sensor-capabilities-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
# Multi-Sensor Capabilities API Plan

- **Status:** Complete
- **Layers:** API, Frontend, cmd/radar
- **Canonical:** `internal/api/server.go`, `cmd/radar/capabilities.go`

Redesign `/api/capabilities` to support multiple named sensors per class,
future-proofing for deployments with more than one radar or LiDAR unit.

## 1. Current Format

```json
{
"radar": true,
"lidar": { "enabled": false, "state": "disabled" },
"lidar_sweep": false
}
```

Flat structure — one boolean for radar, one object for LiDAR. No room for a
second sensor of either class without a breaking change.

## 2. Target Format

Two top-level keys — `radar` and `lidar` — each a **named object**
(keys are stable, human-assigned sensor names). No `_sensors` suffix so path
access stays light: `$.lidar.hesai.enabled`.

### Single-sensor deployment (today)

```json
{
"radar": {
"default": {
"enabled": true,
"status": "receiving",
"last_reading_at": "2026-03-24T06:45:12Z"
}
},
Comment on lines +33 to +39

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plan/examples and type definitions include a last_reading_at field, but the implemented Go/TypeScript SensorStatus types in this PR do not include it. Either implement last_reading_at end-to-end or remove it from the plan/spec so the documentation matches the shipped API.

Copilot uses AI. Check for mistakes.
"lidar": {}
}
```

### Multi-sensor deployment (future)

```json
{
"radar": {
"ops243_front": {
"enabled": true,
"status": "receiving",
"last_reading_at": "2026-03-24T06:45:12Z"
},
"ops243_rear": {
"enabled": true,
"status": "stale",
"last_reading_at": "2026-03-23T02:11:44Z"
}
},
"lidar": {
"hesai": {
"enabled": true,
"status": "receiving",
"last_reading_at": "2026-03-24T07:38:59Z",
"sweep": true
}
}
}
```

### Why named objects over lists

| Concern | Named objects | Lists |
|---------|--------------|-------|
| Lookup by identity | `caps.radar["ops243_front"]` — O(1), stable key | Must scan by name field |
| Diffing across polls | Keys are stable — trivial Svelte keying | Index shifts on removal |
| Go type | `map[string]SensorStatus` — idiomatic | `[]SensorStatus` + Name field |
| Uniqueness | Structural — keys unique by definition | Must validate no duplicates |
| Ordering | Maps unordered (UI sorts by name) | Ordered but meaningless |

Named objects win on every axis relevant here.

### Field definitions

| Field | Type | Description |
|-------|------|-------------|
| `enabled` | `bool` | Sensor channel was activated at startup |
| `status` | `string` | Runtime state: `disabled`, `starting`, `receiving`, `stale`, `error` |
| `last_reading_at` | `string \| null` | ISO 8601 timestamp of last data received; `null` = never |
| `sweep` | `bool` | (lidar only) Sweep/auto-tuner operational |

### State machine

```
disabled → starting → receiving ⇄ stale
↘ error
```
Comment on lines +87 to +97

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plan defines the status state machine/literals around "receiving"/"stale" and does not mention "ready", but the current implementation/provider emits "ready" for LiDAR. Please reconcile the documented status vocabulary/state machine with what the API actually returns (update the plan or adjust the implementation/tests).

Copilot uses AI. Check for mistakes.

### Empty map semantics

`{}` = no sensors of this class are currently configured or active for this
sensor class. Providers may omit disabled sensors from the map, so a missing
entry is treated the same as a disabled or unconfigured sensor.

## 3. Go Types

```go
// SensorStatus is the per-sensor health snapshot.
type SensorStatus struct {
Enabled bool `json:"enabled"`
Status string `json:"status"`
LastReadingAt *string `json:"last_reading_at"`
}

// LidarSensorStatus extends SensorStatus with lidar-specific fields.
type LidarSensorStatus struct {
SensorStatus
Sweep bool `json:"sweep"`
}

// Capabilities is the JSON shape returned by /api/capabilities.
type Capabilities struct {
Radar map[string]SensorStatus `json:"radar"`
Lidar map[string]LidarSensorStatus `json:"lidar"`
}
```

`map[string]T` marshals to `{}` when empty — clean, no `null` vs `[]` ambiguity.

## 4. Frontend Types

```typescript
interface SensorStatus {
enabled: boolean;
status: 'disabled' | 'starting' | 'receiving' | 'stale' | 'error';
last_reading_at: string | null;
}

interface LidarSensorStatus extends SensorStatus {
sweep: boolean;
}

interface Capabilities {
radar: Record<string, SensorStatus>;
lidar: Record<string, LidarSensorStatus>;
}
```

### Convenience derivations

```typescript
const anyLidarEnabled = derived(capabilities, ($c) =>
Object.values($c.lidar).some(s => s.enabled)
);
```

## 5. Migration Path

| Old field | New location |
|-----------|-------------|
| `radar: true` | `radar.default.enabled = true` |
| `lidar.enabled` | `lidar.default.enabled` (or empty map) |
| `lidar.state` | `lidar.default.status` |
| `lidar_sweep` | `lidar.default.sweep` |

Frontend ships embedded in the binary — both sides change atomically. No
backwards-compatibility shim needed.

## 6. Sensor naming

Keys are stable, human-assigned identifiers. Today the single radar/lidar uses
`"default"`. Multi-sensor deployments use descriptive names:

- `"ops243_front"`, `"ops243_rear"` — by model and position
- `"hesai"`, `"hesai_kerb"` — by model and role

Names come from CLI flags or config at startup. The server rejects duplicate
names.

## 7. Future extensibility

A third sensor class (thermal, ultrasonic) is just another top-level key:

```json
{
"radar": { ... },
"lidar": { ... },
"thermal": { ... }
}
```

Each class gets its own extended status type if it has class-specific fields.

## 8. Implementation Checklist

### Backend (Go)

- [x] Replace `Capabilities`, `LidarCapability` structs in `internal/api/server.go`
with new `SensorStatus`, `LidarSensorStatus`, `Capabilities` types
- [x] Update `showCapabilities` default in `internal/api/server_admin.go`
- [x] Rewrite `capabilitiesProvider` in `cmd/radar/capabilities.go` to populate
`map[string]SensorStatus` / `map[string]LidarSensorStatus`
- [x] Update `internal/api/capabilities_test.go`
- [x] Update `cmd/radar/capabilities_test.go`

### Frontend (Svelte/TypeScript)

- [x] Update `Capabilities`, `LidarCapability` types in `web/src/lib/api.ts`
- [x] Update default capabilities and derived stores in
`web/src/lib/stores/capabilities.ts`
- [x] Update layout gate in `web/src/routes/+layout.svelte` to use
`Object.values($capabilities.lidar).some(s => s.enabled)`
- [x] Update `web/src/lib/stores/capabilities.test.ts`

### Validation

- [x] `make lint-go && make test-go`
- [x] `make lint-web && make test-web`
- [x] `make build-web`
Loading
Loading