Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 73 additions & 7 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": "" }
}
```

Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |

Expand Down
5 changes: 3 additions & 2 deletions docs/insights.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
113 changes: 111 additions & 2 deletions server/app_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
)
Expand Down Expand Up @@ -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],
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
}


Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
4 changes: 4 additions & 0 deletions server/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading