refactor: extract Discovery Protocol to references/discovery_protocol.md

- Move full discovery spec (multicast params, beacon payload, sender/listener
  implementations, hub behaviour) out of AGENTS.md into references/.
- Update AGENTS.md to link to the external file.
- Align reference file field names with AGENTS.md canonical schema:
  item_id -> qr_item_id, add device_name / device_id.
This commit is contained in:
fedora-bot
2026-04-26 22:48:46 +03:00
parent 1c06983c39
commit f025efcf28
2 changed files with 128 additions and 86 deletions

View File

@@ -123,92 +123,7 @@ Device registry and config live at `timi.conf` in the repo root (`/home/user/tim
SciCam devices announce their presence via **UDP multicast** so hubs and clients can auto-discover them without hard-coding IP addresses.
### Multicast Parameters
| Parameter | Value |
|-----------|-------|
| Group | `239.255.0.1` |
| Port | `9876` |
| Transport | UDP |
| Interval | every **5 seconds** while the app/device is running |
### Beacon Payload (JSON)
```json
{
"name": "Pixel 7 Lab-Cam",
"ip": "192.168.1.101",
"api_port": 8080,
"qr_item_id": "ITEM-001",
"device_name": "Pixel 7",
"device_id": "a1b2c3d4e5f67890",
"timestamp": 1714041600
}
```
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Human-readable device identifier. Android defaults to `Build.MODEL`; custom clients may use hostname, config value, etc. |
| `ip` | string | The device's current local IP address (the one the HTTP API is bound to). |
| `api_port` | integer | The port the SciCam HTTP API is listening on. Default: `8080`. |
| `qr_item_id` | string | The current QR item ID as returned by `GET /status`. May be empty if unset. |
| `device_name` | string | Human-readable device model/name. |
| `device_id` | string | Unique persistent device identifier (ANDROID_ID on Android, persisted UUID on GoPro). |
| `timestamp` | integer | Unix epoch seconds. Used by listeners to detect stale beacons. |
### Sender Implementation Notes
- **Language-agnostic** — any language with a UDP socket can send a multicast datagram.
- The beacon should be a **single UDP datagram** (typically < 1 KB).
- Senders should gracefully handle transient failures (e.g. wrong subnet, temporarily no WiFi) — log and retry on the next interval.
#### Android
- Acquire a `WifiManager.MulticastLock` before sending; release it on shutdown.
- Requires `ACCESS_WIFI_STATE` and `CHANGE_WIFI_MULTICAST_STATE` permissions.
- Start the beacon thread after the HTTP server starts; stop it in `onDestroy`.
#### Python
```python
import socket, json, time
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
while running:
payload = json.dumps({"name": ..., "ip": ..., "api_port": 8080, "qr_item_id": ..., "device_name": ..., "device_id": ..., "timestamp": int(time.time())})
sock.sendto(payload.encode(), ("239.255.0.1", 9876))
time.sleep(5)
```
#### Go
```go
addr, _ := net.ResolveUDPAddr("udp4", "239.255.0.1:9876")
conn, _ := net.DialUDP("udp4", nil, addr)
for {
conn.Write(payloadBytes)
time.Sleep(5 * time.Second)
}
```
#### TypeScript / Node.js
```typescript
import dgram from "dgram";
const sock = dgram.createSocket("udp4");
setInterval(() => {
sock.send(JSON.stringify(payload), 9876, "239.255.0.1");
}, 5000);
```
### Listener (Hub) Implementation Notes
- Bind a UDP socket to `0.0.0.0:9876`.
- Join the multicast group with `IP_ADD_MEMBERSHIP` / `join_multicast_v4("239.255.0.1", "0.0.0.0")`.
- Parse each incoming datagram as JSON. On a valid beacon:
1. Look up the device by `ip`.
2. If **new**: auto-register it, mark `auto_sync = true`, persist to the registry, and immediately poll `GET /status`.
3. If **known**: update `last_seen` (and `name`/`item_id` if changed).
4. If no beacon is received for ~3060 s, mark the device offline.
- The IP in the beacon is what the listener should use for all subsequent HTTP API calls.
See [`references/discovery_protocol.md`](references/discovery_protocol.md) for the full specification, verified implementation snippets, and hub behaviour.
## GoPro Client (`clients/gopro-scicam/`)

View File

@@ -0,0 +1,127 @@
# SciCam Discovery Protocol
Single source of truth for the UDP multicast auto-discovery used by SciCam devices and hubs.
## Parameters
| Parameter | Value |
|-----------|-------|
| Group | `239.255.0.1` |
| Port | `9876` |
| Transport | UDP |
| Interval | every **5 seconds** while running |
| Payload max | < 1 KB (single datagram) |
| Multicast TTL | 2 |
## Payload (JSON)
```json
{
"name": "Pixel 7 Lab-Cam",
"ip": "192.168.1.101",
"api_port": 8080,
"qr_item_id": "ITEM-001",
"device_name": "Pixel 7",
"device_id": "a1b2c3d4e5f67890",
"timestamp": 1714041600
}
```
| Field | Type | Required | Source of truth |
|-------|------|----------|-----------------|
| `name` | string | yes | Human identifier (e.g. `Build.MODEL` on Android, hostname, or config) |
| `ip` | string | yes | The local IP the HTTP API is bound to |
| `api_port` | integer | yes | HTTP API port. Default `8080` |
| `qr_item_id` | string | no | Current `qr_item_id` from `GET /status`; empty string if unset |
| `device_name` | string | yes | Human-readable device model/name |
| `device_id` | string | yes | Unique persistent device identifier |
| `timestamp` | integer | yes | Unix epoch seconds |
## Verified Implementations
Sender and listener code below is extracted from the working sources in this repo.
### Sender — Kotlin (Android)
Source: `scicam/app/src/main/java/com/scicam/logger/PresenceBeacon.kt`
Key: acquire a `WifiManager.MulticastLock` before sending; release on shutdown.
Requires `ACCESS_WIFI_STATE` + `CHANGE_WIFI_MULTICAST_STATE` permissions.
```kotlin
val payload = JSONObject().apply {
put("name", Build.MODEL)
put("ip", getIpAddress())
put("api_port", 8080)
put("qr_item_id", itemId)
put("timestamp", System.currentTimeMillis() / 1000)
}.toString()
val packet = DatagramPacket(
payload.toByteArray(Charsets.UTF_8),
payload.length,
InetAddress.getByName("239.255.0.1"), 9876
)
socket.send(packet)
```
### Sender — Python
Source: `clients/gopro-scicam/app.py` (`_beacon_worker`)
```python
import socket, json, time
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
addr = ("239.255.0.1", 9876)
while running:
payload = json.dumps({
"name": name,
"ip": host_ip,
"api_port": api_port,
"qr_item_id": item_id,
"timestamp": int(time.time()),
})
sock.sendto(payload.encode(), addr)
time.sleep(5)
```
### Listener — Rust (async)
Source: `dashboard/src/discovery.rs`
```rust
use tokio::net::UdpSocket;
let addr: SocketAddr = ([0, 0, 0, 0], 9876).into();
let socket = UdpSocket::bind(addr).await?;
socket.join_multicast_v4(
"239.255.0.1".parse()?,
"0.0.0.0".parse()?,
)?;
let mut buf = [0u8; 1024];
loop {
let (len, _from) = socket.recv_from(&mut buf).await?;
let payload = serde_json::from_str::<BeaconPayload>(&String::from_utf8_lossy(&buf[..len]))?;
// Payload fields: name, ip, api_port, qr_item_id
}
```
## Hub Behaviour
On every valid beacon:
1. Look up device by `ip`.
2. **New** → auto-register, set `auto_sync = true`, persist, immediately poll `GET /status`.
3. **Known** → update `last_seen` (and `name`/`qr_item_id`/`api_port` if changed).
4. Stale threshold: ~3060 s without a beacon marks device offline.
## Gotchas
- **Bind to `0.0.0.0:9876`** — not the multicast address itself.
- **Join `IP_ADD_MEMBERSHIP`** / `join_multicast_v4` on the socket before reading.
- **Handle malformed datagrams silently** — any UDP packet on this port may be noise.
- **The IP in the beacon is the one used for all subsequent HTTP API calls**.