# TTP v1 – Tracker Telemetry Protocol, version 1

Status: **V0 release of the portal, protocol major version 1.** This document is the
normative contract between a field controller ("box") and the portal. Machine-readable
JSON Schemas (draft 2020-12) are in `protocol/schemas/`. Where this text and a schema
disagree, the schema wins for message structure and this text wins for behaviour.

The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be read as in RFC 2119.

---

## 1. Principles

1. **Control stays in the field.** The box computes sun position, targets, backtracking and
   every safety decision (wind stow, snow, hail, interlocks). The portal never sends target
   angles in V0 and never decides a stow.
2. **The portal observes, alarms, reports and relays** a small set of signed, time-limited
   commands. A box MUST keep operating normally when the portal or the network is
   unavailable for any length of time.
3. **Any controller may connect.** The reference implementation is not a precondition;
   every controller that implements this document is supported. `controller_type` in the
   heartbeat identifies the implementation.
4. **Nothing is silently lost.** Boxes buffer while offline and replay; the portal records
   sequence gaps and accepts late data.

## 2. Roles and identities

| Term | Meaning |
|---|---|
| box | one field controller with one network connection; controls 1–999 trackers |
| tracker | one rotating row/table, index `n` = 1..999, unique per box |
| serial | the box identity: `^[A-Za-z0-9][A-Za-z0-9_-]{2,63}$`, globally unique, MUST NOT start with `portal-` (reserved) |
| tenant slug / plant slug | lowercase `^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$`, assigned by the portal at enrollment |
| portal | the monitoring service (MQTT broker + ingest + web/API) |

## 3. Transport

| Item | Requirement |
|---|---|
| Protocol | MQTT 3.1.1 (MQTT 5 MAY be used; no v5-only features are required) |
| Port | 8883, TLS 1.2 or 1.3 |
| Authentication | mutual TLS. Client certificate issued at enrollment (section 4); CN = serial. No username/password. |
| Server verification | the box MUST verify the broker certificate against `ca_chain_pem` from enrollment and the host name `mqtt_host` |
| Client id | the serial |
| Session | `clean_session = false` (MQTT 5: session expiry ≥ 14 days) so that commands sent while the box is offline are delivered after reconnect |
| Keep-alive | 60 s (RECOMMENDED) |
| QoS | 1 for every message in both directions |
| Retain | `true` only for live `heartbeat` and live `tracker/{n}/state`; `false` for everything else and for every replayed message (8.3) |
| Max message size | 16 384 bytes (broker and ingest reject larger messages) |
| Encoding | UTF-8 JSON, no BOM, no comments |
| Reconnect | exponential back-off 1 s → 60 s with ±20 % jitter |
| Last will | not used in v1 (offline detection is time-based in the portal) |

### 3.1 Topics

Topic root: `ttp/v1/{tenant_slug}/{plant_slug}/{serial}/` (returned as `topic_prefix` at enrollment;
a box MUST use it verbatim and MUST NOT construct it itself).

| Topic suffix | Direction | Interval | Retain | Schema |
|---|---|---|---|---|
| `heartbeat` | box → portal | every 30 s | yes | `heartbeat.schema.json` |
| `tracker/{n}/state` | box → portal | every 60 s per tracker | yes | `tracker_state.schema.json` |
| `weather` | box → portal | every 60 s (only boxes with a weather station) | no | `weather.schema.json` |
| `alarm` | box → portal | on event | no | `alarm.schema.json` |
| `cmd` | portal → box | on demand | no | `cmd.schema.json` |
| `cmd/ack` | box → portal | on event | no | `cmd_ack.schema.json` |
| `ota` | portal → box | **reserved for v1.x (V1 of the portal)** – boxes SHOULD subscribe but MUST ignore messages until a later revision of this document defines them | – | – |

`{n}` is the decimal tracker index without leading zeros (1..999).

The broker enforces: a box may publish only below `ttp/v1/+/+/{its CN}/…` and subscribe only to
its own `cmd` and `ota`. The portal additionally rejects messages whose tenant/plant part does not
match the box's registration (`prefix_mismatch`).

## 4. Enrollment (self-service, claim code)

1. A tenant administrator creates a plant in the portal and generates a **claim code**: 8 characters
   from `ABCDEFGHJKMNPQRSTUVWXYZ23456789`, single use, valid 72 hours.
2. The box generates its key pair **on the device** (RECOMMENDED: EC P-256; accepted: P-256,
   P-384, RSA ≥ 2048). The private key MUST NOT leave the device.
3. The box creates a PKCS#10 CSR with subject `CN=<serial>` (other subject attributes are ignored).
4. The box sends `POST https://{BRAND_DOMAIN}/api/v1/enroll` with `Content-Type: application/json`
   (schema `enroll_request.schema.json`):

```json
{
  "claim_code": "K7M2Q9XT",
  "serial": "RB-2026-000123",
  "csr_pem": "-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----\n",
  "fw_version": "1.4.2",
  "controller_type": "reference-box/1",
  "tracker_count": 12,
  "drive_count": 1
}
```

`tracker_count` and `drive_count` are optional; when given, the portal pre-creates the trackers so
they are visible before the first state message.

5. Response **200** (schema `enroll_response.schema.json`):

```json
{
  "status": "enrolled",
  "cert_pem": "-----BEGIN CERTIFICATE-----\n...",
  "ca_chain_pem": "-----BEGIN CERTIFICATE-----\n(intermediate)\n-----BEGIN CERTIFICATE-----\n(root)\n...",
  "mqtt_host": "mqtt.example.com",
  "mqtt_port": 8883,
  "topic_prefix": "ttp/v1/muster-solar/solarpark-musterfeld/RB-2026-000123/",
  "cmd_public_key": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg",
  "cmd_key_id": "56475aa75463474c",
  "cert_not_after": "2029-10-27T10:00:00Z"
}
```

The box MUST persist `cert_pem`, `ca_chain_pem`, `mqtt_host`, `mqtt_port`, `topic_prefix`,
`cmd_public_key` and `cmd_key_id` in non-volatile storage. The client certificate is valid for
3 years (`cert_not_after`); renewal over the authenticated channel is a V1 item – until then a
box is re-enrolled with a new claim code.

6. Response **202** (only if the platform requires manual approval):

```json
{"status": "pending_approval", "enrollment_id": 42, "poll_token": "…", "poll_url": "/api/v1/enroll/42", "retry_after_s": 60}
```

The box polls `GET {poll_url}` with header `X-Enroll-Token: {poll_token}` every `retry_after_s`
seconds until it receives 200 (same body as above) or an error.

Errors (`{"status": "error", "error": "<code>"}`):

| HTTP | error | box action |
|---|---|---|
| 400 | `invalid_request`, `csr_invalid`, `csr_bad_signature`, `csr_cn_mismatch`, `csr_key_type`, `serial_reserved` | fix the request; do not retry unchanged |
| 403 | `invalid_claim_code` | ask the installer for a new code |
| 409 | `claim_code_used`, `serial_in_use` | new code / contact the platform |
| 410 | `claim_code_expired`, `already_issued` (poll) | new code |
| 429 | `rate_limited` | wait at least 1 hour (20 failed attempts per IP and hour) |

Revocation: the platform can revoke a box; its certificate is put on the CRL and every message
from it is rejected (`box_not_active`). A revoked box must be re-enrolled.

## 5. Envelope (all messages)

```json
{"v": 1, "ts": "2026-06-21T10:00:00Z", "seq": 18234, "payload": { ... }}
```

| Field | Type | Rule |
|---|---|---|
| `v` | integer | `1` (protocol major version) |
| `ts` | string | RFC 3339, **UTC with `Z`**, optional fraction ≤ 6 digits. Time of the measurement/event, **not** of sending. |
| `seq` | integer ≥ 0 | see 5.1 |
| `payload` | object | message specific |

No other envelope keys are allowed.

### 5.1 Sequence numbers

* Box → portal: one counter per box, shared by **all** box→portal topics (heartbeat, states,
  weather, alarm, cmd/ack). It increases by exactly 1 for every message the box creates
  (also while offline – buffered messages get their seq when created). Start at 1.
* The counter MUST be persisted and continue after reboot and firmware update. It MUST NOT be
  reset; a factory reset requires re-enrollment with a new serial.
* Duplicates (same seq, QoS 1 redelivery) are allowed and ignored by the portal.
* Portal → box (`cmd`): the portal uses its own per-box counter; boxes MAY use it for logging only.

Portal behaviour: a jump from `last_seq` to `seq > last_seq + 1` opens a **data gap**
`[last_seq+1, seq-1]`; late messages inside the range fill it; the gap closes when every seq has
arrived. A gap alone is not an alarm (offline alarms are time based, section 10).

### 5.2 Time

The box clock MUST be synchronised (NTP or GNSS) to within ±2 s. The portal rejects messages with
`ts` more than 5 minutes in the future (`future_ts`) and older than 7 days (`too_old`).

## 6. Messages box → portal

All payloads allow additional properties for forward compatibility (section 11). `null` means
"not available / not applicable" and is always allowed where the table says *nullable*.

### 6.1 heartbeat (every 30 s, retained)

| Field | Type | Req. | Meaning |
|---|---|---|---|
| `uptime_s` | integer ≥ 0 | yes | seconds since boot (shows reboots) |
| `fw_version` | string 1–64 | yes | firmware version |
| `controller_type` | string 1–80, `[A-Za-z0-9._/+-]` | yes | vendor/model identifier, e.g. `reference-box/1` |
| `rssi_dbm` | number −150..0, nullable | no | cellular signal |
| `active_sim` | integer 1–4, nullable | no | active SIM slot |
| `temp_c` | number −60..125, nullable | no | enclosure temperature |

```json
{"v":1,"ts":"2026-06-21T10:00:00Z","seq":18234,"payload":{"uptime_s":86412,"rssi_dbm":-79,"active_sim":1,"temp_c":31.5,"fw_version":"1.4.2","controller_type":"reference-box/1"}}
```

### 6.2 tracker/{n}/state (every 60 s per tracker, retained)

| Field | Type | Req. | Meaning |
|---|---|---|---|
| `target_deg` | number −90..90, nullable | yes | angle the controller is driving to |
| `actual_deg_a` | number −90..90, nullable | yes | measured angle, sensor/drive A (primary) |
| `actual_deg_b` | number −90..90, nullable | yes | measured angle, sensor/drive B; `null` for single-sensor trackers |
| `mode` | `auto` \| `stow` \| `manual` \| `maintenance` \| `fault` | yes | operating mode |
| `mode_reason` | string `[a-z0-9_]`, ≤ 32, nullable | no | why: `wind`, `snow`, `hail`, `flood`, `command`, `night`, `schedule`, `commissioning`, `other` |
| `motor_current_a` | number 0..200, nullable | no | maximum motor current during the reporting interval, A |
| `battery_v` | number 0..100, nullable | no | tracker battery / supply voltage, V; `null` if mains powered |
| `fault_code` | string `<class>.<detail>`, nullable | no | `null` = no fault (6.2.2) |

**Angle convention.** 0° = modules horizontal. Positive angles rotate the module surface toward
the west for a north–south axis (right-hand rule about the axis pointing to `axis_azimuth_deg`,
same convention as pvlib `tracker_theta`). Angles are rotation angles, not tilt magnitudes.

**Modes.**

| mode | use |
|---|---|
| `auto` | normal tracking incl. backtracking and the night position (`mode_reason: "night"`) |
| `stow` | safety position; `mode_reason` MUST be set (`wind`, `snow`, `hail`, `flood`, `command`, …) |
| `manual` | moved by a person on site |
| `maintenance` | maintenance mode (e.g. by command or local switch) |
| `fault` | the controller cannot move the tracker; `fault_code` SHOULD be set |

`stow` with reason `wind`/`snow`/`hail`/`flood`/`command` counts as *justified* for availability
(docs/metrics.md).

#### 6.2.2 Fault codes

`<class>.<detail>` with lowercase `[a-z0-9_]` parts. Classes: `motor` (drive, overcurrent, stall),
`sensor` (inclinometer, encoder), `comm` (bus/radio to the tracker), `power` (battery, supply),
`controller` (internal), `other`. The portal raises *motor_fault* for every `motor.*` code and for
codes whose last part is `overcurrent` or `stall`. Examples: `motor.overcurrent`, `motor.stall`,
`sensor.inclinometer_a`, `comm.rs485_timeout`, `power.battery_low`.

