diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d65b5e..bf2f2b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,7 +140,8 @@ jobs: python3 -m pytest -q test-data/test_tempcomp.py test-data/test_hive_tempcomp.py \ test-data/test_hiveheart_fft.py \ test-data/test_mqtt_ha_discovery.py \ - test-data/test_relay_ota_gate.py test-data/test_publish.py + test-data/test_relay_ota_gate.py test-data/test_publish.py \ + test-data/test_channel_links.py # The mock server ships verbatim copies of the production insights engine # and its FFT helper: its Dockerfile build context is test-data/mock-server, # so it cannot reach ../../server and the files have to be duplicated. Left diff --git a/docs/api.md b/docs/api.md index c5d9ff2..4401c5f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -111,7 +111,7 @@ without this field remain supported but cannot get this delivery guarantee. **Auth:** `X-API-Key` (per-device key — registered on first contact, enforced thereafter) -> **Multi-hive payload (firmware v0.20.0+).** Devices now report up to 16 hives in +> **Multi-hive payload (firmware v0.20.0+).** Devices now report up to 18 hives in > a `hives` array (see below); the flat `scale_1/2_*` / `hive_1/2_*` fields are the > legacy two-hive form and remain accepted. The server stores per-hive data in the > `hive_readings` table, mirrors hives 1–2 onto the legacy columns, and on read @@ -830,16 +830,33 @@ Readings, config and channel names are untouched; only the pairing is undone. ### `GET /api/v1/app/devices/{device_id}/channels` -Returns channel display names for scale 1 and scale 2. +Returns the hive display names and HivePal hive links for every hive index. +`scale_1/2_display_name` are the legacy two-hive fields; `names` and `hive_ids` +are keyed by hive index (`"1"`–`"18"`). + +```json +{ + "scale_1_display_name": "Buckfast colony", + "scale_2_display_name": "Carnica colony", + "names": { "1": "Buckfast colony", "2": "Carnica colony", "5": "Nuc 3" }, + "hive_ids": { "1": "3c1f…", "5": "a07e…" } +} +``` + +`GET /api/v1/app/devices` carries the same `names` and `hive_ids` maps inside +each device's `channels` object. ### `PATCH /api/v1/app/devices/{device_id}/channels` -Updates channel display names. Requires `owner` or `admin`. +Updates hive display names and/or links. Requires `owner` or `admin`. Only the +indexes you send are touched. A name of `null` leaves it alone and `""` clears it. +A `hive_ids` entry of `""` or `null` removes the link. HiveHub never resolves +`hive_ids`; it stores the HivePal hive id so the mapping survives a rename. ```json { - "scale_1_display_name": "Buckfast colony", - "scale_2_display_name": "Carnica colony" + "names": { "1": "Buckfast colony", "3": "Nuc 3" }, + "hive_ids": { "1": "3c1f…", "3": "" } } ``` @@ -1145,10 +1162,23 @@ owner has not approved it yet, so the device will **not** auto-flash until they "latest_is_official": false, "approved_version": null, "update_available": true, - "pending_approval": true + "pending_approval": true, + "device_board": "esp32", + "other_board_releases": [], + "hiveinside_latest_version": "1.4.0", + "hiveinside_relays": { + "3": { "status": "running", "message": null, "version": "1.4.0", + "created_at": "2026-09-01T10:00:00+00:00", "completed_at": null } + }, + "beecounter_latest_version": "0.7.1", + "beecounter_relays": {} } ``` +`hiveinside_relays` / `beecounter_relays` hold the last relay attempt per hive +slot (the same data the local dashboard's firmware card shows), so a queued, +running or failed node update is visible to HivePal too. + ### `POST /api/v1/app/devices/{device_id}/firmware/approve` Approves the latest available `hivescale` firmware so this device may install it — @@ -1279,6 +1309,42 @@ recurrence of the same detector starts a new row. } ``` +### `POST /api/v1/app/devices/{device_id}/provisioning/start` + +Opens the hub's setup access point remotely. It queues a `start_provisioning` +command, and the hub opens its AP after its next check-in, so up to one send +interval later. Requires `owner` or `admin`. + +```json +{ "status": "pending", "id": 91, "command_type": "start_provisioning", "payload": {} } +``` + +### `GET /api/v1/app/devices/{device_id}/export/measurements/summary?start_at=&end_at=` + +Returns how many readings an export of this device and period would contain: +`{devices:[{device_id, measurements, first_measured_at, last_measured_at}], +total_measurements, filename}`. Requires `owner` or `admin`. + +### `GET /api/v1/app/devices/{device_id}/export/measurements?hive=&start_at=&end_at=` + +Streams this device's readings as an SD-style NDJSON backup, the same format as +the local dashboard download. `hive` may be repeated to limit the per-hive fields +written. The file imports straight back through the SD upload. Requires `owner` +or `admin`. + +### `POST /api/v1/app/devices/{device_id}/measurements/delete` + +Deletes this device's readings in `[start_at, end_at]`. It is gated by the +device's claim code as a second factor, exactly like the local dashboard. +Owner only. + +```json +{ "start_at": "2026-09-01T00:00:00Z", "end_at": "2026-09-01T00:10:00Z", "claim_code": "ABCD-1234" } +``` + +Response: `{ "status": "ok", "device_id": "...", "deleted": 3 }`. A wrong claim +code returns `403`. + --- ## Hive audio recordings @@ -1306,7 +1372,7 @@ to upload. | Query | Default | Meaning | |---|---|---| -| `slot` | `1` | hive index, 1–16 — must be a hive with a HiveInside | +| `slot` | `1` | hive index, 1–18 — must be a hive with a HiveInside | | `duration` | `0.0` | seconds, 0–60. `0` is open-ended; the node still stops at its own 60-second cap | | `gain_db` | `0` | −20…+20, applied on the node before transmission | diff --git a/docs/insights.md b/docs/insights.md index 68aae41..0e8e2c5 100644 --- a/docs/insights.md +++ b/docs/insights.md @@ -7,8 +7,9 @@ time-series your HiveHub device already records: weight (per channel), internal hive temperature (per channel), and ambient temperature/humidity. Insights are computed in [`server/insights.py`](../server/insights.py) and -exposed through `/api/v1/devices/{device_id}/insights`. The HivePal frontend -displays them in the **HiveScale → Insights** card. +exposed to HivePal through `/api/v1/app/devices/{device_id}/insights` (plus +`/insights/summary` and `/insights/history`). HivePal shows them on its +**HiveHub** page and emails new or escalating alerts to the device's members. This document is the authoritative reference for **what is detected**, **how**, **when an alert is raised**, and **where the rule comes from**. diff --git a/server/app_api.py b/server/app_api.py index 1067312..e877a6d 100644 --- a/server/app_api.py +++ b/server/app_api.py @@ -16,7 +16,13 @@ recording_wav, request_recording, ) -from commands import check_relay_slot, create_command, queue_relay_firmware_update +from commands import ( + check_relay_slot, + create_command, + latest_beecounter_relays, + latest_hiveinside_relays, + queue_relay_firmware_update, +) from db import get_conn, hash_claim_code from devices import ( apply_device_channels, @@ -29,6 +35,8 @@ from firmware import ( get_approved_firmware_version, get_device_board, + latest_beecounter_release, + latest_hiveinside_release, latest_release_for_owner, other_board_releases, parse_version, @@ -40,12 +48,18 @@ execute_measurement_query, serialize_measurements, ) +from local_dashboard import ( + local_delete_measurements, + local_export_measurements, + local_export_summary, +) from schemas import ( AppCalibrationModeStartIn, AppDeviceConfigUpdate, ClaimDeviceIn, DeviceChannelsUpdateIn, DeviceCommandIn, + MeasurementDeleteIn, ShareDeviceIn, TempCoefficientFitIn, ) @@ -140,13 +154,17 @@ def list_devices(user_id: str = Depends(require_user_id)): rows = cur.fetchall() device_ids = [r[0] for r in rows] channels: dict[str, dict] = {} + hive_links: dict[str, dict[str, str]] = {} if device_ids: cur.execute( - "SELECT device_id, channel_number, name FROM device_channels WHERE device_id = ANY(%s);", + "SELECT device_id, channel_number, name, hivepal_hive_id " + "FROM device_channels WHERE device_id = ANY(%s);", (device_ids,), ) for ch in cur.fetchall(): channels.setdefault(ch[0], {})[ch[1]] = ch[2] + if ch[3]: + hive_links.setdefault(ch[0], {})[str(ch[1])] = ch[3] return [ { "device_id": r[0], @@ -165,6 +183,8 @@ def list_devices(user_id: str = Depends(require_user_id)): for num, name in channels.get(r[0], {}).items() if name is not None }, + # HivePal hive each index is linked to (index "1".."18" -> id). + "hive_ids": hive_links.get(r[0], {}), }, } for r in rows @@ -547,6 +567,10 @@ def firmware_status_from_app(device_id: str, user_id: str = Depends(require_user current_version = row[0] owner_id = get_device_owner_id(device_id) + # Same relay metadata the local dashboard shows, so HivePal can tell a + # queued / relaying / failed node update apart from one that never started. + hiveinside_release = latest_hiveinside_release(owner_id) + beecounter_release = latest_beecounter_release(owner_id) # Resolve the latest release for this device's reported board so a C6 device is # never shown an esp32-only build as "available" (and vice versa). Falls back to # board-agnostic when the device has not yet checked in with the board param. @@ -577,6 +601,10 @@ def firmware_status_from_app(device_id: str, user_id: str = Depends(require_user "other_board_releases": other_board_releases( "hivescale", owner_id, device_board, current_version ), + "hiveinside_latest_version": hiveinside_release[0] if hiveinside_release else None, + "hiveinside_relays": latest_hiveinside_relays(device_id), + "beecounter_latest_version": beecounter_release[0] if beecounter_release else None, + "beecounter_relays": latest_beecounter_relays(device_id), } @@ -727,3 +755,84 @@ def queue_beecounter_update_from_app( "version": result.get("version"), "current_version": result.get("current_version"), } + + +@router.post( + "/api/v1/app/devices/{device_id}/provisioning/start", + dependencies=[Depends(require_hivepal_service_key)], +) +def start_provisioning_from_app(device_id: str, user_id: str = Depends(require_user_id)): + """Open the hub's setup access point remotely (owner/admin). + + The app-side twin of POST /api/v1/local/devices/{id}/provisioning/start: the + hub picks the command up on its next check-in and opens its AP once that + cycle is done, so it appears up to one send interval later. + """ + require_device_role(user_id, device_id, ["owner", "admin"]) + result = create_command( + device_id, + DeviceCommandIn(command_type="start_provisioning", payload={}), + ) + return { + "status": result["status"], + "id": result["id"], + "command_type": "start_provisioning", + "payload": {}, + } + + +# ── Data export / range delete ───────────────────────────────────────────── +# +# Thin wrappers over the local dashboard handlers, scoped to one device the +# caller is a member of. The local routes are gated by their decorators only, +# so calling the functions directly reuses the logic without the dashboard +# session requirement. + + +@router.get( + "/api/v1/app/devices/{device_id}/export/measurements/summary", + dependencies=[Depends(require_hivepal_service_key)], +) +def export_summary_from_app( + device_id: str, + start_at: Optional[datetime] = None, + end_at: Optional[datetime] = None, + user_id: str = Depends(require_user_id), +): + """How many readings an export of this device and period would contain.""" + require_device_role(user_id, device_id, ["owner", "admin"]) + return local_export_summary(device_id=[device_id], start_at=start_at, end_at=end_at) + + +@router.get( + "/api/v1/app/devices/{device_id}/export/measurements", + dependencies=[Depends(require_hivepal_service_key)], +) +def export_measurements_from_app( + device_id: str, + hive: list[int] = Query(default=[]), + start_at: Optional[datetime] = None, + end_at: Optional[datetime] = None, + user_id: str = Depends(require_user_id), +): + """Stream this device's readings as an SD-style NDJSON backup. + + The file imports straight back through POST .../measurements/import (or the + HivePal SD upload), so it doubles as a migration path between servers. + """ + require_device_role(user_id, device_id, ["owner", "admin"]) + return local_export_measurements( + device_id=[device_id], hive=hive, start_at=start_at, end_at=end_at + ) + + +@router.post( + "/api/v1/app/devices/{device_id}/measurements/delete", + dependencies=[Depends(require_hivepal_service_key)], +) +def delete_measurements_from_app( + device_id: str, body: MeasurementDeleteIn, user_id: str = Depends(require_user_id) +): + """Delete this device's readings in a time range (owner only, claim-code gated).""" + require_device_role(user_id, device_id, ["owner"]) + return local_delete_measurements(device_id, body) diff --git a/server/config.py b/server/config.py index 68a4913..ad2c496 100644 --- a/server/config.py +++ b/server/config.py @@ -7,7 +7,7 @@ # Backend server version. Bump on releases; reported in the OpenAPI metadata # (/docs) and by GET /health so a deployment's version is visible remotely. -SERVER_VERSION = "0.5.3" +SERVER_VERSION = "0.6.0" DATABASE_URL = os.environ["DATABASE_URL"] API_KEY = os.environ["API_KEY"] diff --git a/server/db.py b/server/db.py index d8c705e..1eb9606 100644 --- a/server/db.py +++ b/server/db.py @@ -65,6 +65,10 @@ def init_db(): ALTER TABLE device_channels ADD CONSTRAINT device_channels_channel_number_check CHECK (channel_number BETWEEN 1 AND 18); + -- The HivePal hive each slot is linked to, so the link survives + -- a rename. See migrations/031_device_channel_hive_links.sql. + ALTER TABLE device_channels + ADD COLUMN IF NOT EXISTS hivepal_hive_id TEXT; CREATE TABLE IF NOT EXISTS measurements ( id BIGSERIAL PRIMARY KEY, diff --git a/server/devices.py b/server/devices.py index 416acf2..d982365 100644 --- a/server/devices.py +++ b/server/devices.py @@ -307,13 +307,15 @@ def fetch_device_channels(device_id: str) -> dict: """Return the per-hive (scale-channel) display names for a device. ``names`` maps every stored hive index ("1".."18") to its custom name and is - the canonical multi-hive shape the local dashboard consumes. ``scale_1/2_*`` - are kept for the HivePal app endpoints and older callers. + the canonical multi-hive shape the local dashboard consumes. ``hive_ids`` + maps an index to the HivePal hive it is linked to. ``scale_1/2_*`` are kept + for the HivePal app endpoints and older callers. """ with get_conn() as conn: with conn.cursor() as cur: cur.execute( - "SELECT channel_number, name FROM device_channels WHERE device_id = %s ORDER BY channel_number;", + "SELECT channel_number, name, hivepal_hive_id FROM device_channels " + "WHERE device_id = %s ORDER BY channel_number;", (device_id,), ) rows = cur.fetchall() @@ -322,30 +324,51 @@ def fetch_device_channels(device_id: str) -> dict: "scale_1_display_name": ch.get(1), "scale_2_display_name": ch.get(2), "names": {str(num): name for num, name in ch.items() if name is not None}, + "hive_ids": {str(r[0]): r[2] for r in rows if r[2]}, } -def apply_device_channels(device_id: str, payload: DeviceChannelsUpdateIn) -> dict: - """Upsert the provided per-hive display names and return all of them.""" - # Collapse the legacy scale_1/2 fields and the general names[] map into one - # {hive_index: name} set, dropping anything outside 1..MAX_HIVES. - updates: dict[int, Optional[str]] = {} +def _hive_index_map(values: Optional[dict]) -> dict[int, Optional[str]]: + """Keep the entries of an index-keyed map whose key is a hive in 1..MAX_HIVES.""" + out: dict[int, Optional[str]] = {} + for key, value in (values or {}).items(): + try: + idx = int(key) + except (TypeError, ValueError): + continue + if 1 <= idx <= MAX_HIVES: + out[idx] = value + return out + + +def channel_updates(payload: DeviceChannelsUpdateIn) -> tuple[dict[int, str], dict[int, Optional[str]]]: + """Split a channels PATCH into ``({index: name}, {index: hive_id or None})``. + + Names follow the existing contract (``None`` means "leave alone"); a hive + link of ``None`` or ``""`` clears it. Pure, so it is unit-testable. + """ + names: dict[int, str] = {} if payload.scale_1_display_name is not None: - updates[1] = payload.scale_1_display_name + names[1] = payload.scale_1_display_name if payload.scale_2_display_name is not None: - updates[2] = payload.scale_2_display_name - if payload.names: - for key, name in payload.names.items(): - try: - idx = int(key) - except (TypeError, ValueError): - continue - if 1 <= idx <= MAX_HIVES and name is not None: - updates[idx] = name + names[2] = payload.scale_2_display_name + for idx, name in _hive_index_map(payload.names).items(): + if name is not None: + names[idx] = name + links = { + idx: (str(value).strip() or None) if value is not None else None + for idx, value in _hive_index_map(payload.hive_ids).items() + } + return names, links + + +def apply_device_channels(device_id: str, payload: DeviceChannelsUpdateIn) -> dict: + """Upsert the provided per-hive display names / hive links and return all of them.""" + names, links = channel_updates(payload) with get_conn() as conn: with conn.cursor() as cur: - for ch_num, ch_name in updates.items(): + for ch_num, ch_name in names.items(): cur.execute( """ INSERT INTO device_channels (device_id, channel_number, name) @@ -354,6 +377,16 @@ def apply_device_channels(device_id: str, payload: DeviceChannelsUpdateIn) -> di """, (device_id, ch_num, ch_name), ) + for ch_num, hive_id in links.items(): + cur.execute( + """ + INSERT INTO device_channels (device_id, channel_number, hivepal_hive_id) + VALUES (%s, %s, %s) + ON CONFLICT (device_id, channel_number) + DO UPDATE SET hivepal_hive_id = EXCLUDED.hivepal_hive_id; + """, + (device_id, ch_num, hive_id), + ) conn.commit() return fetch_device_channels(device_id) diff --git a/server/migrations/031_device_channel_hive_links.sql b/server/migrations/031_device_channel_hive_links.sql new file mode 100644 index 0000000..5333126 --- /dev/null +++ b/server/migrations/031_device_channel_hive_links.sql @@ -0,0 +1,10 @@ +-- 031_device_channel_hive_links.sql — remember which HivePal hive a slot is. +-- +-- HivePal maps each of a device's hive slots (1..18) to one of its own hives. +-- Until now only the display name crossed over, so the link was a name match: +-- renaming the hive in HivePal, or two hives sharing a name, broke it. The +-- HivePal hive id is stored next to the name so the link survives both. +-- +-- Nullable and free-form: HiveHub never resolves it, it only hands it back. +ALTER TABLE device_channels + ADD COLUMN IF NOT EXISTS hivepal_hive_id TEXT; diff --git a/server/schemas.py b/server/schemas.py index b95aaa2..1101299 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -881,6 +881,9 @@ class DeviceChannelsUpdateIn(BaseModel): # reports, not just the first two. Ignored entries outside 1..MAX_HIVES are # dropped by apply_device_channels(). names: Optional[dict[str, Optional[str]]] = None + # HivePal hive id linked to each hive index ("1".."18" -> id). An empty + # string clears the link; indexes left out are not touched. + hive_ids: Optional[dict[str, Optional[str]]] = None # Lightweight email check — good enough to catch typos without pulling in the diff --git a/test-data/mock-server/app.py b/test-data/mock-server/app.py index 50fc274..3428334 100644 --- a/test-data/mock-server/app.py +++ b/test-data/mock-server/app.py @@ -20,6 +20,10 @@ from __future__ import annotations +import base64 +import hashlib +import hmac +import json import os import zlib from datetime import datetime, timedelta, timezone @@ -65,6 +69,8 @@ "devices": {}, # device_id -> device metadata dict "commands": [], # queued device commands "firmware_releases": [], # registered releases + "inspections": {}, # device_id -> [inspection dict, ...] newest last + "next_inspection_id": 1, "restamp_cache": {}, # device_id -> dataset re-stamped for that device "data_end": None, # newest measured_at (anchor for insights "now") "next_command_id": 1, @@ -135,10 +141,45 @@ def require_hivepal_service_key(x_hivepal_service_key: str = Header(default="")) raise HTTPException(status_code=401, detail="Invalid HivePal service key") -def require_user_id(x_user_id: str = Header(default="")) -> str: - if not x_user_id: - raise HTTPException(status_code=401, detail="X-User-Id header is required") - return x_user_id +HIVEPAL_JWT_SECRET = os.environ.get("HIVEPAL_JWT_SECRET", "") + + +def _user_from_bearer(authorization: str) -> Optional[str]: + """``sub`` of the HS256 token HivePal forwards, like server/auth.py. + + Verified when HIVEPAL_JWT_SECRET is set; otherwise only decoded, which is + enough for a demo backend. Stdlib only, so the mock keeps its tiny + dependency list. + """ + if not authorization.lower().startswith("bearer "): + return None + token = authorization.split(" ", 1)[1].strip() + try: + header_b64, payload_b64, signature_b64 = token.split(".") + def b64(part: str) -> bytes: + return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)) + if HIVEPAL_JWT_SECRET: + expected = hmac.new(HIVEPAL_JWT_SECRET.encode(), + f"{header_b64}.{payload_b64}".encode(), + hashlib.sha256).digest() + if not hmac.compare_digest(expected, b64(signature_b64)): + return None + payload = json.loads(b64(payload_b64)) + except (ValueError, json.JSONDecodeError): + return None + sub = payload.get("sub") + return str(sub) if sub else None + + +def require_user_id(x_user_id: str = Header(default=""), + authorization: str = Header(default="")) -> str: + """HivePal sends ``Authorization: Bearer ``; ``X-User-Id`` is kept for + curl-driven testing.""" + user_id = _user_from_bearer(authorization) or x_user_id + if not user_id: + raise HTTPException(status_code=401, + detail="A HivePal bearer token (or X-User-Id header) is required") + return user_id def _ensure_app_device(device_id: str) -> None: @@ -291,6 +332,8 @@ class ShareDeviceIn(BaseModel): class DeviceChannelsUpdateIn(BaseModel): scale_1_display_name: Optional[str] = None scale_2_display_name: Optional[str] = None + names: Optional[dict[str, Optional[str]]] = None + hive_ids: Optional[dict[str, Optional[str]]] = None class AppCalibrationModeStartIn(BaseModel): @@ -544,6 +587,8 @@ def list_devices(user_id: str = Depends(require_user_id)): "channels": { "scale_1": ch.get("scale_1_display_name"), "scale_2": ch.get("scale_2_display_name"), + "names": _channel_names(ch), + "hive_ids": dict(ch.get("hive_ids", {})), }, }) return out @@ -579,12 +624,22 @@ def release_device_claim(device_id: str, user_id: str = Depends(require_user_id) } +def _channel_names(ch: dict) -> dict[str, str]: + names = {k: v for k, v in ch.get("names", {}).items() if v is not None} + for idx, key in ((1, "scale_1_display_name"), (2, "scale_2_display_name")): + if ch.get(key) is not None: + names[str(idx)] = ch[key] + return names + + @app.get("/api/v1/app/devices/{device_id}/channels", dependencies=[Depends(require_hivepal_service_key)]) def get_device_channels(device_id: str, user_id: str = Depends(require_user_id)): require_device_role(user_id, device_id, ["owner", "admin", "viewer"]) ch = STORE["channels"].get(device_id, {}) return {"scale_1_display_name": ch.get("scale_1_display_name"), - "scale_2_display_name": ch.get("scale_2_display_name")} + "scale_2_display_name": ch.get("scale_2_display_name"), + "names": _channel_names(ch), + "hive_ids": dict(ch.get("hive_ids", {}))} @app.patch("/api/v1/app/devices/{device_id}/channels", dependencies=[Depends(require_hivepal_service_key)]) @@ -595,6 +650,23 @@ def update_device_channels(device_id: str, payload: DeviceChannelsUpdateIn, user ch["scale_1_display_name"] = payload.scale_1_display_name if payload.scale_2_display_name is not None: ch["scale_2_display_name"] = payload.scale_2_display_name + # Mirrors server/devices.py channel_updates(): names of None are left alone, + # an empty / None hive link clears it; indexes outside 1..18 are dropped. + for key, name in (payload.names or {}).items(): + if key.isdigit() and 1 <= int(key) <= 18 and name is not None: + if key == "1": + ch["scale_1_display_name"] = name + elif key == "2": + ch["scale_2_display_name"] = name + ch.setdefault("names", {})[key] = name + for key, hive_id in (payload.hive_ids or {}).items(): + if key.isdigit() and 1 <= int(key) <= 18: + links = ch.setdefault("hive_ids", {}) + value = (hive_id or "").strip() + if value: + links[key] = value + else: + links.pop(key, None) return get_device_channels(device_id, user_id) @@ -646,6 +718,7 @@ def list_device_measurements( limit: int = 200, start_at: Optional[datetime] = None, end_at: Optional[datetime] = None, + max_points: Optional[int] = None, user_id: str = Depends(require_user_id), ): require_device_role(user_id, device_id, ["owner", "admin", "viewer"]) @@ -660,6 +733,10 @@ def list_device_measurements( if end_at is not None: rows = [m for m in rows if m["measured_at"] <= end_at] rows = sorted(rows, key=lambda m: m["measured_at"], reverse=True) + # Same even-stride thinning as measurements.execute_measurement_query. + if max_points and max_points > 0 and len(rows) > max_points: + stride = -(-len(rows) // max_points) + rows = rows[::stride] return rows[:limit] @@ -728,9 +805,29 @@ def _ver(v: str): "latest_is_official": latest_is_official, "approved_version": approved_version, "update_available": update_available, "pending_approval": update_available and approved_version != latest_version, + "hiveinside_latest_version": (_latest_release_for_owner("hiveinside", _device_owner_id(device_id)) or {}).get("version"), + "hiveinside_relays": _latest_relays(device_id, "update_hiveinside"), + "beecounter_latest_version": (_latest_release_for_owner("beecounter", _device_owner_id(device_id)) or {}).get("version"), + "beecounter_relays": _latest_relays(device_id, "update_beecounter"), } +def _latest_relays(device_id: str, command_type: str) -> dict[str, dict]: + """Last relay command per slot, shaped like commands.latest_*_relays().""" + out: dict[str, dict] = {} + for cmd in STORE["commands"]: + if cmd["device_id"] == device_id and cmd["command_type"] == command_type: + slot = str(cmd["payload"].get("slot")) + out[slot] = { + "status": cmd["status"], + "message": (cmd.get("result") or {}).get("message"), + "version": cmd["payload"].get("version"), + "created_at": cmd["created_at"], + "completed_at": cmd.get("completed_at"), + } + return out + + @app.post("/api/v1/app/devices/{device_id}/firmware/approve", dependencies=[Depends(require_hivepal_service_key)]) def approve_firmware_from_app(device_id: str, user_id: str = Depends(require_user_id)): @@ -764,8 +861,8 @@ def stop_calibration_mode_from_app(device_id: str, user_id: str = Depends(requir def _queue_relay_update_from_app(device_id: str, target: str, command_type: str, slot: int): - if slot not in (1, 2): - raise HTTPException(status_code=400, detail="slot must be 1 or 2") + if not 1 <= slot <= 18: + raise HTTPException(status_code=400, detail="slot must be between 1 and 18") r = _latest_release_for_owner(target, _device_owner_id(device_id)) if not r: raise HTTPException(status_code=404, detail=f"No active {target} firmware release") @@ -829,3 +926,126 @@ def get_device_insights_summary(device_id: str, user_id: str = Depends(require_u "highest_alert": s.highest_alert.model_dump() if s.highest_alert else None, "categories": s.categories, } + + +# ── Remote AP mode, inspections, recordings (mirrors server/app_api.py and +# server/inspections.py; kept in memory) ───────────────────────────────── + + +@app.post("/api/v1/app/devices/{device_id}/provisioning/start", + dependencies=[Depends(require_hivepal_service_key)]) +def start_provisioning_from_app(device_id: str, user_id: str = Depends(require_user_id)): + require_device_role(user_id, device_id, ["owner", "admin"]) + cmd = _create_command(device_id, "start_provisioning", {}) + return {"status": cmd["status"], "id": cmd["id"], "command_type": "start_provisioning", "payload": {}} + + +class InspectionStartIn(BaseModel): + hives: Optional[list[int]] = None + note: Optional[str] = None + started_at: Optional[datetime] = None + + +class InspectionStopIn(BaseModel): + note: Optional[str] = None + ended_at: Optional[datetime] = None + + +class InspectionNoteIn(BaseModel): + note: Optional[str] = None + + +def _active_inspection(device_id: str) -> Optional[dict]: + return next((i for i in reversed(STORE["inspections"].get(device_id, [])) if i["active"]), None) + + +def _inspection_status(device_id: str) -> dict: + active = _active_inspection(device_id) + timeout = (STORE["configs"].get(device_id) or {}).get("inspection_timeout_minutes", 60) + return {"device_id": device_id, "active": active is not None, "pending": False, + "inspection": active, "timeout_minutes": timeout} + + +@app.get("/api/v1/app/devices/{device_id}/inspections/status", + dependencies=[Depends(require_hivepal_service_key)]) +def inspection_status_from_app(device_id: str, user_id: str = Depends(require_user_id)): + require_device_role(user_id, device_id, ["owner", "admin", "viewer"]) + return _inspection_status(device_id) + + +@app.get("/api/v1/app/devices/{device_id}/inspections", + dependencies=[Depends(require_hivepal_service_key)]) +def list_inspections_from_app(device_id: str, start_at: Optional[datetime] = None, + end_at: Optional[datetime] = None, limit: int = Query(100), + user_id: str = Depends(require_user_id)): + require_device_role(user_id, device_id, ["owner", "admin", "viewer"]) + rows = list(reversed(STORE["inspections"].get(device_id, []))) + if start_at is not None: + rows = [r for r in rows if (r["ended_at"] or STORE["data_end"]) >= start_at] + if end_at is not None: + rows = [r for r in rows if r["started_at"] <= end_at] + return rows[:limit] + + +@app.post("/api/v1/app/devices/{device_id}/inspections/start", + dependencies=[Depends(require_hivepal_service_key)]) +def start_inspection_from_app(device_id: str, payload: Optional[InspectionStartIn] = None, + user_id: str = Depends(require_user_id)): + require_device_role(user_id, device_id, ["owner", "admin"]) + payload = payload or InspectionStartIn() + if _active_inspection(device_id) is None: + now = datetime.now(timezone.utc) + inspection = { + "id": STORE["next_inspection_id"], "device_id": device_id, + "hives": payload.hives, "started_at": payload.started_at or now, "ended_at": None, + "active": True, "source": "api", "end_reason": None, "requested_at": now, + "acknowledged_at": now, "note": payload.note, "created_by": user_id, + } + STORE["next_inspection_id"] += 1 + STORE["inspections"].setdefault(device_id, []).append(inspection) + _create_command(device_id, "start_inspection", {}) + return _active_inspection(device_id) + + +@app.post("/api/v1/app/devices/{device_id}/inspections/stop", + dependencies=[Depends(require_hivepal_service_key)]) +def stop_inspection_from_app(device_id: str, payload: Optional[InspectionStopIn] = None, + user_id: str = Depends(require_user_id)): + require_device_role(user_id, device_id, ["owner", "admin"]) + payload = payload or InspectionStopIn() + active = _active_inspection(device_id) + if active is not None: + active.update(active=False, ended_at=payload.ended_at or datetime.now(timezone.utc), + end_reason="api") + if payload.note: + active["note"] = payload.note + _create_command(device_id, "stop_inspection", {}) + return active + + +@app.patch("/api/v1/app/devices/{device_id}/inspections/{inspection_id}", + dependencies=[Depends(require_hivepal_service_key)]) +def update_inspection_from_app(device_id: str, inspection_id: int, payload: InspectionNoteIn, + user_id: str = Depends(require_user_id)): + require_device_role(user_id, device_id, ["owner", "admin"]) + for inspection in STORE["inspections"].get(device_id, []): + if inspection["id"] == inspection_id: + inspection["note"] = payload.note + return inspection + raise HTTPException(status_code=404, detail="Inspection not found") + + +@app.get("/api/v1/app/devices/{device_id}/recordings", + dependencies=[Depends(require_hivepal_service_key)]) +def list_recordings_from_app(device_id: str, hive: Optional[int] = Query(None), + limit: int = Query(50), user_id: str = Depends(require_user_id)): + require_device_role(user_id, device_id, ["owner", "admin", "viewer"]) + # The mock has no hub to stream audio from, so it never holds a recording. + return {"recordings": []} + + +@app.post("/api/v1/app/devices/{device_id}/recordings", + dependencies=[Depends(require_hivepal_service_key)]) +def request_recording_from_app(device_id: str, user_id: str = Depends(require_user_id)): + require_device_role(user_id, device_id, ["owner", "admin"]) + raise HTTPException(status_code=501, detail="The mock server cannot record hive audio") diff --git a/test-data/mock-server/instructions.md b/test-data/mock-server/instructions.md index ec5a4a9..8409397 100644 --- a/test-data/mock-server/instructions.md +++ b/test-data/mock-server/instructions.md @@ -35,7 +35,7 @@ Everything you need: | Base URL | `http://localhost:31115` | | Device key — `X-API-Key` | `demo-device-key` | | HivePal service key — `X-HivePal-Service-Key` | `demo-hivepal-service-key` | -| HivePal user — `X-User-Id` | any value works (e.g. `demo-user`) | +| HivePal user — `Authorization: Bearer ` (what HivePal sends; verified when `HIVEPAL_JWT_SECRET` is set) or `X-User-Id` | any value works (e.g. `demo-user`) | | Demo devices (both serve the full dataset) | `hive_scale_dual_01` (claim code `ABCD-1234`), `hive_scale_dual_02` (`WXYZ-5678`) | | Dummy data span | `2025-01-01` → `2026-05-31`, 30-min samples (~24,800 points) | diff --git a/test-data/test_channel_links.py b/test-data/test_channel_links.py new file mode 100644 index 0000000..6659850 --- /dev/null +++ b/test-data/test_channel_links.py @@ -0,0 +1,45 @@ +"""Tests for the channels PATCH normalisation (hive names + HivePal hive links). + +Run: PYTHONPATH=server python3 -m pytest -q test-data/test_channel_links.py + (no database required) +""" + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql://unused@localhost/unused") +os.environ.setdefault("API_KEY", "test-api-key-not-used-0000") + +from devices import channel_updates # noqa: E402 +from schemas import DeviceChannelsUpdateIn # noqa: E402 + + +def test_names_merge_legacy_fields_and_map(): + names, links = channel_updates( + DeviceChannelsUpdateIn( + scale_1_display_name="North", + names={"2": "South", "3": "East", "18": "Last"}, + ) + ) + assert names == {1: "North", 2: "South", 3: "East", 18: "Last"} + assert links == {} + + +def test_names_outside_range_or_none_are_dropped(): + names, _ = channel_updates( + DeviceChannelsUpdateIn(names={"0": "x", "19": "y", "abc": "z", "4": None}) + ) + assert names == {} + + +def test_hive_links_set_and_clear(): + _, links = channel_updates( + DeviceChannelsUpdateIn( + hive_ids={"1": " 7f3c-uuid ", "2": "", "3": None, "42": "ignored"} + ) + ) + assert links == {1: "7f3c-uuid", 2: None, 3: None} + + +def test_empty_name_is_kept_so_a_name_can_be_cleared(): + names, _ = channel_updates(DeviceChannelsUpdateIn(names={"5": ""})) + assert names == {5: ""}