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:
87
AGENTS.md
87
AGENTS.md
@@ -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 ~30–60 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/`)
|
||||
|
||||
|
||||
127
references/discovery_protocol.md
Normal file
127
references/discovery_protocol.md
Normal 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: ~30–60 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**.
|
||||
Reference in New Issue
Block a user