```json
{"v":1,"ts":"2026-06-21T10:00:00Z","seq":18235,"payload":{"target_deg":23.4,"actual_deg_a":23.31,"actual_deg_b":null,"mode":"auto","mode_reason":null,"motor_current_a":0.05,"battery_v":12.91,"fault_code":null}}
```

### 6.3 weather (every 60 s, not retained)

| Field | Type | Req. | Meaning |
|---|---|---|---|
| `wind_ms_avg1m` | number 0..100, nullable | yes | 1-minute mean wind speed, m/s |
| `wind_ms_gust3s` | number 0..150, nullable | yes | max 3-second gust in the last minute, m/s |
| `wind_dir_deg` | number 0..360, nullable | no | from north, clockwise |
| `irradiance_wm2` | number 0..2000, nullable | no | W/m² |

A failed sensor is reported with `null`, not with 0. Boxes without a weather station do not publish
this topic.

### 6.4 alarm (on event)

Box-detected events the portal should show (e.g. cabinet door, UPS, local emergency stop).

| Field | Type | Req. | Meaning |
|---|---|---|---|
| `type` | string `<a>.<b>…`, ≤ 60 | yes | stable identifier, e.g. `cabinet.door_open` |
| `severity` | `info` \| `medium` \| `high` \| `critical` | yes | |
| `state` | `raised` \| `cleared` | yes | |
| `tracker` | integer 1..999, nullable | no | concerned tracker; absent/null = whole box |
| `context` | object, nullable | no | free details, ≤ 2 kB |

The portal keeps one open alarm per (`type`, object); `cleared` closes it. The box SHOULD repeat
`raised` after reconnect if the condition is still present.

Event types the portal knows by name (v1.1; others are shown with their raw `type`):

