{"openapi":"3.1.0","info":{"title":"s5 backend","description":"Device fleet API. Devices themselves speak the protobuf wire protocol on a separate port; this API is for operators and tooling.","license":{"name":"Apache-2.0","identifier":"Apache-2.0"},"version":"0.1.0"},"servers":[{"url":"https://s5.hemala.de/api","description":"This deployment"}],"paths":{"/devices":{"get":{"tags":["devices"],"operationId":"list_devices","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DeviceSummary"}}}}}}}},"/devices/by-mac/{mac}":{"get":{"tags":["devices"],"operationId":"get_device_by_mac","parameters":[{"name":"mac","in":"path","description":"MAC as 00:70:07:24:3b:87, 00-70-07-24-3b-87 or 007007243b87","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceDetail"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}}},"/devices/{id}":{"get":{"tags":["devices"],"operationId":"get_device","parameters":[{"name":"id","in":"path","description":"Device id, e.g. 7X2QM9","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceDetail"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}},"patch":{"tags":["devices"],"operationId":"set_device_label","parameters":[{"name":"id","in":"path","description":"Device id, e.g. 7X2QM9","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LabelUpdate"}}},"required":true},"responses":{"204":{"description":"Label updated"},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}}},"/devices/{id}/commands":{"get":{"tags":["devices"],"operationId":"list_commands","parameters":[{"name":"id","in":"path","description":"Device id, e.g. 7X2QM9","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Rows to return. Clamped to a sane range by the query layer.","required":false,"schema":{"type":["integer","null"],"format":"int64","default":100,"maximum":1000,"minimum":1}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CommandRecord"}}}}}}},"post":{"tags":["devices"],"operationId":"create_command","parameters":[{"name":"id","in":"path","description":"Device id, e.g. 7X2QM9","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewCommand"}}},"required":true},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommandQueued"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"409":{"description":"the command cannot be honoured while the device is absent — an actuator override is only accepted for a connected device","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}}},"/devices/{id}/config":{"get":{"tags":["devices"],"operationId":"get_device_config","parameters":[{"name":"id","in":"path","description":"Device id, e.g. 7X2QM9","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceConfig"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}},"put":{"tags":["devices"],"operationId":"put_device_config","parameters":[{"name":"id","in":"path","description":"Device id, e.g. 7X2QM9","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceConfig"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceConfig"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}}},"/devices/{id}/telemetry":{"get":{"tags":["devices"],"operationId":"list_telemetry","parameters":[{"name":"id","in":"path","description":"Device id, e.g. 7X2QM9","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Rows to return. Clamped to a sane range by the query layer.","required":false,"schema":{"type":["integer","null"],"format":"int64","default":100,"maximum":1000,"minimum":1}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Sample"}}}}}}}},"/health":{"get":{"tags":["meta"],"operationId":"health","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Health"}}}}}}}},"components":{"schemas":{"CommandQueued":{"type":"object","description":"What the caller gets back after queueing a command.","required":["id","delivered"],"properties":{"delivered":{"type":"boolean","description":"True when the command went straight onto a live connection.\n\nFalse means the row is written but nothing carried it: the device was\ngone by the time the command was handed over, or its session's queue was\nfull. What the next connect does with it then depends on the kind. A\n`reboot` is drained and delivered; every other kind is *superseded* at\nthat connect rather than drained, because it either no longer means what\nit meant or is re-derived fresher by the reconcile. So `false` means\n\"queued\", not \"will be delivered\", for anything but a `reboot`."},"id":{"type":"integer","format":"int64","description":"The command's id, which the device echoes in its ack."}}},"CommandRecord":{"type":"object","description":"A row of `commands`.","required":["id","created_at","created_by","kind","payload"],"properties":{"abandoned_at":{"type":["string","null"],"format":"date-time","description":"Sent, then the connection died before the device answered. Without this\na lost command is indistinguishable from one in flight right now."},"accepted":{"type":["boolean","null"],"description":"`None` until the device answers."},"acked_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string"},"error":{"type":["string","null"]},"id":{"type":"integer","format":"int64"},"kind":{"type":"string","description":"The `Command.kind` variant name, e.g. `set_actuators`."},"payload":{"type":"object"},"sent_at":{"type":["string","null"],"format":"date-time"},"superseded_at":{"type":["string","null"],"format":"date-time","description":"Never sent, and never will be. Two statements write it, and a reader of\nthe history cannot tell them apart from the row alone: the connect-time\nsweep ([`supersede_pending_non_queueing`]), which retires a pending\ncommand that no longer means what it meant when it was issued, and\n[`enqueue`]'s CTE, which retires the pending command a newer one of the\nsame kind fully replaces. So a `superseded` row is not evidence of a\nreconnect: a second configuration save produces one without the device\nhaving gone anywhere."}}},"Device":{"type":"object","description":"A row of `devices`.","required":["id","mac","hardware_revision","firmware_version","firmware_build","first_seen_at"],"properties":{"firmware_build":{"type":"string"},"firmware_version":{"type":"string"},"first_seen_at":{"type":"string","format":"date-time"},"hardware_revision":{"type":"string"},"id":{"type":"string","example":"7X2QM9"},"label":{"type":["string","null"]},"mac":{"type":"string","description":"Colon-separated hex, e.g. `00:70:07:24:3b:87`.","example":"00:70:07:24:3b:87"}}},"DeviceConfig":{"type":"object","description":"A row of `device_config`: what the backend wants the device to do.","required":["telemetry_interval_ms","chiller_enabled","chiller_sensor_index","chiller_setpoint_c","chiller_hysteresis_c","pump_enabled","pump_sensor_index","pump_min_temp_c","pump_max_temp_c","pump_min_duty","pump_max_duty"],"properties":{"chiller_enabled":{"type":"boolean"},"chiller_hysteresis_c":{"type":"number","format":"float"},"chiller_sensor_index":{"type":"integer","format":"int32"},"chiller_setpoint_c":{"type":"number","format":"float"},"pump_enabled":{"type":"boolean"},"pump_max_duty":{"type":"integer","format":"int32"},"pump_max_temp_c":{"type":"number","format":"float"},"pump_min_duty":{"type":"integer","format":"int32"},"pump_min_temp_c":{"type":"number","format":"float"},"pump_sensor_index":{"type":"integer","format":"int32"},"telemetry_interval_ms":{"type":"integer","format":"int32"},"version":{"type":["integer","null"],"format":"int32","description":"The optimistic-concurrency token. Always set on a row read from the\ndatabase; `None` on the way in, so a body without it gets a `400` naming\nthe field rather than axum's plain-text `422`."}}},"DeviceDetail":{"allOf":[{"$ref":"#/components/schemas/Device"},{"type":"object","required":["online","config"],"properties":{"config":{"$ref":"#/components/schemas/DeviceConfig"},"latest_telemetry":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/Sample","description":"Most recent telemetry sample, if the device has ever reported."}]},"online":{"type":"boolean"}}}],"description":"A device with the state an operator needs on one screen."},"DeviceSummary":{"type":"object","description":"A device plus the derived state the list view needs.","required":["id","mac","firmware_version","online"],"properties":{"firmware_version":{"type":"string"},"id":{"type":"string","example":"7X2QM9"},"label":{"type":["string","null"]},"last_telemetry_at":{"type":["string","null"],"format":"date-time","description":"When the most recent telemetry sample arrived, if any."},"mac":{"type":"string","example":"00:70:07:24:3b:87"},"online":{"type":"boolean","description":"True while the device's most recent session is still open."}}},"ErrorBody":{"type":"object","description":"The JSON body a failed request gets.","required":["code","message"],"properties":{"code":{"type":"string","description":"Stable machine-readable code, e.g. `not_found`."},"message":{"type":"string","description":"Human-readable detail. Empty for `internal`, which never echoes the\nunderlying error."}}},"Health":{"type":"object","description":"Liveness, a fleet summary, and the settings an operator needs to confirm\nwithout shell access to the config file.","required":["status","version","devices_connected","database_ok","device_listener","device_listener_tls","default_telemetry_interval_ms"],"properties":{"database_ok":{"type":"boolean","description":"Whether the database answered."},"default_telemetry_interval_ms":{"type":"integer","format":"int32","description":"Telemetry interval handed to a device that has no stored configuration.","minimum":0},"device_listener":{"type":"string","description":"Address devices connect to."},"device_listener_tls":{"type":"boolean","description":"Whether that listener requires TLS. False is development-only."},"devices_connected":{"type":"integer","description":"Devices with a live session right now.","minimum":0},"status":{"type":"string","description":"Always `\"ok\"` — the endpoint failing at all is the signal."},"version":{"type":"string"}}},"LabelUpdate":{"type":"object","description":"Body for [`set_device_label`].","properties":{"label":{"type":["string","null"],"description":"`null` clears the label."}}},"NewCommand":{"oneOf":[{"type":"object","description":"Direct actuator override. Omitted fields are left alone.","required":["kind"],"properties":{"chiller_on":{"type":["boolean","null"]},"heater_1_duty":{"type":["integer","null"],"format":"int32","minimum":0},"heater_2_duty":{"type":["integer","null"],"format":"int32","minimum":0},"kind":{"type":"string","enum":["set_actuators"]},"pump_a_duty":{"type":["integer","null"],"format":"int32","minimum":0},"pump_b_duty":{"type":["integer","null"],"format":"int32","minimum":0}}},{"type":"object","description":"Restart the device.","required":["kind"],"properties":{"delay_ms":{"type":["integer","null"],"format":"int32","description":"Grace period before the reset, so the device can ack first.","minimum":0},"kind":{"type":"string","enum":["reboot"]}}}],"description":"A command an operator can issue.\n\nDeliberately not the protobuf type: this is the operator-facing contract and\nit validates its own ranges, whereas the protobuf message is the transport\nand must stay permissive for forward compatibility."},"Sample":{"type":"object","description":"A stored telemetry sample: the extracted series, without the payload.\n\nThe verbatim message is deliberately not joined back on. The point of the\nsplit is that the series stays narrow enough to scan, and after retention\nruns the field would be `null` on old rows and populated on new ones, which\nreads as data loss.\n\nTemperatures are decidegrees Celsius, humidity is hundredths of a percent\nand acceleration is milli-g. A `None` is data: that sensor was not\nreporting.","required":["id","received_at","uptime_ms","power_12v_ok","remote_stale"],"properties":{"boiler_temp_c10":{"type":["integer","null"],"format":"int32"},"chiller_air_temp_c10":{"type":["integer","null"],"format":"int32"},"chiller_on":{"type":["boolean","null"],"description":"`None` when the message carried no `Actuators`, which is not the same as\nthe mains relay having been open."},"flow_temp_c10":{"type":["integer","null"],"format":"int32"},"gas_temp_c10":{"type":["integer","null"],"format":"int32"},"heater_1_duty":{"type":["integer","null"],"format":"int32"},"heater_2_duty":{"type":["integer","null"],"format":"int32"},"id":{"type":"integer","format":"int64"},"power_12v_ok":{"type":"boolean"},"pump_a_duty":{"type":["integer","null"],"format":"int32"},"pump_a_tacho_pulses":{"type":["integer","null"],"format":"int32"},"pump_b_duty":{"type":["integer","null"],"format":"int32"},"pump_b_tacho_pulses":{"type":["integer","null"],"format":"int32"},"received_at":{"type":"string","format":"date-time"},"remote_accel_x_mg":{"type":["integer","null"],"format":"int32"},"remote_accel_y_mg":{"type":["integer","null"],"format":"int32"},"remote_accel_z_mg":{"type":["integer","null"],"format":"int32"},"remote_humidity_pct100":{"type":["integer","null"],"format":"int32"},"remote_stale":{"type":"boolean"},"remote_temp_c10":{"type":["integer","null"],"format":"int32"},"return_temp_c10":{"type":["integer","null"],"format":"int32"},"sampled_at":{"type":["string","null"],"format":"date-time","description":"The device's own clock at sampling time. `None` when the device had no\nsynchronised clock."},"tacho_window_ms":{"type":["integer","null"],"format":"int32"},"uptime_ms":{"type":"integer","format":"int64"}}}}},"tags":[{"name":"devices","description":"Device registry, configuration and control"},{"name":"meta","description":"Service metadata"}]}