{"openapi": "3.1.0", "info": {"title": "BluNotify Partner API", "version": "1.0.0", "description": "REST API for approved partners to work with BluNotify devices in their scope: notification lights (BluNotify) and energy monitors (BluEM, under /v1/bluem). All endpoints require a bearer API key and HTTPS. Every response carries an X-Request-Id header, and every error body the same value as request_id: quote it to BluNotify support. Every response to an authenticated request carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset for the per-partner request limit. Quick-start guide at /docs; full reference at /docs_adv. Versioning: after launch /v1 only gains things (new routes, optional parameters, fields and error codes), so a client must ignore fields it does not recognise. Anything to be removed is deprecated first, with at least six months before its sunset; a response that uses it carries Deprecation, Sunset and Link (rel=\"deprecation\") headers, and the operation is marked deprecated here. Changes are listed at /changelog. Every /v1 answer carries Cache-Control: no-store. Bodies are JSON objects sent as application/json (415 otherwise). A create answers 201 with Location, an accepted command 202, and an update or removal with nothing to return 204."}, "servers": [{"url": "https://api.blunotify.com"}], "security": [{"bearerAuth": []}], "components": {"securitySchemes": {"bearerAuth": {"type": "http", "scheme": "bearer", "description": "API key issued by BluNotify staff, format bnp_<handle>_<secret>."}}, "schemas": {"Error": {"type": "object", "description": "Every error response. `code` is the stable machine word; `error` is a sentence for people and may be reworded. Codes in use: unauthorized, forbidden, not_found (no such device, or not in your scope), unknown_route (no route matches the URL: check the base URL and path), method_not_allowed, conflict, confirmation_required, validation_error, rate_limited, publish_failed, internal_error, node_out_of_scope, would_lose_access, already_attached, limit_reached, name_taken, in_use, encryption_unavailable, device_offline, idempotency_key_reused, idempotency_in_progress, unsupported_media_type. New codes may be added, so treat an unknown one by its HTTP status.", "properties": {"error": {"type": "string"}, "code": {"type": "string"}, "request_id": {"type": "string", "description": "The same value as the X-Request-Id header."}, "required_permission": {"type": "string", "description": "On a 403 refused because the key lacks a permission the request needs: its name, as GET /v1/account lists it (for example energy_read). Permissions are checked first, so with it granted the request can still be refused for something else. Absent on a 403 no permission would lift, and never on a 404."}}, "required": ["error", "code", "request_id"]}, "CommandResult": {"type": "object", "properties": {"channel": {"type": "string"}, "payload": {"type": "string"}, "command": {"type": "string"}}}, "BluEMIntervalRecord": {"type": "object", "description": "One BluEM interval record. current_a, power_w and energy_wh are corrected for CT turns (the raw meter readings divided by ct_turns); voltage_v, frequency_hz and power_factor are as measured. A quantity is null when the record carries no reading for it (quarantined, or reads.n = 0).", "properties": {"seq": {"type": "integer"}, "sid": {"type": "string", "description": "Stream id, 8 hex characters."}, "start_at": {"type": ["string", "null"], "format": "date-time", "description": "When the interval began; null when the meter could not know (time_quality unknown)."}, "time_quality": {"type": "string", "enum": ["device", "derived", "unknown"]}, "duration_s": {"type": ["integer", "null"]}, "reads": {"type": "object", "properties": {"n": {"type": ["integer", "null"]}, "err": {"type": ["integer", "null"]}}}, "voltage_v": {"$ref": "#/components/schemas/BluEMStat", "description": "Voltage, in volts (V), as measured."}, "current_a": {"$ref": "#/components/schemas/BluEMStat", "description": "Current, in amperes (A): the real load, already divided by ct_turns."}, "power_w": {"$ref": "#/components/schemas/BluEMStat", "description": "Real (active) power, in watts (W): the real load, already divided by ct_turns."}, "frequency_hz": {"$ref": "#/components/schemas/BluEMStat", "description": "Mains frequency, in hertz (Hz), as measured."}, "power_factor": {"$ref": "#/components/schemas/BluEMStat", "description": "Power factor, without unit (0 to 1), as measured."}, "energy_wh": {"type": ["object", "null"], "description": "The meter's lifetime energy counter, in watt-hours (Wh), at the record's first and last good read, divided by ct_turns (so fractional when ct_turns > 1).", "properties": {"first": {"type": ["number", "null"]}, "last": {"type": ["number", "null"]}}}, "ct_turns": {"type": ["integer", "null"], "description": "CT turns the record was measured under (firmware emCtTurns; 1 when the record omitted it). a, w and energy_wh are already divided by it. null on a quarantined record whose ct was unusable."}, "alarm_reads": {"type": ["integer", "null"]}, "flags": {"type": "object", "properties": {"partial": {"type": "boolean"}, "energy_reset": {"type": "boolean"}, "meter_reconfigured": {"type": "boolean"}}}, "quarantined": {"type": "boolean"}, "quarantine_reason": {"type": ["string", "null"]}, "received_at": {"type": "string", "format": "date-time", "description": "When the server took the record off the network. Much later than start for a record the meter stored through an outage."}}, "example": {"seq": 118, "sid": "1a2b3c4d", "start_at": "2026-09-25T11:45:00+00:00", "time_quality": "device", "duration_s": 900, "reads": {"n": 900, "err": 0}, "voltage_v": {"avg": 229.83, "min": 228.1, "max": 231.4}, "current_a": {"avg": 1.203, "min": 0.9, "max": 1.6}, "power_w": {"avg": 276.1, "min": 205.0, "max": 370.0}, "frequency_hz": {"avg": 50.01, "min": 49.9, "max": 50.1}, "power_factor": {"avg": 0.947, "min": 0.9, "max": 0.99}, "energy_wh": {"first": 100020.0, "last": 100089.0}, "ct_turns": 1, "alarm_reads": 0, "flags": {"partial": false, "energy_reset": false, "meter_reconfigured": false}, "quarantined": false, "quarantine_reason": null, "received_at": "2026-09-25T12:00:01.532+00:00"}}, "BluEMStat": {"type": ["object", "null"], "description": "avg / min / max of one quantity over the interval, in the unit its name ends with, rounded to 6 decimal places; null when the record carries no reading for it (quarantined, or reads.n = 0).", "properties": {"avg": {"type": "number"}, "min": {"type": "number"}, "max": {"type": "number"}}}, "SysCommandResult": {"type": "object", "description": "Fire-and-forget acknowledgement (202). Echoes exactly what was published; poll /v1/devices/{id}/system/responses to observe the device's reply.", "properties": {"topic": {"type": "string"}, "payload": {"type": "string"}, "sent_at": {"type": "string", "format": "date-time"}, "command": {"type": "string"}, "key": {"type": "string"}}}, "BluEMLiveReading": {"type": "object", "description": "What the meter read, corrected for CT turns. A quantity the meter has no value for is null, never zero.", "properties": {"link": {"type": "string", "enum": ["ok", "timeout", "crc", "exception", "badreply", "notstarted", "unknown"], "description": "The unit's link to its meter at its last read."}, "answered": {"type": "boolean", "description": "True only when link is ok."}, "voltage_v": {"type": ["number", "null"], "description": "Voltage, in volts (V), as measured."}, "current_a": {"type": ["number", "null"], "description": "Current, in amperes (A): the real load, already divided by ct_turns."}, "power_w": {"type": ["number", "null"], "description": "Real (active) power, in watts (W): the real load, already divided by ct_turns."}, "power_factor": {"type": ["number", "null"], "description": "Power factor, without unit (0 to 1), as measured. Null when nothing is drawing power."}, "frequency_hz": {"type": ["number", "null"], "description": "Mains frequency, in hertz (Hz), as measured."}, "ct_turns": {"type": "integer", "description": "The CT turns the unit is set to."}, "age_s": {"type": ["number", "null"], "description": "Seconds between the meter reading these values and its answer."}, "taken_at": {"type": "string", "format": "date-time", "description": "When the answer reached the server."}, "took_s": {"type": "number", "description": "Seconds from the request to the answer, to the millisecond (for example 1.278)."}}, "example": {"link": "ok", "answered": true, "voltage_v": 122.0, "current_a": 0.6268, "power_w": 58.54, "power_factor": 0.77, "frequency_hz": 60.0, "ct_turns": 5, "age_s": 0, "taken_at": "2026-09-30T14:02:11.204+00:00", "took_s": 1.278}}, "Window": {"type": "object", "required": ["start", "end"], "description": "The half-open window [start, end) the answer covers.", "properties": {"start": {"type": "string", "format": "date-time"}, "end": {"type": "string", "format": "date-time"}}}, "Account": {"type": "object", "required": ["partner"], "properties": {"partner": {"type": "object", "required": ["name", "key_prefix", "permissions", "allowed_channels", "rate_limits"], "properties": {"name": {"type": "string"}, "key_prefix": {"type": "string"}, "permissions": {"type": "object", "description": "Every permission, granted or not. A 403 names a missing one by these keys (required_permission).", "properties": {"raw_commands": {"type": "boolean"}, "predefined_commands": {"type": "boolean"}, "state_read": {"type": "boolean"}, "health_read": {"type": "boolean"}, "automation_control": {"type": "boolean"}, "relaxed_caps": {"type": "boolean"}, "device_provisioning": {"type": "boolean"}, "device_removal": {"type": "boolean"}, "system_read": {"type": "boolean"}, "system_control": {"type": "boolean"}, "system_destructive": {"type": "boolean"}, "file_upload": {"type": "boolean"}, "file_delete": {"type": "boolean"}, "raw_mqtt_devices": {"type": "boolean"}, "raw_mqtt_global": {"type": "boolean"}, "access_all_devices": {"type": "boolean"}, "config_read": {"type": "boolean"}, "config_write": {"type": "boolean"}, "profile_manage": {"type": "boolean"}, "energy_reset": {"type": "boolean"}, "energy_read": {"type": "boolean"}, "signal_manage": {"type": "boolean"}}, "required": ["raw_commands", "predefined_commands", "state_read", "health_read", "automation_control", "relaxed_caps", "device_provisioning", "device_removal", "system_read", "system_control", "system_destructive", "file_upload", "file_delete", "raw_mqtt_devices", "raw_mqtt_global", "access_all_devices", "config_read", "config_write", "profile_manage", "energy_reset", "energy_read", "signal_manage"]}, "allowed_channels": {"type": "array", "items": {"type": "string"}}, "rate_limits": {"type": "object", "properties": {"requests_per_min": {"type": "integer"}, "device_commands_per_min": {"type": "integer"}}}}}}}, "Device": {"type": "object", "required": ["device_id", "name", "product"], "properties": {"device_id": {"type": "string"}, "name": {"type": ["string", "null"]}, "product": {"type": ["string", "null"], "description": "The product the device was provisioned as: 'blunotify' (notification light) or 'bluem' (energy monitor). Null only for a device of a product this server does not know."}, "channels": {"type": "array", "items": {"type": "string"}, "description": "The channels the partner may command. Only on devices of a product that has named channels (BluNotify); absent on a BluEM meter."}, "automation_enabled": {"type": "boolean", "description": "BluNotify devices only: whether temperature automation drives the light (PUT .../automation)."}, "status": {"type": ["string", "null"], "description": "With the health-read permission only."}, "status_changed_at": {"type": ["string", "null"], "format": "date-time", "description": "With the health-read permission only."}, "last_seen_at": {"type": ["string", "null"], "format": "date-time", "description": "With the health-read permission only."}}}, "BluEMEnergyTotal": {"type": "object", "required": ["energy_wh", "resets", "intervals", "buckets"], "properties": {"energy_wh": {"type": "number", "description": "Consumption over the window, in watt-hours (Wh)."}, "resets": {"type": "integer", "description": "Energy-counter resets seen in the window."}, "intervals": {"type": "integer", "description": "Records the consumption is summed over."}, "buckets": {"type": "integer", "description": "Buckets with a reading."}}}, "BluEMQuantitySummary": {"type": "object", "required": ["avg", "min", "max", "intervals", "seconds"], "description": "One quantity over the records that carry it.", "properties": {"avg": {"type": "number", "description": "Duration-weighted: sum(avg x duration_s) / sum(duration_s)."}, "min": {"type": "number", "description": "The lowest record min."}, "max": {"type": "number", "description": "The highest record max."}, "intervals": {"type": "integer", "description": "Records that carry the quantity."}, "seconds": {"type": "integer", "description": "Their summed duration_s."}}}, "BluEMSummary": {"type": "object", "required": ["device_id", "window", "coverage", "voltage_v", "current_a", "power_w", "frequency_hz", "power_factor", "energy_wh"], "properties": {"device_id": {"type": "string"}, "window": {"$ref": "#/components/schemas/Window"}, "coverage": {"type": "object", "required": ["records", "quarantined", "seconds"], "properties": {"records": {"type": "integer", "description": "Records with a start in the window."}, "quarantined": {"type": "integer", "description": "Of those, quarantined."}, "seconds": {"type": "integer", "description": "Summed duration_s of the ones not quarantined."}}}, "voltage_v": {"anyOf": [{"$ref": "#/components/schemas/BluEMQuantitySummary"}, {"type": "null"}], "description": "Voltage, in volts (V), as measured."}, "current_a": {"anyOf": [{"$ref": "#/components/schemas/BluEMQuantitySummary"}, {"type": "null"}], "description": "Current, in amperes (A): the real load, already divided by ct_turns."}, "power_w": {"anyOf": [{"$ref": "#/components/schemas/BluEMQuantitySummary"}, {"type": "null"}], "description": "Real (active) power, in watts (W): the real load, already divided by ct_turns."}, "frequency_hz": {"anyOf": [{"$ref": "#/components/schemas/BluEMQuantitySummary"}, {"type": "null"}], "description": "Mains frequency, in hertz (Hz), as measured."}, "power_factor": {"anyOf": [{"$ref": "#/components/schemas/BluEMQuantitySummary"}, {"type": "null"}], "description": "Power factor, without unit (0 to 1), as measured. Over the records with a load only: those whose highest power before CT correction (power_w.max x ct_turns) reached 5 W. With nothing drawing, the meter reports a placeholder, not a ratio. A duration-weighted average of the records' own averages, not an energy-weighted power factor."}, "energy_wh": {"anyOf": [{"$ref": "#/components/schemas/BluEMSummaryEnergy"}, {"type": "null"}], "description": "Null when no record carried a counter reading."}}}, "FileTransfer": {"type": "object", "required": ["transfer_id", "device_id", "state", "terminal", "src", "dest", "sha256", "attempts", "poll_after_s", "timeline"], "properties": {"transfer_id": {"type": "string"}, "device_id": {"type": "string"}, "state": {"type": "string", "enum": ["queued", "sent", "succeeded", "failed", "timed_out"]}, "terminal": {"type": "boolean", "description": "Final: stop polling."}, "src": {"type": "string", "description": "The platform file pushed."}, "dest": {"type": "string", "description": "Its name on the device."}, "sha256": {"type": "string"}, "corr_mode": {"type": "string"}, "attempts": {"type": "integer"}, "detail": {"type": ["string", "null"], "description": "The device's reply, or why it timed out."}, "timeline": {"type": "object", "properties": {"created_at": {"type": ["string", "null"], "format": "date-time"}, "sent_at": {"type": ["string", "null"], "format": "date-time"}, "ack_deadline": {"type": ["string", "null"], "format": "date-time"}, "settled_at": {"type": ["string", "null"], "format": "date-time"}}}, "note": {"type": "string", "description": "While sent: the device drops its MQTT session while it downloads."}, "device_online": {"type": "boolean", "description": "POST only: whether the device was connected when the push was sent."}, "poll_after_s": {"type": ["number", "null"], "description": "Seconds to wait before polling again; null once terminal."}}}, "BluEMSummaryEnergy": {"type": "object", "required": ["total", "resets", "intervals"], "properties": {"total": {"type": "number", "description": "Consumption over the window, in watt-hours (Wh): the total GET .../energy gives for the same window."}, "resets": {"type": "integer", "description": "Energy-counter resets seen in the window."}, "intervals": {"type": "integer", "description": "Records the consumption is summed over."}}}, "AudioCheck": {"type": "object", "description": "How a sound file measures up against what a BluNotify speaker plays best: mono MP3, 16 kHz, 48 kbps constant bitrate, no tags or cover art, mastered loud. Issue codes may be added over time; warnings never block an upload.", "required": ["playable", "ideal", "fixable", "summary", "issues"], "properties": {"playable": {"type": "boolean", "description": "The file holds MP3 (MPEG Layer III) audio a device can play."}, "ideal": {"type": "boolean", "description": "No warnings: the file is ready for the device as it is."}, "fixable": {"type": "boolean", "description": "Converting the file to the recommended format would fix at least one warning."}, "summary": {"type": "object", "properties": {"text": {"type": "string", "description": "One line for people, e.g. \"44.1 kHz, stereo, 192 kbps, 2 min 38 s, 3.63 MB, 37.2% of device storage\"."}, "format": {"type": ["string", "null"], "description": "e.g. \"MPEG-1 Layer III\", or the real format of a file that is not MP3 (\"WAV\")."}, "sample_rate_hz": {"type": ["integer", "null"]}, "channels": {"type": ["integer", "null"]}, "channel_mode": {"type": ["string", "null"], "enum": ["stereo", "joint_stereo", "dual_channel", "mono", null]}, "bitrate_kbps": {"type": ["integer", "null"], "description": "Average over the audio."}, "vbr": {"type": "boolean"}, "duration_s": {"type": "number"}, "size_bytes": {"type": "integer"}, "device_storage_pct": {"type": "number", "description": "Share of a device's sound-file storage the file takes."}, "tag_bytes": {"type": "integer", "description": "ID3 tags, cover art included."}, "cover_art": {"type": "boolean"}, "header_frame": {"type": ["string", "null"], "enum": ["Xing", "Info", "VBRI", null]}}}, "issues": {"type": "array", "description": "Most serious first.", "items": {"type": "object", "required": ["code", "severity", "message"], "properties": {"code": {"type": "string", "description": "e.g. not_mp3, not_layer3, too_big, large_file, stereo, high_sample_rate, high_bitrate, large_tags, uppercase_extension, low_sample_rate, long_clip."}, "severity": {"type": "string", "enum": ["error", "warn", "info"]}, "message": {"type": "string", "description": "Plain-language explanation, fit to show a user."}}}}}}, "PlatformFile": {"type": "object", "required": ["dir", "name", "size", "sha256"], "properties": {"dir": {"type": "string", "enum": ["files", "firmware"]}, "name": {"type": "string"}, "size": {"type": "integer"}, "sha256": {"type": "string"}, "audio": {"$ref": "#/components/schemas/AudioCheck", "description": "Present for an .mp3 uploaded into files/."}}}, "AlertSignal": {"type": "object", "required": ["id", "name", "scope", "is_default", "version", "entries"], "properties": {"id": {"type": "integer"}, "name": {"type": "string"}, "scope": {"type": "string", "enum": ["global", "partner"]}, "is_default": {"type": "boolean", "description": "The default signal: what a light with no signal of its own shows."}, "version": {"type": "integer"}, "description": {"type": ["string", "null"]}, "entries": {"type": "object", "required": ["CLEAR", "ADVISORY", "WARNING", "NO_DATA"], "description": "One entry per alert state group. NO_DATA covers NO_SENSORS, INSUFFICENT_DATA, STALE_DATA and UNKNOWN.", "properties": {"CLEAR": {"type": "object", "required": ["light"], "properties": {"light": {"type": "object", "required": ["type"], "description": "A light effect: {type, ...fields}. type is Solid, Pulse, PulseThenSolid, Chase, ChaseThenSolid, Toggle, ToggleThenSolid or BluChase; the fields are the ones the Effect Designer posts (see the advanced docs).", "properties": {"type": {"type": "string", "enum": ["Solid", "Pulse", "PulseThenSolid", "Chase", "ChaseThenSolid", "Toggle", "ToggleThenSolid", "BluChase"]}}}}}, "ADVISORY": {"type": "object", "required": ["light"], "properties": {"light": {"type": "object", "required": ["type"], "description": "A light effect: {type, ...fields}. type is Solid, Pulse, PulseThenSolid, Chase, ChaseThenSolid, Toggle, ToggleThenSolid or BluChase; the fields are the ones the Effect Designer posts (see the advanced docs).", "properties": {"type": {"type": "string", "enum": ["Solid", "Pulse", "PulseThenSolid", "Chase", "ChaseThenSolid", "Toggle", "ToggleThenSolid", "BluChase"]}}}}}, "WARNING": {"type": "object", "required": ["light"], "properties": {"light": {"type": "object", "required": ["type"], "description": "A light effect: {type, ...fields}. type is Solid, Pulse, PulseThenSolid, Chase, ChaseThenSolid, Toggle, ToggleThenSolid or BluChase; the fields are the ones the Effect Designer posts (see the advanced docs).", "properties": {"type": {"type": "string", "enum": ["Solid", "Pulse", "PulseThenSolid", "Chase", "ChaseThenSolid", "Toggle", "ToggleThenSolid", "BluChase"]}}}}}, "NO_DATA": {"type": "object", "required": ["light"], "properties": {"light": {"type": "object", "required": ["type"], "description": "A light effect: {type, ...fields}. type is Solid, Pulse, PulseThenSolid, Chase, ChaseThenSolid, Toggle, ToggleThenSolid or BluChase; the fields are the ones the Effect Designer posts (see the advanced docs).", "properties": {"type": {"type": "string", "enum": ["Solid", "Pulse", "PulseThenSolid", "Chase", "ChaseThenSolid", "Toggle", "ToggleThenSolid", "BluChase"]}}}}}}}}}}, "headers": {"X-Request-Id": {"description": "The id of this request, on every response. Error bodies carry the same value as request_id; quote it to BluNotify support to find the call.", "schema": {"type": "string"}}, "RateLimit-Limit": {"description": "The per-partner request limit: requests per minute.", "schema": {"type": "integer"}}, "RateLimit-Remaining": {"description": "Requests the current minute still admits.", "schema": {"type": "integer"}}, "RateLimit-Reset": {"description": "Seconds until the oldest request counted in the current minute leaves it, i.e. until RateLimit-Remaining next grows.", "schema": {"type": "integer"}}, "Retry-After": {"description": "Seconds to wait before retrying.", "schema": {"type": "integer"}}, "Location": {"description": "The resource created, or the one to poll for an outcome.", "schema": {"type": "string"}}, "Idempotent-Replayed": {"description": "true on a response replayed for a repeated Idempotency-Key.", "schema": {"type": "string", "enum": ["true"]}}, "Cache-Control": {"description": "Always no-store on /v1: answers are one partner's, and some carry secrets.", "schema": {"type": "string", "enum": ["no-store"]}}, "WWW-Authenticate": {"description": "Bearer realm=\"BluNotify Partner API\" (RFC 6750), with error=\"invalid_token\" when a key was sent.", "schema": {"type": "string"}}}, "responses": {"Unauthorized": {"description": "Missing, malformed, unknown, revoked or disabled API key (one generic answer for all of them).", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "WWW-Authenticate": {"$ref": "#/components/headers/WWW-Authenticate"}, "Cache-Control": {"$ref": "#/components/headers/Cache-Control"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "TooManyRequests": {"description": "A rate limit was reached: the per-partner request limit (RateLimit-Remaining is then 0), or a per-device or per-operation limit of the route. Retry-After says when to retry.", "headers": {"Retry-After": {"$ref": "#/components/headers/Retry-After"}, "X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}, "Cache-Control": {"$ref": "#/components/headers/Cache-Control"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "UnsupportedMediaType": {"description": "Code unsupported_media_type: the body was not sent as application/json.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "parameters": {"IdempotencyKey": {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"type": "string", "pattern": "^[\\x21-\\x7e]{1,255}$"}, "description": "A value of your choosing (a UUID, for example) that makes a retry safe. A repeat of the same request with the same key within 24 hours gets the first response back, status and body, without the work being done again; it carries Idempotent-Replayed: true. Scoped to your account and this route. The same key with a different request is a 422 idempotency_key_reused; a repeat while the first is still running is a 409 idempotency_in_progress. Only a successful (2xx) response is kept: after a failure the same key runs the request again. Not taken by the multipart file upload, which never overwrites anyway."}}}, "paths": {"/v1/account": {"get": {"summary": "Partner configuration", "responses": {"200": {"description": "Partner name, permissions, allowed channels, and rate limits.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Account"}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["Account"]}}, "/v1/org-tree": {"get": {"summary": "Read the organization tree (config surface)", "description": "Requires allow_config_read. Returns the granted subtrees as a forest (a granted subtree-root's parent_id is nulled); the whole tree with access_all_devices; an empty forest (not 403) when no nodes are granted.", "responses": {"200": {"description": "{ nodes: [{ id, parent_id, name }] }", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "config read permission not granted", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["Configuration"]}}, "/v1/devices/{device_id}/config": {"get": {"summary": "Read a device's configuration", "description": "Requires allow_config_read. Out-of-scope devices return 404 indistinguishable from nonexistent.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "{ device_id, name, org_node, sensors:[{tdl_id,label,profile}], automation_enabled, alert_signal_id }", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "config read permission not granted", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Not in scope or not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["Configuration"]}, "patch": {"summary": "Rename a device", "description": "Requires allow_config_write. Sets the device's canonical name.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"name": {"type": "string", "maxLength": 120}}, "required": ["name"]}}}}, "responses": {"204": {"description": "Renamed. No body.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "400": {"description": "A body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["Configuration"]}}, "/v1/devices/{device_id}/org": {"put": {"summary": "Move a device to an org node", "description": "Requires allow_config_write. The target node must be within the partner's own scope (else 403 node_out_of_scope). For an org-scoped partner (no access_all_devices), org_node_id: null is ALWAYS 409 would_lose_access, since a granted subtree is the partner's only reach to the device; only a partner with access_all_devices may detach a device back to Staging / Inventory this way.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"org_node_id": {"type": ["integer", "null"]}}, "required": ["org_node_id"]}}}}, "responses": {"204": {"description": "Moved. No body.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "400": {"description": "A body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "node_out_of_scope", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "would_lose_access", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["Configuration"]}}, "/v1/devices/{device_id}/sensors": {"post": {"summary": "Attach a sensor to a device", "description": "Requires allow_config_write. Incremental attach; 409 if already attached. profile_id must be global or partner-owned (else 404).", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"$ref": "#/components/parameters/IdempotencyKey"}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"tdl_id": {"type": "string"}, "profile_id": {"type": ["integer", "null"]}}, "required": ["tdl_id"]}}}}, "responses": {"201": {"description": "Attached. Location names the attachment (PUT and DELETE address it).", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}, "Idempotent-Replayed": {"$ref": "#/components/headers/Idempotent-Replayed"}, "Location": {"$ref": "#/components/headers/Location"}}, "content": {"application/json": {"schema": {"type": "object", "properties": {"device_id": {"type": "string"}, "tdl_id": {"type": "string"}, "profile_id": {"type": ["integer", "null"]}}}}}}, "400": {"description": "A body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device or profile not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "already_attached. Or code idempotency_in_progress: a request with the same Idempotency-Key is still running; retry after Retry-After.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "422": {"description": "Code idempotency_key_reused: this Idempotency-Key was used with a different request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["Configuration"]}}, "/v1/devices/{device_id}/sensors/{tdl_id}": {"put": {"summary": "Set a sensor's alert profile", "description": "Requires allow_config_write. profile_id null clears to Default; a non-visible profile returns 404.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "tdl_id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"profile_id": {"type": ["integer", "null"]}}}}}}, "responses": {"204": {"description": "Updated. No body.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "400": {"description": "A body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Not attached / profile not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["Configuration"]}, "delete": {"summary": "Detach a sensor from a device", "description": "Requires allow_config_write. 404 if not attached.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "tdl_id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"204": {"description": "Detached. No body.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "400": {"description": "A body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Not attached", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["Configuration"]}}, "/v1/profiles": {"get": {"summary": "List alert profiles", "description": "Requires allow_config_read. Returns global + own partner-owned profiles.", "responses": {"200": {"description": "{ profiles: [{ id, name, scope, version, description, rules }] }", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["Configuration"]}, "post": {"summary": "Create a partner-owned alert profile", "description": "Requires allow_profile_manage. Body is validated by the rule engine. Cap 50 profiles per partner (403 limit_reached).", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"name": {"type": "string"}, "rules": {"type": "array", "items": {"type": "object", "description": "One alert rule, as GET /v1/profiles lists them."}}, "default": {"type": "string"}}, "required": ["name", "rules"]}}}}, "responses": {"201": {"description": "Created. Location names the profile.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}, "Idempotent-Replayed": {"$ref": "#/components/headers/Idempotent-Replayed"}, "Location": {"$ref": "#/components/headers/Location"}}, "content": {"application/json": {"schema": {"type": "object", "required": ["id"], "properties": {"id": {"type": "integer"}}}}}}, "400": {"description": "validation_error. Or a body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "denied / limit_reached", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "name_taken. Or code idempotency_in_progress: a request with the same Idempotency-Key is still running; retry after Retry-After.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "422": {"description": "Code idempotency_key_reused: this Idempotency-Key was used with a different request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}], "tags": ["Configuration"]}}, "/v1/profiles/{profile_id}": {"put": {"summary": "Update a partner-owned alert profile", "description": "Requires allow_profile_manage. Own rows only (global/system/other \u2192 404). Bumps version + records a revision.", "parameters": [{"name": "profile_id", "in": "path", "required": true, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"name": {"type": "string"}, "rules": {"type": "array", "items": {"type": "object", "description": "One alert rule, as GET /v1/profiles lists them."}}, "default": {"type": "string"}}}}}}, "responses": {"200": {"description": "Updated: the profile id and its new version.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}, "content": {"application/json": {"schema": {"type": "object", "required": ["id", "version"], "properties": {"id": {"type": "integer"}, "version": {"type": "integer"}}}}}}, "400": {"description": "validation_error. Or a body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "name_taken", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["Configuration"]}, "delete": {"summary": "Delete a partner-owned alert profile", "description": "Requires allow_profile_manage. Own rows only; 409 in_use with a count when still referenced (no force).", "parameters": [{"name": "profile_id", "in": "path", "required": true, "schema": {"type": "integer"}}], "responses": {"204": {"description": "Deleted. No body.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "400": {"description": "A body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "in_use", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["Configuration"]}}, "/v1/alert-signals": {"get": {"summary": "List alert signals", "description": "Requires allow_config_read. Returns the global signals (including the default) and your own partner-owned signals.", "responses": {"200": {"description": "{ alert_signals: [AlertSignal] }", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}, "content": {"application/json": {"schema": {"type": "object", "required": ["alert_signals"], "properties": {"alert_signals": {"type": "array", "items": {"$ref": "#/components/schemas/AlertSignal"}}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["Configuration"]}, "post": {"summary": "Create a partner-owned alert signal", "description": "Requires allow_signal_manage. Every entry is validated as the device would run it, with the availability caps (no light period under 100 ms). Cap 50 signals per partner (403 limit_reached).", "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"name": {"type": "string"}, "description": {"type": "string"}, "entries": {"type": "object", "required": ["CLEAR", "ADVISORY", "WARNING", "NO_DATA"], "description": "One entry per alert state group. NO_DATA covers NO_SENSORS, INSUFFICENT_DATA, STALE_DATA and UNKNOWN.", "properties": {"CLEAR": {"type": "object", "required": ["light"], "properties": {"light": {"type": "object", "required": ["type"], "description": "A light effect: {type, ...fields}. type is Solid, Pulse, PulseThenSolid, Chase, ChaseThenSolid, Toggle, ToggleThenSolid or BluChase; the fields are the ones the Effect Designer posts (see the advanced docs).", "properties": {"type": {"type": "string", "enum": ["Solid", "Pulse", "PulseThenSolid", "Chase", "ChaseThenSolid", "Toggle", "ToggleThenSolid", "BluChase"]}}}}}, "ADVISORY": {"type": "object", "required": ["light"], "properties": {"light": {"type": "object", "required": ["type"], "description": "A light effect: {type, ...fields}. type is Solid, Pulse, PulseThenSolid, Chase, ChaseThenSolid, Toggle, ToggleThenSolid or BluChase; the fields are the ones the Effect Designer posts (see the advanced docs).", "properties": {"type": {"type": "string", "enum": ["Solid", "Pulse", "PulseThenSolid", "Chase", "ChaseThenSolid", "Toggle", "ToggleThenSolid", "BluChase"]}}}}}, "WARNING": {"type": "object", "required": ["light"], "properties": {"light": {"type": "object", "required": ["type"], "description": "A light effect: {type, ...fields}. type is Solid, Pulse, PulseThenSolid, Chase, ChaseThenSolid, Toggle, ToggleThenSolid or BluChase; the fields are the ones the Effect Designer posts (see the advanced docs).", "properties": {"type": {"type": "string", "enum": ["Solid", "Pulse", "PulseThenSolid", "Chase", "ChaseThenSolid", "Toggle", "ToggleThenSolid", "BluChase"]}}}}}, "NO_DATA": {"type": "object", "required": ["light"], "properties": {"light": {"type": "object", "required": ["type"], "description": "A light effect: {type, ...fields}. type is Solid, Pulse, PulseThenSolid, Chase, ChaseThenSolid, Toggle, ToggleThenSolid or BluChase; the fields are the ones the Effect Designer posts (see the advanced docs).", "properties": {"type": {"type": "string", "enum": ["Solid", "Pulse", "PulseThenSolid", "Chase", "ChaseThenSolid", "Toggle", "ToggleThenSolid", "BluChase"]}}}}}}}}, "required": ["name", "entries"]}}}}, "responses": {"201": {"description": "Created. Location names the signal.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}, "Idempotent-Replayed": {"$ref": "#/components/headers/Idempotent-Replayed"}, "Location": {"$ref": "#/components/headers/Location"}}, "content": {"application/json": {"schema": {"type": "object", "required": ["id"], "properties": {"id": {"type": "integer"}}}}}}, "400": {"description": "validation_error. Or a body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "denied / limit_reached", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "name_taken. Or code idempotency_in_progress: a request with the same Idempotency-Key is still running; retry after Retry-After.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "422": {"description": "Code idempotency_key_reused: this Idempotency-Key was used with a different request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}], "tags": ["Configuration"]}}, "/v1/alert-signals/{signal_id}": {"put": {"summary": "Update a partner-owned alert signal", "description": "Requires allow_signal_manage. Own rows only (global/default/other \u2192 404). Send any of name, description, entries; bumps version. Lights showing it change at their next state change or within the hour.", "parameters": [{"name": "signal_id", "in": "path", "required": true, "schema": {"type": "integer"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"name": {"type": "string"}, "description": {"type": "string"}, "entries": {"type": "object", "required": ["CLEAR", "ADVISORY", "WARNING", "NO_DATA"], "description": "One entry per alert state group. NO_DATA covers NO_SENSORS, INSUFFICENT_DATA, STALE_DATA and UNKNOWN.", "properties": {"CLEAR": {"type": "object", "required": ["light"], "properties": {"light": {"type": "object", "required": ["type"], "description": "A light effect: {type, ...fields}. type is Solid, Pulse, PulseThenSolid, Chase, ChaseThenSolid, Toggle, ToggleThenSolid or BluChase; the fields are the ones the Effect Designer posts (see the advanced docs).", "properties": {"type": {"type": "string", "enum": ["Solid", "Pulse", "PulseThenSolid", "Chase", "ChaseThenSolid", "Toggle", "ToggleThenSolid", "BluChase"]}}}}}, "ADVISORY": {"type": "object", "required": ["light"], "properties": {"light": {"type": "object", "required": ["type"], "description": "A light effect: {type, ...fields}. type is Solid, Pulse, PulseThenSolid, Chase, ChaseThenSolid, Toggle, ToggleThenSolid or BluChase; the fields are the ones the Effect Designer posts (see the advanced docs).", "properties": {"type": {"type": "string", "enum": ["Solid", "Pulse", "PulseThenSolid", "Chase", "ChaseThenSolid", "Toggle", "ToggleThenSolid", "BluChase"]}}}}}, "WARNING": {"type": "object", "required": ["light"], "properties": {"light": {"type": "object", "required": ["type"], "description": "A light effect: {type, ...fields}. type is Solid, Pulse, PulseThenSolid, Chase, ChaseThenSolid, Toggle, ToggleThenSolid or BluChase; the fields are the ones the Effect Designer posts (see the advanced docs).", "properties": {"type": {"type": "string", "enum": ["Solid", "Pulse", "PulseThenSolid", "Chase", "ChaseThenSolid", "Toggle", "ToggleThenSolid", "BluChase"]}}}}}, "NO_DATA": {"type": "object", "required": ["light"], "properties": {"light": {"type": "object", "required": ["type"], "description": "A light effect: {type, ...fields}. type is Solid, Pulse, PulseThenSolid, Chase, ChaseThenSolid, Toggle, ToggleThenSolid or BluChase; the fields are the ones the Effect Designer posts (see the advanced docs).", "properties": {"type": {"type": "string", "enum": ["Solid", "Pulse", "PulseThenSolid", "Chase", "ChaseThenSolid", "Toggle", "ToggleThenSolid", "BluChase"]}}}}}}}}}}}}, "responses": {"200": {"description": "Updated: the profile id and its new version.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}, "content": {"application/json": {"schema": {"type": "object", "required": ["id", "version"], "properties": {"id": {"type": "integer"}, "version": {"type": "integer"}}}}}}, "400": {"description": "validation_error. Or a body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "name_taken", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["Configuration"]}, "delete": {"summary": "Delete a partner-owned alert signal", "description": "Requires allow_signal_manage. Own rows only. Allowed while in use: the lights showing it go back to the default signal.", "parameters": [{"name": "signal_id", "in": "path", "required": true, "schema": {"type": "integer"}}], "responses": {"204": {"description": "Deleted. No body.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "400": {"description": "A body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["Configuration"]}}, "/v1/devices/{device_id}/alert-signal": {"put": {"summary": "Choose a light's alert signal", "description": "Requires allow_config_write. alert_signal_id is a global or your own partner-owned signal (404 otherwise); null, or the default signal's id, means the default.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["alert_signal_id"], "properties": {"alert_signal_id": {"type": ["integer", "null"]}}}}}}, "responses": {"200": {"description": "The device and the signal it now shows (null = default).", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}, "content": {"application/json": {"schema": {"type": "object", "required": ["device_id", "alert_signal_id"], "properties": {"device_id": {"type": "string"}, "alert_signal_id": {"type": ["integer", "null"]}}}}}}, "400": {"description": "A body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "denied", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Not attached / profile not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["Configuration"]}}, "/v1/devices": {"get": {"summary": "List devices in scope", "responses": {"200": {"description": "Devices in the partner's scope.", "content": {"application/json": {"schema": {"type": "object", "required": ["devices"], "properties": {"devices": {"type": "array", "items": {"$ref": "#/components/schemas/Device"}}}}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "400": {"description": "Unknown product.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "description": "The devices in your scope, in device-id order. ?product= narrows the list to one product's devices ('blunotify' or 'bluem'); an unknown value is a 400, never an empty list.", "parameters": [{"name": "product", "in": "query", "required": false, "schema": {"type": "string", "enum": ["blunotify", "bluem"]}, "description": "Only this product's devices."}], "tags": ["Devices"]}}, "/v1/devices/{device_id}": {"get": {"summary": "Get one device in scope", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "Device detail.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Device"}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"description": "Outside scope or not found.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["Devices"]}, "post": {"summary": "Provision a new device (derived-v1)", "description": "Registers a new device under the firmware's derived-v1 credential contract and places it in the partner's scope. Requires the device-provisioning permission. The device ID (path parameter) is the ESP32 base MAC as exactly 12 uppercase hexadecimal characters (no separators); it is the MQTT username, the HKDF salt, and the firmware device ID, and must not already be in use. `credential_source` selects what you provide (default `generate`): `generate` \u2014 the server mints a 16-character root secret, derives the 32-character MQTT broker password from it (HKDF-SHA256), and returns the root secret exactly once as `password` (the eFuse root secret, NOT the MQTT password); `root_secret` \u2014 you supply the device's existing eFuse secret and the server derives from it, which is how you RE-ENROLL a device whose eFuse is already burned (minting a new secret would derive a password the device can never produce); `mqtt_password` \u2014 you supply the already-derived 32-character lowercase-hex broker password verbatim and the server performs no derivation. A supplied credential is never echoed back, and the root secret is never stored server-side in any mode \u2014 it lives only in the device eFuse and the provisioning helper's own record. `org` placement: a partner with access_all_devices may optionally name an existing ROOT organization (omitted, the device lands in the root Staging / Inventory node); an org-scoped partner (no access_all_devices) MUST supply `org` naming a node INSIDE its own granted subtrees \u2014 omitted is 400 validation_error, a name that is unknown or outside scope is 400 \"unknown organization '<org>'\" (the two are indistinguishable, so the response never confirms an organization outside the partner's sight), and a name shared by two or more in-scope nodes is 400 \"organization '<org>' is ambiguous: N organizations in your scope share that name\".", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string", "pattern": "^[0-9A-F]{12}$"}}, {"$ref": "#/components/parameters/IdempotencyKey"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["credential_format"], "properties": {"credential_format": {"type": "string", "enum": ["derived-v1"]}, "credential_source": {"type": "string", "enum": ["generate", "root_secret", "mqtt_password"], "default": "generate", "description": "What you are providing. Only the field matching this value may be sent; supplying the others is rejected."}, "root_secret": {"type": "string", "minLength": 16, "maxLength": 16, "description": "Required when credential_source is 'root_secret': the device's existing eFuse secret, 16 printable-ASCII characters with no whitespace."}, "mqtt_password": {"type": "string", "minLength": 32, "maxLength": 32, "description": "Required when credential_source is 'mqtt_password': the already-derived broker password, 32 lowercase hexadecimal characters."}, "product": {"type": "string", "default": "blunotify", "example": "bluem", "description": "Which device product this is. Currently 'blunotify' or 'bluem'. It selects the HKDF label the MQTT password is derived with ('<product>/mqtt/v1'), the MQTT topic root the device uses ('<product>/<DEV_ID>/...'), and the broker ACL subtree its credential is granted. Omit it and you get 'blunotify', so older tooling keeps working; an unrecognised value is rejected rather than defaulted, because a device burned against one label can never produce the password derived from another. Echoed back in the response \u2014 check it before burning an eFuse."}, "name": {"type": "string", "minLength": 1, "maxLength": 120, "description": "Optional display name."}, "org": {"type": "string", "minLength": 1, "maxLength": 120, "description": "Where to place the device; provisioning never creates org nodes. Optional for an access_all_devices partner (must then name an existing ROOT organization; omitted lands the device in Staging / Inventory). REQUIRED for an org-scoped partner (no access_all_devices), and must name a node inside that partner's own granted subtrees \u2014 see the operation description for the exact 400s this produces."}}}}}}, "responses": {"201": {"description": "Provisioned. device_id, credential_format, product, password (the root secret, only when credential_source was generate), and name and org when given. Location names the device.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}, "Idempotent-Replayed": {"$ref": "#/components/headers/Idempotent-Replayed"}, "Location": {"$ref": "#/components/headers/Location"}}, "content": {"application/json": {"schema": {"type": "object", "required": ["device_id", "credential_format", "product"], "properties": {"device_id": {"type": "string"}, "credential_format": {"type": "string", "enum": ["derived-v1"]}, "product": {"type": "string"}, "password": {"type": "string", "description": "The 16-character root secret; only when the server generated it."}, "name": {"type": "string"}, "org": {"type": "string"}}}}}}, "400": {"description": "Invalid device_id, missing/unsupported credential_format, unknown product, invalid credential_source or supplied credential material, invalid name/org, or unknown organization. Nothing is created on any of these. Or a body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Device provisioning not permitted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "device_id is already in use (existing devices are never modified, rotated, or disclosed). Or code idempotency_in_progress: a request with the same Idempotency-Key is still running; retry after Retry-After.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "422": {"description": "Code idempotency_key_reused: this Idempotency-Key was used with a different request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}, "503": {"description": "Broker provisioning or DB commit failed; nothing was persisted and any partial broker credential was rolled back.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "tags": ["Devices"]}, "delete": {"summary": "Remove a device", "description": "Cuts the device's broker credential, then deletes its record (cascades automation suspensions and sensor attachments). Requires the device-removal permission. Applies to any device in the partner's scope, not only ones it provisioned. DESTRUCTIVE TIER: `confirm` must equal the device id (as a query parameter or in a JSON body) or the call is refused with 409, and each removal spends a slot from the same per-partner per-hour destructive-operation ceiling as factory_reset.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "confirm", "in": "query", "required": false, "description": "Must equal the device id. Required unless the same field is supplied in a JSON request body.", "schema": {"type": "string"}}], "responses": {"204": {"description": "Removed. No body.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "400": {"description": "A body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Device removal not permitted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Outside scope or not found.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "Missing or mismatched confirm (code confirmation_required); nothing was deleted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "429": {"description": "Destructive-operation ceiling reached (Retry-After header). A rate limit was reached: the per-partner request limit (RateLimit-Remaining is then 0), or a per-device or per-operation limit of the route. Retry-After says when to retry.", "headers": {"Retry-After": {"$ref": "#/components/headers/Retry-After"}, "X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "503": {"description": "Broker removal failed; the device record was not deleted (retryable).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "requestBody": {"required": false, "description": "Optional alternative to the `confirm` query parameter, for clients that prefer a body on DELETE.", "content": {"application/json": {"schema": {"type": "object", "properties": {"confirm": {"type": "string", "description": "Must equal the device id."}}}}}}, "tags": ["Devices"]}}, "/v1/commands": {"get": {"summary": "List predefined commands available to the partner", "description": "Requires the predefined-commands permission. Includes each parameter's effective min/max/default.", "responses": {"200": {"description": "Command list with parameter bounds.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Predefined commands not permitted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["BluNotify lights"]}}, "/v1/devices/{device_id}/commands": {"post": {"summary": "Send a raw command", "description": "Requires the raw-command permission and channel access. Availability caps apply unless the partner also has the relaxed-caps permission.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"$ref": "#/components/parameters/IdempotencyKey"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"channel": {"type": "string", "enum": ["light1", "light2", "light3", "sound", "contact"]}, "payload": {"type": "string", "example": "sldff0000"}}, "required": ["channel", "payload"]}}}}, "responses": {"202": {"description": "Published to the broker: the device takes it from there.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommandResult"}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}, "Idempotent-Replayed": {"$ref": "#/components/headers/Idempotent-Replayed"}}}, "400": {"description": "Invalid payload or channel. Or a body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Not permitted", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "Code idempotency_in_progress: a request with the same Idempotency-Key is still running; retry after Retry-After.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "422": {"description": "Code idempotency_key_reused: this Idempotency-Key was used with a different request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limited A rate limit was reached: the per-partner request limit (RateLimit-Remaining is then 0), or a per-device or per-operation limit of the route. Retry-After says when to retry.", "headers": {"Retry-After": {"$ref": "#/components/headers/Retry-After"}, "X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "503": {"description": "Publish failed", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "tags": ["BluNotify lights"]}}, "/v1/devices/{device_id}/channels/{channel}/effects": {"post": {"summary": "Trigger a predefined effect", "description": "Requires the predefined-commands permission, channel access, and that the command is granted. light2/light3 effects are auto-wrapped as repeating overlays (duration_s / interval_s).", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "channel", "in": "path", "required": true, "schema": {"type": "string", "enum": ["light1", "light2", "light3", "sound", "contact"]}}, {"$ref": "#/components/parameters/IdempotencyKey"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"command": {"type": "string", "example": "flash_white"}, "params": {"type": "object", "additionalProperties": {"type": "number"}}}, "required": ["command"]}}}}, "responses": {"202": {"description": "Published to the broker: the device takes it from there.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommandResult"}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}, "Idempotent-Replayed": {"$ref": "#/components/headers/Idempotent-Replayed"}}}, "400": {"description": "Invalid command or parameter. Or a body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Not permitted", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "Code idempotency_in_progress: a request with the same Idempotency-Key is still running; retry after Retry-After.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "422": {"description": "Code idempotency_key_reused: this Idempotency-Key was used with a different request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limited A rate limit was reached: the per-partner request limit (RateLimit-Remaining is then 0), or a per-device or per-operation limit of the route. Retry-After says when to retry.", "headers": {"Retry-After": {"$ref": "#/components/headers/Retry-After"}, "X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "503": {"description": "Publish failed", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "tags": ["BluNotify lights"]}}, "/v1/devices/{device_id}/state": {"get": {"summary": "Current device state", "description": "Requires the state-read permission. Returns the latest command per permitted channel with a decoded label.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "Per-channel state.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "State access not permitted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["Devices"]}}, "/v1/devices/{device_id}/health": {"get": {"summary": "Device health", "description": "Requires the health-read permission.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "hours", "in": "query", "schema": {"type": "integer", "default": 24, "maximum": 720}}, {"name": "limit", "in": "query", "schema": {"type": "integer", "default": 100, "maximum": 500}}], "responses": {"200": {"description": "The connectivity fields and recent health telemetry.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}, "content": {"application/json": {"schema": {"type": "object", "required": ["device_id", "telemetry"], "properties": {"device_id": {"type": "string"}, "status": {"type": ["string", "null"], "enum": ["connected", "disconnected", null]}, "status_changed_at": {"type": ["string", "null"], "format": "date-time", "description": "When status last changed."}, "last_seen_at": {"type": ["string", "null"], "format": "date-time", "description": "When the device last sent anything itself."}, "telemetry": {"type": "array", "items": {"type": "object", "properties": {"created_at": {"type": "string", "format": "date-time"}, "topic": {"type": "string"}, "payload": {"type": "string"}}}}}}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Health access not permitted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["Devices"]}}, "/v1/devices/{device_id}/automation": {"put": {"summary": "Enable/disable temperature-sensor automation", "description": "Requires the automation-control permission (an independent add-on permission). Disabling stops automation from overwriting light1.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"enabled": {"type": "boolean"}}, "required": ["enabled"]}}}}, "responses": {"200": {"description": "Updated: device_id and automation_enabled.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "400": {"description": "Invalid body. Or a body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Automation-control permission not granted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["BluNotify lights"]}}, "/v1/system/commands": {"get": {"summary": "System command registry (System API)", "description": "Every system verb, its tier, parameters, whether it needs confirm, and whether the caller holds that tier. Also lists the destructive settings keys. Requires system read.", "responses": {"200": {"description": "Verb registry + dangerous settings keys.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "System read not permitted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["System"]}}, "/v1/system/files": {"get": {"summary": "List platform files (System API)", "description": "Files devices download (files/ + firmware/). Requires system read.", "responses": {"200": {"description": "File and firmware listings.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "System read not permitted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["System"]}, "post": {"summary": "Upload a platform file (System API)", "description": "Requires the file-upload permission. Extension allowlist; firmware/ accepts .bin only; size-capped. Uploads never overwrite (409 on name clash). An .mp3 uploaded into files/ is checked against what a BluNotify device can play: one that holds no MP3 (MPEG Layer III) audio is refused, and any other comes back with an `audio` assessment whose warnings say what would make it better (mono, 16 kHz, 48 kbps, no tags). Warnings never block.", "requestBody": {"required": true, "content": {"multipart/form-data": {"schema": {"type": "object", "properties": {"file": {"type": "string", "format": "binary"}, "dir": {"type": "string", "enum": ["files", "firmware"], "default": "files"}}, "required": ["file"]}}}}, "responses": {"201": {"description": "Stored: dir, name, size, sha256, and `audio` for an .mp3 in files/.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PlatformFile"}}}}, "400": {"description": "Invalid directory, name, or extension, or an .mp3 that holds no MP3 (MPEG Layer III) audio a BluNotify device can play.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "File upload not permitted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "A file with that name already exists.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "parameters": [], "tags": ["System"]}, "delete": {"summary": "Delete a platform file (System API)", "description": "Requires the file-delete permission. Protected names are refused.", "parameters": [{"name": "dir", "in": "query", "schema": {"type": "string", "enum": ["files", "firmware"], "default": "files"}}, {"name": "name", "in": "query", "required": true, "schema": {"type": "string"}}], "responses": {"204": {"description": "Deleted. No body.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "400": {"description": "Invalid directory or name (or protected). Or a body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "File delete not permitted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "File not found.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["System"]}}, "/v1/devices/{device_id}/system/settings": {"get": {"summary": "Cached device settings (System API)", "description": "Latest NVS settings, fw_ver, and file_list. Requires system read.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "Settings, firmware version, and file list.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "System read not permitted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["System"]}}, "/v1/devices/{device_id}/system/status": {"get": {"summary": "Device status snapshot & health (System API)", "description": "Status, last connection string, on-demand status snapshots, and the health heartbeat. Requires system read.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "The connectivity fields (status, status_changed_at, last_seen_at), last_conn, status_snapshots and health.", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "System read not permitted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["System"]}}, "/v1/devices/{device_id}/system/responses": {"get": {"summary": "Command response feed (System API)", "description": "Recent command acknowledgements and device log lines. Page with since/limit; use server_now as the next cursor. Requires system read.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "cursor", "in": "query", "required": false, "schema": {"type": "string", "pattern": "^[A-Za-z0-9_-]+$"}, "description": "A previous answer's next_cursor: the rows after it, oldest first. Wins over since."}, {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "maximum": 1000}, "description": "Default 200, at most 1000."}, {"name": "since", "in": "query", "required": false, "schema": {"type": "string", "format": "date-time"}, "description": "The older way to page: rows at or after this instant (percent-encode the '+' of its offset). Prefer cursor."}], "responses": {"200": {"description": "A page of rows: oldest first with a cursor. Without one, the last ten minutes (or since).", "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}, "content": {"application/json": {"schema": {"type": "object", "required": ["rows", "has_more", "next_cursor"], "properties": {"device_id": {"type": "string"}, "rows": {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "integer"}, "created_at": {"type": "string", "format": "date-time"}, "topic": {"type": "string"}, "payload": {"type": "string"}}}}, "has_more": {"type": "boolean", "description": "More rows are waiting now: ask again with next_cursor at once."}, "next_cursor": {"type": "string", "description": "Always set: pass it back as cursor. With no rows it is the cursor sent, or a position at the present."}, "server_now": {"type": "string", "format": "date-time", "description": "For the older since paging."}}}}}}, "400": {"description": "An invalid cursor or since.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "System read not permitted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["System"]}}, "/v1/devices/{device_id}/files": {"post": {"summary": "Push a platform file to a device, as a transfer to poll", "description": "Requires system control (system_control); polling the transfer needs system read (system_read). Sends addfile> with a correlation token and records a durable transfer. An Idempotency-Key header makes a retry return the original transfer (200) instead of downloading twice.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"$ref": "#/components/parameters/IdempotencyKey"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["name"], "properties": {"name": {"type": "string", "description": "A file in the platform files/ directory."}, "dest": {"type": "string", "description": "Its name on the device; defaults to name."}}}}}}, "responses": {"200": {"description": "The transfer an earlier request with the same Idempotency-Key created.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FileTransfer"}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}, "Idempotent-Replayed": {"$ref": "#/components/headers/Idempotent-Replayed"}}}, "202": {"description": "Sent; poll the transfer. Location names the resource to poll.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FileTransfer"}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}, "Location": {"$ref": "#/components/headers/Location"}, "Idempotent-Replayed": {"$ref": "#/components/headers/Idempotent-Replayed"}}}, "400": {"description": "Missing or invalid name, unknown file, or a type this device does not accept. Or a body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "System control not permitted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "The device has no registered product. Or code idempotency_in_progress: a request with the same Idempotency-Key is still running; retry after Retry-After.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "422": {"description": "Code idempotency_key_reused: this Idempotency-Key was used with a different request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Per-device system-command limit. A rate limit was reached: the per-partner request limit (RateLimit-Remaining is then 0), or a per-device or per-operation limit of the route. Retry-After says when to retry.", "headers": {"Retry-After": {"$ref": "#/components/headers/Retry-After"}, "X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "503": {"description": "Publish failed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "tags": ["File transfers"]}}, "/v1/devices/{device_id}/files/transfers": {"get": {"summary": "This partner's transfers for a device, newest first", "description": "Requires system read (system_read). ?state= and ?since= filter; the reconciliation read after an outage.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "state", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "since", "in": "query", "required": false, "schema": {"type": "string", "format": "date-time"}}, {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Transfers.", "content": {"application/json": {"schema": {"type": "object", "required": ["device_id", "transfers"], "properties": {"device_id": {"type": "string"}, "transfers": {"type": "array", "items": {"$ref": "#/components/schemas/FileTransfer"}}}}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "400": {"description": "Invalid since.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "System read not permitted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["File transfers"]}}, "/v1/devices/{device_id}/files/transfers/{transfer_id}": {"get": {"summary": "Poll one transfer", "description": "Requires system read (system_read). Terminal when terminal is true; until then wait poll_after_s seconds. A transfer id that is not this partner's, or not for this device, is the same 404 as an unknown one.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "transfer_id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "The transfer.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FileTransfer"}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "System read not permitted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope, or transfer not found.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["File transfers"]}}, "/v1/devices/{device_id}/system/commands": {"post": {"summary": "Run a system verb (System API)", "description": "Control verbs need system control; destructive verbs need system destructive + confirm == device_id. Fire-and-forget (202). Call GET /v1/system/commands for the verb list and parameters.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"$ref": "#/components/parameters/IdempotencyKey"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"command": {"type": "string", "example": "query"}, "params": {"type": "object"}, "confirm": {"type": "string", "description": "Required for destructive verbs; must equal the device id."}}, "required": ["command"]}}}}, "responses": {"202": {"description": "Accepted", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SysCommandResult"}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}, "Idempotent-Replayed": {"$ref": "#/components/headers/Idempotent-Replayed"}}}, "400": {"description": "Unknown verb or invalid params. Or a body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Tier not permitted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "Destructive verb missing/mismatched confirm. Or code idempotency_in_progress: a request with the same Idempotency-Key is still running; retry after Retry-After.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "422": {"description": "Code idempotency_key_reused: this Idempotency-Key was used with a different request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limited. A rate limit was reached: the per-partner request limit (RateLimit-Remaining is then 0), or a per-device or per-operation limit of the route. Retry-After says when to retry.", "headers": {"Retry-After": {"$ref": "#/components/headers/Retry-After"}, "X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "503": {"description": "Publish failed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "tags": ["System"]}}, "/v1/devices/{device_id}/system/settings/{key}": {"put": {"summary": "Write an NVS settings key (System API)", "description": "Ordinary keys need system control; connection/identity keys (mqttUser, mqttPass, devId, topicRoot, host, tlsMode, mqttPort, caCertFile) need system destructive + confirm == device_id. Fire-and-forget (202). On a BluEM device the keys emAck, pmBaud, pmSlave and emCtTurns are fixed by the meter hardware and its wiring and cannot be written: 403 forbidden, whatever the caller's permissions.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "key", "in": "path", "required": true, "schema": {"type": "string", "pattern": "^[A-Za-z0-9_]{1,15}$"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"value": {"type": "string"}, "confirm": {"type": "string"}}, "required": ["value"]}}}}, "responses": {"202": {"description": "Accepted", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SysCommandResult"}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "400": {"description": "Invalid key or value. Or a body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Tier not permitted, or the key is locked for this device's product.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "Dangerous key missing/mismatched confirm.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "429": {"description": "Rate limited. A rate limit was reached: the per-partner request limit (RateLimit-Remaining is then 0), or a per-device or per-operation limit of the route. Retry-After says when to retry.", "headers": {"Retry-After": {"$ref": "#/components/headers/Retry-After"}, "X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "503": {"description": "Publish failed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "tags": ["System"]}}, "/v1/bluem/devices/{device_id}/intervals": {"get": {"summary": "Interval records over a time range (BluEM)", "description": "Requires the energy-read permission (energy_read). Two ways of asking. BY START TIME (start/end): records come back oldest first, ordered by (start, stream id, sequence number) and cursor-paginated by that key; a cursor carries its window, so the next page is cut from the window the first page resolved, and start, end and unknown_time may be left out beside it (sent, each must equal the first request's value, or the call is a 400); pass unknown_time=true to list records with no determinable start instead. BY ARRIVAL (received_since, then cursor): every record of the meter in the order the server stored it, late records included; next_cursor is always present and is the bookmark to keep; with a cursor, received_since is ignored; start, end and unknown_time cannot be combined with it (400). A bookmark answered 400 invalid 'cursor' belongs to a database history from before a restore: start again from received_since and de-duplicate by sid and seq. Meters are tested before they ship, and the records a meter stored during the test may be cleared: cleared records are no longer listed in either mode, records from before the clear are not stored if they arrive later, and a bookmark stays valid. Each record's current_a, power_w and energy_wh are corrected for the meter's CT turns (divided by its ct_turns); voltage_v, frequency_hz and power_factor are as measured.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The BluEM device id."}, {"name": "start", "in": "query", "required": false, "schema": {"type": "string"}, "description": "ISO-8601 or epoch seconds; defaults to 24h before end. Beside a range-mode cursor: may be left out, and must otherwise equal the first request's."}, {"name": "end", "in": "query", "required": false, "schema": {"type": "string"}, "description": "ISO-8601 or epoch seconds; defaults to now. Beside a range-mode cursor: may be left out, and must otherwise equal the first request's."}, {"name": "received_since", "in": "query", "required": false, "schema": {"type": "string"}, "description": "ISO-8601 or epoch seconds. Selects the arrival feed: records the server received at or after this instant, in the order it stored them. Ignored when a cursor is sent."}, {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer"}, "description": "Default 200, max 1000."}, {"name": "cursor", "in": "query", "required": false, "schema": {"type": "string", "pattern": "^[A-Za-z0-9_-]+$"}, "description": "A previous answer's next_cursor, exactly as received. Cursors are URL-safe ([A-Za-z0-9_-]) and need no encoding."}, {"name": "unknown_time", "in": "query", "required": false, "schema": {"type": "boolean"}, "description": "By start time only: list the records that have no start time."}], "responses": {"200": {"description": "A page of interval records. By start time: device_id, unknown_time, window, records, has_more, next_cursor (null on the last page). By arrival: device_id, mode, received_since, records, has_more, next_cursor (never null).", "content": {"application/json": {"schema": {"type": "object", "properties": {"device_id": {"type": "string"}, "unknown_time": {"type": "boolean"}, "window": {"$ref": "#/components/schemas/Window"}, "records": {"type": "array", "items": {"$ref": "#/components/schemas/BluEMIntervalRecord"}}, "next_cursor": {"type": ["string", "null"], "description": "URL-safe. By start time: null on the last page. By arrival: never null; the bookmark to keep."}, "mode": {"type": "string", "enum": ["received"], "description": "Arrival feed only."}, "received_since": {"type": ["string", "null"], "format": "date-time", "description": "Arrival feed only: the instant applied, or null on a cursor call."}, "has_more": {"type": "boolean", "description": "Both modes. By start time: true exactly when next_cursor is set. By arrival: call again at once with next_cursor."}}, "required": ["device_id", "records", "has_more", "next_cursor"]}, "example": {"device_id": "EM123", "unknown_time": false, "window": {"start": "2026-09-24T12:00:00+00:00", "end": "2026-09-25T12:00:00+00:00"}, "records": [{"seq": 118, "sid": "1a2b3c4d", "start_at": "2026-09-25T11:45:00+00:00", "time_quality": "device", "duration_s": 900, "reads": {"n": 900, "err": 0}, "voltage_v": {"avg": 229.83, "min": 228.1, "max": 231.4}, "current_a": {"avg": 1.203, "min": 0.9, "max": 1.6}, "power_w": {"avg": 276.1, "min": 205.0, "max": 370.0}, "frequency_hz": {"avg": 50.01, "min": 49.9, "max": 50.1}, "power_factor": {"avg": 0.947, "min": 0.9, "max": 0.99}, "energy_wh": {"first": 100020.0, "last": 100089.0}, "ct_turns": 1, "alarm_reads": 0, "flags": {"partial": false, "energy_reset": false, "meter_reconfigured": false}, "quarantined": false, "quarantine_reason": null, "received_at": "2026-09-25T12:00:01.532+00:00"}], "has_more": false, "next_cursor": null}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "400": {"description": "Invalid start/end/received_since/cursor, the two ways of asking mixed, or the range exceeds the cap.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Energy data access not permitted (required_permission energy_read).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope, unknown, or not a BluEM device.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["BluEM energy monitors"]}}, "/v1/bluem/devices/{device_id}/summary": {"get": {"summary": "Statistics and consumption over a window (BluEM)", "description": "Requires the energy-read permission (energy_read). Adds up exactly the records GET .../intervals lists by start time for the same start and end (records with no start time are in none). For voltage_v, current_a, power_w, frequency_hz and power_factor, over the records that carry the quantity (not quarantined, reads.n > 0, an average, a positive duration_s): avg is duration-weighted, min and max are the records' own extremes, rounded to 6 places; null, never zeros, when no record carries it. power_factor counts only records with a load (see its schema). energy_wh is the total GET .../energy gives for the same window, or null with no counter reading. coverage counts the window's records, the quarantined ones, and the seconds the rest cover. A summary of a past window can change when late records arrive.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The BluEM device id."}, {"name": "start", "in": "query", "required": false, "schema": {"type": "string"}, "description": "ISO-8601 or epoch seconds; defaults to 24h before end."}, {"name": "end", "in": "query", "required": false, "schema": {"type": "string"}, "description": "ISO-8601 or epoch seconds; defaults to now."}], "responses": {"200": {"description": "The window's statistics.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BluEMSummary"}, "example": {"device_id": "7C9EBD91235C", "window": {"start": "2026-09-29T14:00:00+00:00", "end": "2026-09-30T14:00:00+00:00"}, "coverage": {"records": 96, "quarantined": 0, "seconds": 86391}, "voltage_v": {"avg": 123.077838, "min": 119.6, "max": 125.3, "intervals": 96, "seconds": 86391}, "current_a": {"avg": 0.336163, "min": 0.0, "max": 6.9894, "intervals": 96, "seconds": 86391}, "power_w": {"avg": 31.99751, "min": 0.0, "max": 764.92, "intervals": 96, "seconds": 86391}, "frequency_hz": {"avg": 59.967292, "min": 59.9, "max": 60.0, "intervals": 96, "seconds": 86391}, "power_factor": {"avg": 0.693137, "min": 0.0, "max": 0.96, "intervals": 55, "seconds": 49493}, "energy_wh": {"total": 769.0, "resets": 0, "intervals": 96}}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "400": {"description": "Invalid start/end, or the range exceeds a year.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Energy data access not permitted (required_permission energy_read).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope, unknown, or not a BluEM device.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["BluEM energy monitors"]}}, "/v1/bluem/devices/{device_id}/energy": {"get": {"summary": "Consumption per bucket and total (BluEM)", "description": "Requires the energy-read permission (energy_read). bucket is 'hour' or 'day' (default 'hour'). Buckets are always exactly the requested width, never silently coarser: a window the bucket cannot cover in under 2000 buckets is a 400 validation_error, so an 'hour'-bucketed range must span fewer than 2000 hours (83 days) \u2014 use bucket=day beyond that. total is null, not zero, when no record in the window carried a counter reading. Consumption is in watt-hours (Wh), corrected for CT turns like a record's energy_wh; a change of emCtTurns is never reported as a reset (the interval after it counts its own first-to-last reading). With tz the buckets are local days or hours of that zone; an unknown zone is a 400.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The BluEM device id."}, {"name": "bucket", "in": "query", "required": false, "schema": {"type": "string", "enum": ["hour", "day"]}}, {"name": "start", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "end", "in": "query", "required": false, "schema": {"type": "string"}}, {"name": "tz", "in": "query", "required": false, "schema": {"type": "string"}, "description": "An IANA zone name, e.g. America/New_York. Buckets are then that zone's local days or hours (a day at a daylight-saving change is 23 or 25 hours), each point's t is written in that zone with its offset, and the answer echoes tz. Absent: UTC buckets, exactly as before. Nothing is stored."}], "responses": {"200": {"description": "Bucketed consumption points plus the window total; tz is echoed when it was sent.", "content": {"application/json": {"schema": {"type": "object", "required": ["device_id", "bucket", "window", "points", "total"], "properties": {"device_id": {"type": "string"}, "bucket": {"type": "string", "enum": ["hour", "day"]}, "window": {"$ref": "#/components/schemas/Window"}, "points": {"type": "array", "items": {"type": "object", "required": ["start_at", "energy_wh", "reset"], "properties": {"start_at": {"type": "string", "format": "date-time", "description": "The bucket's first instant (with tz, in that zone)."}, "energy_wh": {"type": "number", "description": "Consumption in the bucket, in watt-hours (Wh)."}, "reset": {"type": "boolean", "description": "The counter was reset in this bucket."}}}}, "total": {"anyOf": [{"$ref": "#/components/schemas/BluEMEnergyTotal"}, {"type": "null"}]}, "tz": {"type": "string", "description": "Echoed when sent."}}}, "example": {"device_id": "EM123", "bucket": "day", "window": {"start": "2026-09-28T00:00:00+00:00", "end": "2026-09-30T00:00:00+00:00"}, "points": [{"start_at": "2026-09-28T00:00:00+00:00", "energy_wh": 812.4, "reset": false}, {"start_at": "2026-09-29T00:00:00+00:00", "energy_wh": 744.1, "reset": false}], "total": {"energy_wh": 1556.5, "resets": 0, "intervals": 192, "buckets": 2}}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "400": {"description": "Invalid bucket or start/end, the range exceeds the cap, or the bucket cannot cover the window in under 2000 buckets (an 'hour'-bucketed range of 2000 hours / ~83 days and up), or tz is not a known zone.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Energy data access not permitted (required_permission energy_read).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope, unknown, or not a BluEM device.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["BluEM energy monitors"]}}, "/v1/bluem/devices/{device_id}/report": {"post": {"summary": "Close the interval in progress now (cmd/report)", "description": "Requires system control. Fire-and-forget (202); the record carries a partial flag and reaches GET .../intervals a few seconds later. Not a refresh: every call closes the interval in progress and stores a short record. For what the meter reads now, use POST .../sample, which stores nothing.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The BluEM device id."}, {"$ref": "#/components/parameters/IdempotencyKey"}], "responses": {"202": {"description": "Accepted", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SysCommandResult"}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}, "Idempotent-Replayed": {"$ref": "#/components/headers/Idempotent-Replayed"}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "System control not permitted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope, unknown, or not a BluEM device.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "Code idempotency_in_progress: a request with the same Idempotency-Key is still running; retry after Retry-After.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "422": {"description": "Code idempotency_key_reused: this Idempotency-Key was used with a different request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limited. A rate limit was reached: the per-partner request limit (RateLimit-Remaining is then 0), or a per-device or per-operation limit of the route. Retry-After says when to retry.", "headers": {"Retry-After": {"$ref": "#/components/headers/Retry-After"}, "X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "503": {"description": "Publish failed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "tags": ["BluEM energy monitors"]}}, "/v1/bluem/devices/{device_id}/sample": {"post": {"summary": "Ask for a live reading (BluEM)", "description": "Requires the energy-read permission (energy_read). Asks the meter what it reads now. Closes no interval and stores no record. The server does not wait for the meter: the answer is a token to poll with GET .../sample/{token}. At most one request per meter every 10 seconds, whoever asks.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The BluEM device id."}, {"$ref": "#/components/parameters/IdempotencyKey"}], "responses": {"202": {"description": "Sent to the meter. Location names the resource to poll.", "content": {"application/json": {"schema": {"type": "object", "properties": {"device_id": {"type": "string"}, "token": {"type": "string", "example": "5f3a9c1e"}, "state": {"type": "string", "enum": ["pending"]}, "requested_at": {"type": "string", "format": "date-time"}, "timeout_s": {"type": "integer", "example": 30, "description": "After this the outcome is final."}}}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}, "Location": {"$ref": "#/components/headers/Location"}, "Idempotent-Replayed": {"$ref": "#/components/headers/Idempotent-Replayed"}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Energy data access not permitted (required_permission energy_read).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope, unknown, or not a BluEM device.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "code device_offline: the meter is not connected and nothing was sent. Carries status, status_changed_at and last_seen_at. Or code idempotency_in_progress: a request with the same Idempotency-Key is still running; retry after Retry-After.", "content": {"application/json": {"schema": {"allOf": [{"$ref": "#/components/schemas/Error"}, {"type": "object", "properties": {"status": {"type": ["string", "null"]}, "status_changed_at": {"type": ["string", "null"], "format": "date-time"}, "last_seen_at": {"type": ["string", "null"], "format": "date-time"}}}]}}}}, "422": {"description": "Code idempotency_key_reused: this Idempotency-Key was used with a different request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "This meter was asked less than 10 seconds ago. Retry-After says how long to wait. A rate limit was reached: the per-partner request limit (RateLimit-Remaining is then 0), or a per-device or per-operation limit of the route. Retry-After says when to retry.", "headers": {"Retry-After": {"$ref": "#/components/headers/Retry-After"}, "X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "503": {"description": "code publish_failed: the broker could not be reached.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "tags": ["BluEM energy monitors"]}}, "/v1/bluem/devices/{device_id}/sample/{token}": {"get": {"summary": "Outcome of a live reading (BluEM)", "description": "Requires the energy-read permission (energy_read). Poll every second or two until state is no longer pending; it is final within 30 seconds. The reading is a fixed set of electrical quantities and nothing of the device's own status.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The BluEM device id."}, {"name": "token", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The token a POST .../sample returned."}], "responses": {"200": {"description": "The state of the request.", "content": {"application/json": {"schema": {"type": "object", "properties": {"device_id": {"type": "string"}, "token": {"type": "string"}, "state": {"type": "string", "enum": ["pending", "done", "failed", "no_reply"]}, "reason": {"type": ["string", "null"], "description": "Set when state is failed: busy, config_mode, unsupported, rejected or no_snapshot."}, "requested_at": {"type": "string", "format": "date-time"}, "reading": {"allOf": [{"$ref": "#/components/schemas/BluEMLiveReading"}], "description": "Set when state is done."}}}, "example": {"device_id": "EM123", "token": "0a1b2c3d", "state": "done", "reason": null, "requested_at": "2026-09-30T14:02:09.926+00:00", "reading": {"link": "ok", "answered": true, "voltage_v": 122.0, "current_a": 0.6268, "power_w": 58.54, "power_factor": 0.77, "frequency_hz": 60.0, "ct_turns": 5, "age_s": 0, "taken_at": "2026-09-30T14:02:11.204+00:00", "took_s": 1.278}}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Energy data access not permitted (required_permission energy_read).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope, unknown, or not a BluEM device; or a token that was not issued for this device.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["BluEM energy monitors"]}}, "/v1/bluem/devices/{device_id}/resetenergy": {"post": {"summary": "Zero a meter's lifetime energy counter (cmd/resetenergy)", "description": "DESTRUCTIVE AND IRREVERSIBLE. Requires the energy-reset permission (off by default, independent of every other flag) plus confirm equal to the device id exactly. Fire-and-forget (202) with the minted token; poll GET .../resetenergy/{token} for the outcome.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The BluEM device id."}, {"$ref": "#/components/parameters/IdempotencyKey"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"confirm": {"type": "string", "description": "Must equal the device id exactly."}}, "required": ["confirm"]}}}}, "responses": {"202": {"description": "Accepted. Location names the resource to poll.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SysCommandResult"}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}, "Location": {"$ref": "#/components/headers/Location"}, "Idempotent-Replayed": {"$ref": "#/components/headers/Idempotent-Replayed"}}}, "400": {"description": "A body that is not valid JSON, or not a JSON object.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Energy reset permission not granted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device outside scope, unknown, or not a BluEM device.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "Missing or mismatched 'confirm'. Or code idempotency_in_progress: a request with the same Idempotency-Key is still running; retry after Retry-After.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, "422": {"description": "Code idempotency_key_reused: this Idempotency-Key was used with a different request.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Rate limited (destructive ceiling included). A rate limit was reached: the per-partner request limit (RateLimit-Remaining is then 0), or a per-device or per-operation limit of the route. Retry-After says when to retry.", "headers": {"Retry-After": {"$ref": "#/components/headers/Retry-After"}, "X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "503": {"description": "Publish failed.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "tags": ["BluEM energy monitors"]}}, "/v1/bluem/devices/{device_id}/resetenergy/{token}": {"get": {"summary": "The outcome of one energy-reset request", "description": "Requires the energy-reset permission (same flag as the request). state is pending, done, duplicate or failed. A token not issued for this device is 404, like an unknown or out-of-scope device.", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The BluEM device id."}, {"name": "token", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The token returned by the POST above."}], "responses": {"200": {"description": "The current outcome. pending carries requested_at and unmatched_rejection; the final states carry result, reason and at.", "content": {"application/json": {"schema": {"type": "object", "required": ["state"], "properties": {"state": {"type": "string", "enum": ["pending", "done", "duplicate", "failed"]}, "result": {"type": ["string", "null"], "description": "The meter's answer, when final."}, "reason": {"type": ["string", "null"], "description": "Why it failed, when it did."}, "at": {"type": "string", "format": "date-time", "description": "When the meter answered, when final."}, "requested_at": {"type": "string", "format": "date-time", "description": "Pending only."}, "unmatched_rejection": {"type": "boolean", "description": "Pending only: the meter has logged a rejection that names no token since the request, which may or may not be this one's answer."}}}}}, "headers": {"X-Request-Id": {"$ref": "#/components/headers/X-Request-Id"}, "RateLimit-Limit": {"$ref": "#/components/headers/RateLimit-Limit"}, "RateLimit-Remaining": {"$ref": "#/components/headers/RateLimit-Remaining"}, "RateLimit-Reset": {"$ref": "#/components/headers/RateLimit-Reset"}}}, "401": {"$ref": "#/components/responses/Unauthorized"}, "403": {"description": "Energy reset permission not granted.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Device or token not found.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"$ref": "#/components/responses/TooManyRequests"}}, "tags": ["BluEM energy monitors"]}}}, "tags": [{"name": "Account", "description": "Your key, its permissions and its rate limits."}, {"name": "Devices", "description": "The devices in your scope, whatever their product: listing, provisioning, removal, state and health."}, {"name": "BluEM energy monitors", "description": "A meter's interval records, summary, consumption and live reading, and its two commands."}, {"name": "BluNotify lights", "description": "Commands and effects on a light's channels, and its temperature automation."}, {"name": "Configuration", "description": "Device names and organization placement, sensors and alert profiles."}, {"name": "System", "description": "The device's own MQTT interface: settings, status, structured commands and the platform file server."}, {"name": "File transfers", "description": "Pushing a platform file to a device as a job to poll."}]}