| `type` | Raised by | `context` |
|---|---|---|
| `wind_stow` | controller stows for measured wind | `wind_ms` |
| `snow_stow` | controller stows at its snow angle (Feldmelder Beyin: DWD snow/ice warning for the site's warn cell or a snow sensor) | `reason` (`snow`), `trigger` (`dwd`/`sensor`), `stow_deg`; with `dwd` the fields of `weather_warning` below; with `sensor` `sensor_value` |
| `weather_warning` | controller stows for an official weather warning (Feldmelder Beyin: DWD hail/storm warnings for the site's warn cell) | `source` (`DWD`), `event` (DWD text, unchanged), `ii` (DWD event code), `level` (1–4), `groups`, `onset`, `expires` (RFC 3339 UTC), `reason` (`hail`/`wind`), `warncell`, `hail`, `gusts`, `attribution` ("Quelle: Deutscher Wetterdienst") |

A stow for a weather warning reports `mode = stow` with `mode_reason = hail` (hail) or `wind` (storm) in the
tracker state, a snow stow `mode_reason = snow`, so it counts as a justified stow (section 10, availability).

### 6.5 cmd/ack (on event)

| Field | Type | Req. | Meaning |
|---|---|---|---|
| `cmd_id` | UUID | yes | from the command |
| `result` | `accepted` \| `rejected` \| `executed` | yes | |
| `reason` | string ≤ 300 | if rejected | `expired`, `bad_signature`, `unknown_key`, `unknown_type`, `unsupported`, `invalid_params`, `interlock`, `replay`, `busy`, `other` |

Sequence: either `rejected`, or `executed`, optionally preceded by `accepted` when execution takes
longer than a few seconds. Exactly one final ack (`rejected` or `executed`) per `cmd_id`.

## 7. Messages portal → box

### 7.1 cmd

```json
{"v":1,"ts":"2026-06-21T10:00:00Z","seq":7,"payload":{
  "cmd_id":"0b8f3c2e-5d4a-4e1b-9c7d-2f6a8e1b3c5d","type":"stow","params":{"trackers":"all"},
  "issued_at":"2026-06-21T10:00:00Z","ttl_s":120,
  "issued_by_hash":"3f3f…3f","key_id":"56475aa75463474c",
  "sig":"wV4sF09dnr-td2bmaA0GXVThKF34japirYLLIe0z1_2zwZuKcJUzKbUNSfg0pdkMPYkJZ9rpvaafauNRC1WGBA"}}
```

| Field | Meaning |
|---|---|
| `cmd_id` | UUID v4, unique |
| `type` | `stow`, `auto`, `maintenance` (V0). `manual_angle`, `config` are **reserved** (V1); a v1 box MUST reject them with `unsupported` |
| `params.trackers` | `"all"` or array of tracker indices |
| `params.duration_s` | `maintenance` only: 60..604800 s; the box returns to `auto` afterwards unless renewed |
| `issued_at` | UTC time of issue |
| `ttl_s` | validity in seconds (portal default 120, range 10..3600) |
| `issued_by_hash` | SHA-256 (hex) identifying the issuing account; contains no personal data; for the box's log |
| `key_id` | first 16 hex digits of SHA-256 over the raw 32-byte Ed25519 public key |
| `sig` | Ed25519 signature, base64url without padding (86 chars) |

### 7.2 Command signing

The signature covers the **canonical JSON** of exactly these six fields:
`cmd_id`, `type`, `params`, `issued_at`, `ttl_s`, `issued_by_hash` (not `key_id`, not `sig`).

Canonical JSON:
1. object keys sorted by Unicode code point, at every nesting level;
2. no whitespace; separators `,` and `:`;
3. strings as UTF-8, non-ASCII characters emitted literally (not `\u` escaped), standard JSON
   escaping for `"`, `\` and control characters;
4. integers in plain decimal; **no floating-point numbers anywhere in the signed object**
   (the portal never sends them, a box MUST reject a command containing one with `invalid_params`);
5. `true`, `false`, `null` lowercase.

The box MUST verify, in this order, and answer with the first failing reason:
1. JSON/schema valid → else `invalid_params` (or ignore if `cmd_id` is unreadable);
2. `cmd_id` already processed → re-send the stored final ack (no new execution, no new reason);
3. `key_id` equals the stored `cmd_key_id` → else `unknown_key`;
4. signature valid with the stored public key → else `bad_signature`;
5. `now ≤ issued_at + ttl_s` and `issued_at ≤ now + 300 s` (box clock) → else `expired`;
6. `type` known and supported → else `unsupported` / `unknown_type`;
7. parameters valid for this box (tracker indices exist) → else `invalid_params`;
8. local safety allows it → else `interlock`.

The box MUST remember processed `cmd_id`s for at least 2 × 3600 s (replay protection) and MUST
reject unsigned commands.

**Test vector** (do not use these keys in production). Ed25519 private key seed (hex)
`000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f`, public key (base64url)
`A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg`, `key_id` `56475aa75463474c`.
Signed fields: `cmd_id 0b8f3c2e-5d4a-4e1b-9c7d-2f6a8e1b3c5d`, `type stow`, `params {"trackers":"all"}`,
`issued_at 2026-06-21T10:00:00Z`, `ttl_s 120`, `issued_by_hash` = `3f` repeated 32 times. Canonical JSON:

```
{"cmd_id":"0b8f3c2e-5d4a-4e1b-9c7d-2f6a8e1b3c5d","issued_at":"2026-06-21T10:00:00Z","issued_by_hash":"3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f","params":{"trackers":"all"},"ttl_s":120,"type":"stow"}
```

Signature: `wV4sF09dnr-td2bmaA0GXVThKF34japirYLLIe0z1_2zwZuKcJUzKbUNSfg0pdkMPYkJZ9rpvaafauNRC1WGBA`

### 7.3 Command semantics

| type | effect | final ack |
|---|---|---|
| `stow` | selected trackers go to the stow position, `mode = stow`, `mode_reason = command`; they stay there until an `auto` command or local release | `executed` once the order is given to the drives |
| `auto` | end command stow / maintenance for the selected trackers and resume tracking | `rejected` with `interlock` while a local safety stow (wind, snow…) is active |
| `maintenance` | selected trackers stop and report `mode = maintenance` until `duration_s` elapsed or `auto` | `executed` |

**Local safety always wins.** No command can override a stow decided by the box. The portal never
retries a command automatically. If no final ack arrives within `ttl_s`, the portal marks the
command `no_ack` and raises a *command_no_ack* alarm (medium). A late ack is still recorded.

## 8. Store-and-forward

1. While the broker is unreachable, the box MUST keep creating messages on schedule (with seq and
   original `ts`) and store them in a non-volatile FIFO buffer of at least **72 hours** of
   messages (heartbeat, all tracker states, weather, alarm, cmd/ack). When full, the oldest
   heartbeats SHOULD be dropped first.
2. After reconnecting, the box SHOULD first publish one live heartbeat, then MUST replay the
   buffer **oldest first**, unchanged (same `ts`, same `seq`), interleaving live messages is
   allowed. RECOMMENDED replay rate ≤ 50 messages/s.
3. Replayed messages MUST be published with `retain = false`.
4. Commands that the broker delivers after reconnect are verified normally; they are usually
   `expired` and answered with a `rejected` ack.
5. A reboot is not an outage of the buffer: buffered messages MUST survive a reboot.

The portal stores late messages idempotently (key: tracker/box + `ts`), fills the data gap and
keeps the newest state as "current".

## 9. Portal-side validation (for debugging)

The portal never drops a message silently. Rejected messages are logged with one of these
reasons (visible to the platform operator): `bad_topic`, `not_box_topic`, `unknown_box`,
`box_not_active`, `prefix_mismatch`, `too_large`, `bad_json`, `schema`, `bad_timestamp`,
`future_ts`, `too_old`, `internal_error`.

## 10. What the portal derives (informative)

Default alarm rules (thresholds editable per plant; see the portal documentation):
site offline (no heartbeat > 5 min), tracker offline (no state > 10 min), tracking deviation
(|target − actual_a| > 3° for > 10 min in `auto`), drive sync deviation (|actual_a − actual_b| > 1.5°
for > 2 min), motor fault, wind stow active (info), wind sensor fault (no weather > 15 min or
identical non-zero value for 15 min, or only `null`), battery low, mode stuck (`manual`/`maintenance`
> 24 h), command not acknowledged. Boxes therefore SHOULD report `actual_deg_b = null` (not a copy of
A) when there is no second sensor, and `null` for failed wind sensors.

## 11. Extensibility and versioning

* Payload objects accept additional properties. The portal ignores unknown fields for processing
  and keeps them for 30 days for diagnostics. Vendors SHOULD prefix private fields
  (e.g. `acme_torque_nm`).
* The envelope does not accept additional keys.
* Backwards-compatible additions keep `v = 1` and are documented as v1.x. A breaking change uses
  `v = 2` and a new topic root `ttp/v2/`.
* Third-party controllers implement this document as is. Gateways for Modbus TCP based controllers
  are planned (V1).

## 12. Conformance checklist for box implementers

- [ ] key pair generated on the device, CSR with `CN=<serial>`, enrollment via claim code, bundle persisted
- [ ] mTLS to `mqtt_host:mqtt_port`, server certificate and host name verified, `clean_session=false`, QoS 1
- [ ] topic prefix taken from enrollment, never constructed
- [ ] heartbeat 30 s (retained), tracker state 60 s per tracker (retained), weather 60 s
- [ ] envelope `v`, UTC `ts` with `Z`, single persisted per-box `seq`
- [ ] `null` for unavailable values (second sensor, failed wind sensor)
- [ ] store-and-forward ≥ 72 h, ordered replay with original `ts`/`seq`, `retain=false` on replay
- [ ] command verification order 7.2, replay memory ≥ 2 h, exactly one final ack per command
- [ ] local safety (wind stow etc.) never overridden; `auto` during safety stow → `rejected/interlock`
- [ ] `manual_angle` / `config` → `rejected/unsupported`; `ota` ignored
- [ ] clock synchronised to ±2 s
- [ ] all messages validate against `protocol/schemas/*.json`

The repository's box simulator (`services/boxsim/`) is a conforming reference client and can be
used to compare behaviour.
