"description":"Save configuration updates.\n\n- Blank secret value keeps the existing stored value (no change).\n- Invalid values return 422 and nothing is written to the database.\n- If MQTT-related settings changed, the MQTT client reconnects automatically.",
"description":"Send a test SMTP email using the current runtime settings.\n\nReturns a structured result indicating success or the category of failure.\nThree possible outcomes:\n- 200 { \"result\": \"success\", \"message\": ... }\n- 400 { \"result\": \"config-error\", \"message\": ... } (EmailConfigurationError)\n- 502 { \"result\": \"failed\", \"message\": ... } (EmailDeliveryError)\n\nSMTP credentials are never echoed in the response.",
"description":"Test MQTT broker connectivity by attempting to connect and publishing a\ntest message to ``<ha_discovery_prefix>/home-automation/test``.\n\nThe message is visible in MQTT Explorer (or any subscriber) so users can\nconfirm the full broker publish path is working.\n\nThree possible outcomes:\n- 200 { \"result\": \"success\", \"message\": ... }\n- 400 { \"result\": \"config-error\", \"message\": ... } (not configured)\n- 502 { \"result\": \"failed\", \"message\": ... } (connection/publish error)\n\nMQTT credentials are never echoed in the response.",
"description":"Return location records with optional time-window filtering and pagination.\n\n- ``start`` / ``end`` are ISO8601 strings; filtering is **inclusive** on both bounds.\n- Results are ordered by ``datetime`` ascending.\n- ``limit`` is capped at 5000 to prevent full-table exports.",
"operationId":"get_locations_api_locations_get",
"parameters":[
{
"name":"limit",
"in":"query",
"required":false,
"schema":{
"type":"integer",
"maximum":5000,
"minimum":1,
"default":1000,
"title":"Limit"
}
},
{
"name":"offset",
"in":"query",
"required":false,
"schema":{
"type":"integer",
"minimum":0,
"default":0,
"title":"Offset"
}
},
{
"name":"start",
"in":"query",
"required":false,
"schema":{
"anyOf":[
{
"type":"string"
},
{
"type":"null"
}
],
"title":"Start"
}
},
{
"name":"end",
"in":"query",
"required":false,
"schema":{
"anyOf":[
{
"type":"string"
},
{
"type":"null"
}
],
"title":"End"
}
}
],
"responses":{
"200":{
"description":"Successful Response",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/LocationsResponse"
}
}
}
},
"422":{
"description":"Validation Error",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/poo":{
"get":{
"tags":[
"api-data"
],
"summary":"Get Poo",
"description":"Return poo records ordered by timestamp descending (most recent first).\n\n``limit`` is capped at 1000 to prevent full-table exports.",
"operationId":"get_poo_api_poo_get",
"parameters":[
{
"name":"limit",
"in":"query",
"required":false,
"schema":{
"type":"integer",
"maximum":1000,
"minimum":1,
"default":100,
"title":"Limit"
}
},
{
"name":"offset",
"in":"query",
"required":false,
"schema":{
"type":"integer",
"minimum":0,
"default":0,
"title":"Offset"
}
}
],
"responses":{
"200":{
"description":"Successful Response",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/PooResponse"
}
}
}
},
"422":{
"description":"Validation Error",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/public-ip":{
"get":{
"tags":[
"api-data"
],
"summary":"Get Public Ip",
"description":"Return the current public IP state and recent history.\n\n- ``state`` is ``null`` if no IP check has been performed yet.\n- ``history`` is ordered by ``observed_at`` descending (most recent first).\n- ``limit`` applies to the history list and is capped at 1000.",
"description":"Update the non-PK fields of a single location record.\n\n- ``person`` and ``datetime`` identify the row (composite PK) and are immutable.\n- Only ``latitude``, ``longitude``, and ``altitude`` may be updated.\n- Omitted body fields are left unchanged.\n- Returns **404** if the PK does not exist.",
"description":"Delete the single location record identified by its composite PK.\n\n- Exactly one row is deleted; **404** if the PK does not exist.\n- No batch delete / truncate path is available.",
"description":"Update the non-PK fields of a single poo record.\n\n- ``timestamp`` is the PK and is immutable.\n- Only ``status``, ``latitude``, and ``longitude`` may be updated.\n- Omitted body fields are left unchanged.\n- Returns **404** if the PK does not exist.",
"description":"Delete the single poo record identified by its PK.\n\n- Exactly one row is deleted; **404** if the PK does not exist.\n- No batch delete / truncate path is available.",
"description":"Return the price curve for the active contract.\n\n**Tibber contracts** (kind=\"tibber\"):\n Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending\n by ``starts_at``. At most ``limit`` rows are returned (most recent first\n within the window, then reversed to ascending order — identical to the\n modbus readings pattern).\n\n Response ``points`` carries per-slot:\n - ``buy = total`` (Tibber all-inclusive price)\n - ``sell = total − energy_tax − sell_adjust`` (from active version values)\n - ``level`` (Tibber price level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"manual\"):\n ``points`` is empty. ``tariff`` carries the four effective prices\n derived using the billing engine formula:\n - ``buy_dal = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal + energy_tax + ode``\n - ``sell_dal = energy.sell.dal``\n - ``sell_normal = energy.sell.normal``\n\n**No active contract**: returns kind=null, currency=\"EUR\", points=[], tariff=null (200).",
"operationId":"get_prices_api_energy_prices_get",
"parameters":[
{
"name":"start",
"in":"query",
"required":false,
"schema":{
"anyOf":[
{
"type":"string",
"format":"date-time"
},
{
"type":"null"
}
],
"description":"Inclusive start of the time window (ISO 8601). Defaults to the start of today UTC when omitted.",
"title":"Start"
},
"description":"Inclusive start of the time window (ISO 8601). Defaults to the start of today UTC when omitted."
},
{
"name":"end",
"in":"query",
"required":false,
"schema":{
"anyOf":[
{
"type":"string",
"format":"date-time"
},
{
"type":"null"
}
],
"description":"Inclusive end of the time window (ISO 8601). Defaults to the end of tomorrow UTC when omitted.",
"title":"End"
},
"description":"Inclusive end of the time window (ISO 8601). Defaults to the end of tomorrow UTC when omitted."
},
{
"name":"limit",
"in":"query",
"required":false,
"schema":{
"type":"integer",
"maximum":5000,
"minimum":1,
"description":"Maximum number of Tibber price points to return.",
"default":500,
"title":"Limit"
},
"description":"Maximum number of Tibber price points to return."
}
],
"responses":{
"200":{
"description":"Successful Response",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/PricesResponse"
}
}
}
},
"422":{
"description":"Validation Error",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/energy/costs":{
"get":{
"tags":[
"api-energy"
],
"summary":"Get Costs",
"description":"Return energy_cost_period rows within a time window.\n\nRows are ordered by ``period_start`` ascending. When the window contains\nmore rows than ``limit``, the **most recent** N rows are returned (DESC LIMIT),\nthen reversed to ascending order — identical to the modbus readings pattern.\n\nQuery parameters:\n- ``start``: inclusive lower bound on ``period_start`` (ISO 8601 datetime).\n- ``end``: inclusive upper bound on ``period_start`` (ISO 8601 datetime).\n- ``limit``: max rows to return (default 500, max {_COSTS_LIMIT_MAX}).",
"operationId":"get_costs_api_energy_costs_get",
"parameters":[
{
"name":"start",
"in":"query",
"required":false,
"schema":{
"anyOf":[
{
"type":"string",
"format":"date-time"
},
{
"type":"null"
}
],
"description":"Inclusive lower bound for period_start (ISO 8601).",
"title":"Start"
},
"description":"Inclusive lower bound for period_start (ISO 8601)."
},
{
"name":"end",
"in":"query",
"required":false,
"schema":{
"anyOf":[
{
"type":"string",
"format":"date-time"
},
{
"type":"null"
}
],
"description":"Inclusive upper bound for period_start (ISO 8601).",
"title":"End"
},
"description":"Inclusive upper bound for period_start (ISO 8601)."
},
{
"name":"limit",
"in":"query",
"required":false,
"schema":{
"type":"integer",
"maximum":5000,
"minimum":1,
"description":"Maximum number of cost periods to return (default 500, max 5000).",
"default":500,
"title":"Limit"
},
"description":"Maximum number of cost periods to return (default 500, max 5000)."
}
],
"responses":{
"200":{
"description":"Successful Response",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/CostsResponse"
}
}
}
},
"422":{
"description":"Validation Error",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/energy/costs/summary":{
"get":{
"tags":[
"api-energy"
],
"summary":"Get Costs Summary",
"description":"Aggregate billing for a time interval.\n\nCalls ``energy_cost.summarize(session, start, end)`` which computes:\n\n total_payable = Σ(net_cost) + fixed_costs − credits\n\nwhere ``fixed_costs`` is (network_fee + management_fee) apportioned to the\ninterval length in days (÷30 per month), and ``credits`` is heffingskorting\napportioned similarly (÷365 per year).\n\nBoth ``fixed_costs`` and ``credits`` are derived from the **currently active\ncontract version at ``end``**. When no active contract exists they are 0.",
"description":"Inclusive start of the summary interval (ISO 8601). Defaults to the start of the current UTC day.",
"title":"Start"
},
"description":"Inclusive start of the summary interval (ISO 8601). Defaults to the start of the current UTC day."
},
{
"name":"end",
"in":"query",
"required":false,
"schema":{
"anyOf":[
{
"type":"string",
"format":"date-time"
},
{
"type":"null"
}
],
"description":"Exclusive end of the summary interval (ISO 8601). Defaults to the start of the next UTC day (i.e. today's full data).",
"title":"End"
},
"description":"Exclusive end of the summary interval (ISO 8601). Defaults to the start of the next UTC day (i.e. today's full data)."
}
],
"responses":{
"200":{
"description":"Successful Response",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/SummaryResponse"
}
}
}
},
"422":{
"description":"Validation Error",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/energy/dsmr/latest":{
"get":{
"tags":[
"api-energy"
],
"summary":"Get Dsmr Latest",
"description":"Return the most recent dsmr_reading row.\n\nReturns ``{\"found\": false, \"recorded_at\": null, \"payload\": null}`` (200, not\n404) when no rows exist yet, so the front-end can distinguish \"no data\" from\na server error.",
"description":"Idempotently recompute billing records in a time window.\n\nCalls ``energy_cost.recompute_range(session, start, end)`` which overwrites\nexisting rows (including successful ones) for every UTC quarter-hour boundary\nin ``[start, end)``.\n\n**Idempotency**: repeated calls with the same window produce the same\noutcome. No rows are deleted; only upserted.\n\n**Window constraint**: the maximum allowed range is {_RECOMPUTE_MAX_DAYS} days.\nRequests exceeding this return 422.\n\nReturns the number of periods for which a billing record was written.\nPeriods skipped due to missing contract or missing Tibber price are not counted.",
"description":"Inclusive start of the recompute window (ISO 8601). Required.",
"title":"Start"
},
"description":"Inclusive start of the recompute window (ISO 8601). Required."
},
{
"name":"end",
"in":"query",
"required":true,
"schema":{
"type":"string",
"format":"date-time",
"description":"Exclusive end of the recompute window (ISO 8601). Required.",
"title":"End"
},
"description":"Exclusive end of the recompute window (ISO 8601). Required."
},
{
"name":"X-CSRF-Token",
"in":"header",
"required":false,
"schema":{
"anyOf":[
{
"type":"string"
},
{
"type":"null"
}
],
"title":"X-Csrf-Token"
}
}
],
"responses":{
"200":{
"description":"Successful Response",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/RecomputeResponse"
}
}
}
},
"422":{
"description":"Validation error (missing window or range too large)"
}
}
}
},
"/api/energy/tibber/test":{
"post":{
"tags":[
"api-energy"
],
"summary":"Post Tibber Test",
"description":"Test Tibber API connectivity by fetching the current price point.\n\nThree possible outcomes:\n\n- **200** ``{ result: \"success\", message: ..., price: {...} }``\n The Tibber API responded with a valid current price. ``price`` contains\n starts_at, total, energy, tax, currency, and level.\n\n- **400** ``{ result: \"config-error\", message: ... }``\n The Tibber API token is empty or not configured.\n\n- **502** ``{ result: \"failed\", message: ... }``\n The API call failed (authentication rejected, network error, timeout,\n unexpected response, etc.).\n\nThe API token is **never** included in the response body or logged.",
"description":"List all available pricing profile structures.\n\nReturns the full profile structure for each supported contract kind\n(``manual`` and ``tibber``). The front-end uses this to dynamically\nrender the correct fields and labels for the contract creation/editing form.",
"description":"List all energy contracts with their active status.\n\nReturns a flat list (no embedded version history); use\nGET /api/energy/contracts/{id} to fetch the full version history for a\nspecific contract.",
"description":"Create a new energy contract with an initial pricing version.\n\nThe ``values`` dict is validated against the YAML profile for the given\n``kind``; non-conforming values result in 422 Unprocessable Entity.\nThe new contract is created with ``active=False``; use\nPATCH /api/energy/contracts/{id} with ``active=true`` to activate it.",
"description":"Return a single energy contract with its full version history.\n\nVersions are ordered by ``effective_from`` ascending so the caller can\neasily inspect the pricing timeline.",
"description":"Partially update a contract: rename or change activation status.\n\n- ``name``: updates the human-readable label.\n- ``active=true``: activates this contract (all others are deactivated).\n- ``active=false``: deactivates this contract (no effect on others).\n\nAt most one contract may be active at any time; the service layer enforces\nmutual exclusion.",
"description":"Add a new pricing version to an existing contract.\n\nThis is how price changes are recorded: the current open version is\nautomatically closed (its ``effective_to`` is set to ``body.effective_from``)\nand a new version is created starting at ``body.effective_from``.\n\nThe ``values`` dict must conform to the contract's pricing profile.\nNon-conforming values return 422. If ``effective_from`` is not strictly\nafter the previous version's ``effective_from``, 422 is returned without\nwriting any rows.\n\nHistorical versions are never modified; this endpoint is append-only.",
"description":"List all meter epochs in ascending ``started_at`` order.\n\nReturns the full historical sequence of meter installations across all\ncommodities. The active meter (``ended_at=null``) appears last because it\nhas the latest ``started_at``.",
"description":"Declare a new meter epoch (swap, home move, or initial declaration).\n\nCloses the current active meter for the given commodity at ``started_at``\nand opens a new active meter. If no active meter exists, the new meter is\nsimply created without closing anything.\n\n**Validation**: ``started_at`` must be **≥** the current active meter's\nown ``started_at`` (no chronological backdate below the active epoch's\nstart). Equal timestamps are allowed (replaces the current meter at the\nsame logical moment). Violation → 422.\n\n**Retroactive recompute**: if ``started_at`` is in the past, billing\nrecords from that point forward are re-judged via ``recompute_range`` to\nreflect the new meter attribution. The response includes the count of\nrecomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``\n— the recompute is transparent; callers should re-fetch costs if needed).",
"description":"Partially update a meter epoch: rename, edit note, or correct started_at.\n\n- ``label``: updates the human-readable label.\n- ``note``: updates the free-form note.\n- ``started_at``: **retroactive correction** — shifts this meter's start\n boundary. The service layer maintains timeline continuity by also\n updating the preceding meter's ``ended_at``. Validation:\n * Must be strictly after the previous meter's own ``started_at``.\n * Must be strictly before this meter's ``ended_at`` (if closed).\n Violation → 422.\n\n**Retroactive recompute when ``started_at`` changes**: billing records in\nthe window ``[min(old, new), now)`` are re-judged to reflect the corrected\nmeter attribution.\n\nNot found → 404.",
"description":"Return the full exposable-entity catalog with toggle states and MQTT status.\n\nThe catalog is computed dynamically from registered providers (e.g. the\nModbus provider enumerates all enabled devices and their metric entities).\nToggle states come from the ``exposed_entity_toggle`` table; entities with\nno row default to ``enabled=False``.",
"operationId":"get_expose_api_expose_get",
"responses":{
"200":{
"description":"Successful Response",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/ExposeResponse"
}
}
}
}
}
},
"put":{
"tags":[
"api-expose"
],
"summary":"Put Expose",
"description":"Set per-entity toggle state.\n\nAccepts a map of ``{key: bool}`` and upserts rows in the\n``exposed_entity_toggle`` table. Only keys present in ``body.toggles``\nare touched; other entities' toggles are left unchanged.\n\nAfter writing the toggles, triggers a HA Discovery re-publish so any\nchanges (enabled ↔ disabled) are reflected in Home Assistant immediately.",
"description":"Manually trigger a full HA Discovery re-publish.\n\nCalls ``publish_discovery(session)`` from the HA discovery service (M5-T11).\nReturns a status indicating whether the publish was attempted (or skipped\nbecause MQTT / discovery is not enabled / connected).",
"description":"Create a new Modbus device.\n\n- Validates that the referenced ``profile`` exists; returns 422 if not.\n- Returns 201 with the created device on success.",
"description":"Partially update a Modbus device (including enable/disable).\n\nOnly fields explicitly provided in the request body are updated.\nProviding ``profile`` triggers a profile-existence check (422 if unknown).",
"description":"Delete a Modbus device.\n\n**Default behaviour (cascade=false)**:\nReturns 409 Conflict if the device has any associated readings; use\n``enabled=false`` to disable it instead.\n\n**Cascade delete (cascade=true)**:\nPermanently deletes the device together with all its readings and any\n``ExposedEntityToggle`` rows whose key matches ``modbus.<uuid>.*``.\nAlso makes a best-effort attempt to clear the device's HA Discovery\nconfig topics from MQTT (empty retained payload) before the DB rows\nare removed. MQTT failures are swallowed — the DB deletion proceeds\nregardless.\nReturns HTTP 200 with a ``ModbusDeleteResponse`` JSON body on success.\n\n**Application-layer safety**: the 409 guard uses an explicit SELECT COUNT\nquery to return a friendly message. FK RESTRICT is enforced at runtime\n(the app sets ``PRAGMA foreign_keys=ON``), so the cascade path deletes\nreadings before the device.",
"description":"Return the most recent reading for a device.\n\nIf no readings exist yet, returns ``{\"found\": false, \"recorded_at\": null,\n\"payload\": null}`` (200, not 404) so the front-end can distinguish\n\"device exists but has no data\" from \"device not found\".",
"description":"Return time-range readings for a device.\n\nWhen the window contains more rows than ``limit``, the **most recent** N rows\nare returned (``ORDER BY recorded_at DESC LIMIT n``), then reversed to\nascending order before being sent to the client. This ensures that for long\ntime-range requests (e.g. 6 h / 24 h) the caller always sees the latest data\nrather than the oldest segment of the window.\n\nWhen the window has fewer rows than ``limit`` the full window is returned in\nascending order — behaviour is identical to a plain ascending query.\n\nThe response schema is unchanged: items are always ``recorded_at`` ascending.\n\nThe query uses the ``(device_id, recorded_at)`` composite index for\nefficient time-window scans.\n\nQuery parameters:\n- ``start``: inclusive lower bound (ISO8601 datetime)\n- ``end``: inclusive upper bound (ISO8601 datetime)\n- ``limit``: max rows to return (default 500, max {_READINGS_LIMIT_MAX})",
"description":"Return the metric catalogue for a device's profile.\n\nEach entry has ``key``, ``label`` (derived from key if the profile has\nnone — underscores → spaces, title-cased), ``unit``, and ``device_class``.\nThis is the authoritative metadata source for front-end card labels and\nchart axis labels.",
"description":"Immediately read and decode the device once without persisting any data.\n\nUseful for validating gateway connectivity and Modbus address settings\nbefore relying on the background polling job.\n\nThis is a write-class endpoint (it initiates network I/O on demand) and\ntherefore requires a CSRF token. The response payload is never stored.",
"description":"Authenticate with username and password.\n\nOn success, sets an HttpOnly session cookie and returns the session user + CSRF token.\nOn failure, returns 401 with no cookie set.\nRepeated failures trigger exponential back-off (429 + Retry-After).\nNo X-CSRF-Token required (unauthenticated endpoint).",
"description":"Revoke the current session and clear the session cookie.\nRequires authentication and X-CSRF-Token header.\nReturns 204 No Content.",
"operationId":"post_logout_api_auth_logout_post",
"parameters":[
{
"name":"X-CSRF-Token",
"in":"header",
"required":false,
"schema":{
"anyOf":[
{
"type":"string"
},
{
"type":"null"
}
],
"title":"X-Csrf-Token"
}
}
],
"responses":{
"200":{
"description":"Successful Response",
"content":{
"application/json":{
"schema":{}
}
}
},
"422":{
"description":"Validation Error",
"content":{
"application/json":{
"schema":{
"$ref":"#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/auth/password":{
"post":{
"tags":[
"api-session"
],
"summary":"Post Change Password",
"description":"Change the current user's password.\nRequires authentication and X-CSRF-Token header.\nOn AuthPasswordChangeError returns 400 with a generic message.\nOn success, force_password_change becomes False (handled by the service).\nReturns 204 No Content.",
"description":"Generate a new pending TOTP secret, otpauth URI, and one-time recovery codes.\n\nThe secret is stored in the DB but TOTP is NOT yet enabled (totp_enabled stays\nFalse until the user confirms with POST /api/auth/totp/enable).\n\nRecovery codes are returned here as plaintext exactly once; their Argon2 hashes\nare persisted immediately so enable only needs to flip the enabled flag.\n\nRepeating this call replaces any prior pending secret and regenerates codes.\n\nRequires: session cookie + X-CSRF-Token.",
"description":"Enable TOTP by confirming with the current 6-digit code from the authenticator app.\n\nRequires a prior call to POST /api/auth/totp/setup (so that a pending secret\nexists). On success, totp_enabled becomes True.\n\nReturns 400 if the code is wrong or there is no pending secret.\nRequires: session cookie + X-CSRF-Token.",
"description":"Disable TOTP. The caller must provide exactly one of:\n- ``password``: the user's current login password, OR\n- ``code``: the current 6-digit TOTP code.\n\nOn success: totp_enabled=False, totp_secret cleared, all recovery codes deleted.\nReturns 400 if neither credential matches or neither is provided.\nRequires: session cookie + X-CSRF-Token.",
"description":"Return the current TOTP status for the authenticated user.\n\nResponse contains only ``{\"enabled\": bool}``.\nSecret and recovery codes are NEVER returned here.\nRequires: session cookie only (no CSRF — read-only).",
"description":"UTC datetime from which the first pricing version is effective. Defaults to the current UTC time when omitted."
}
},
"type":"object",
"required":[
"name",
"kind",
"values"
],
"title":"ContractCreate",
"description":"Request body for POST /api/energy/contracts.\n\n``effective_from`` defaults to the current UTC time if not provided,\ngiving the first version an open-ended start from \"now\".\n``kind`` is validated at the application layer against the profile registry;\nclients should send ``\"manual\"`` or ``\"tibber\"``."
"description":"Response schema for a single EnergyContract with full version history.\n\nReturned by GET /api/energy/contracts/{id} and by successful POST / PATCH\noperations where the caller needs to see all version data."
},
"ContractListResponse":{
"properties":{
"items":{
"items":{
"$ref":"#/components/schemas/ContractResponse"
},
"type":"array",
"title":"Items"
},
"total":{
"type":"integer",
"title":"Total"
}
},
"type":"object",
"required":[
"items",
"total"
],
"title":"ContractListResponse",
"description":"Response schema for GET /api/energy/contracts."
},
"ContractPatch":{
"properties":{
"name":{
"anyOf":[
{
"type":"string",
"maxLength":255,
"minLength":1
},
{
"type":"null"
}
],
"title":"Name"
},
"active":{
"anyOf":[
{
"type":"boolean"
},
{
"type":"null"
}
],
"title":"Active"
}
},
"type":"object",
"title":"ContractPatch",
"description":"Request body for PATCH /api/energy/contracts/{id}.\n\nAll fields are optional. Sending ``active=true`` activates this contract\n(deactivating all others); ``active=false`` deactivates it without affecting\nother contracts."
},
"ContractResponse":{
"properties":{
"id":{
"type":"integer",
"title":"Id"
},
"name":{
"type":"string",
"title":"Name"
},
"kind":{
"type":"string",
"title":"Kind"
},
"active":{
"type":"boolean",
"title":"Active"
},
"currency":{
"type":"string",
"title":"Currency"
},
"created_at":{
"type":"string",
"format":"date-time",
"title":"Created At"
},
"updated_at":{
"type":"string",
"format":"date-time",
"title":"Updated At"
}
},
"type":"object",
"required":[
"id",
"name",
"kind",
"active",
"currency",
"created_at",
"updated_at"
],
"title":"ContractResponse",
"description":"Response schema for a single EnergyContract (without embedded versions).\n\nUsed for list responses where embedding all versions would be expensive."
},
"ContractVersionResponse":{
"properties":{
"id":{
"type":"integer",
"title":"Id"
},
"effective_from":{
"type":"string",
"format":"date-time",
"title":"Effective From"
},
"effective_to":{
"anyOf":[
{
"type":"string",
"format":"date-time"
},
{
"type":"null"
}
],
"title":"Effective To"
},
"values":{
"additionalProperties":true,
"type":"object",
"title":"Values"
},
"created_at":{
"type":"string",
"format":"date-time",
"title":"Created At"
}
},
"type":"object",
"required":[
"id",
"effective_from",
"effective_to",
"values",
"created_at"
],
"title":"ContractVersionResponse",
"description":"Response schema for a single EnergyContractVersion row."
"description":"Response for GET /api/energy/dsmr/latest.\n\n``found`` is False when no dsmr_reading rows exist yet. The front-end\nshould check ``found`` before reading ``recorded_at`` or ``payload``."
"description":"One exposable entity in the catalog.\n\n``value_getter`` is intentionally excluded — it is a non-serialisable\ncallable and is only used internally by the HA Discovery service."
},
"ExposeResponse":{
"properties":{
"catalog":{
"items":{
"$ref":"#/components/schemas/CatalogEntrySchema"
},
"type":"array",
"title":"Catalog"
},
"mqtt_status":{
"$ref":"#/components/schemas/MqttStatusSchema"
}
},
"type":"object",
"required":[
"catalog",
"mqtt_status"
],
"title":"ExposeResponse",
"description":"Response for GET /api/expose."
},
"ExposeUpdateRequest":{
"properties":{
"toggles":{
"additionalProperties":{
"type":"boolean"
},
"type":"object",
"title":"Toggles"
}
},
"type":"object",
"required":[
"toggles"
],
"title":"ExposeUpdateRequest",
"description":"Request body for PUT /api/expose.\n\n``toggles`` is a map from entity key to desired enabled state (bool).\nOnly keys present in the map are updated; absent keys are untouched."
},
"ExposeUpdateResponse":{
"properties":{
"catalog":{
"items":{
"$ref":"#/components/schemas/CatalogEntrySchema"
},
"type":"array",
"title":"Catalog"
},
"mqtt_status":{
"$ref":"#/components/schemas/MqttStatusSchema"
}
},
"type":"object",
"required":[
"catalog",
"mqtt_status"
],
"title":"ExposeUpdateResponse",
"description":"Response for PUT /api/expose (returns updated catalog + status)."
"description":"Effective buy price, low-tariff / dal (EUR/kWh)."
},
"buy_normal":{
"type":"number",
"title":"Buy Normal",
"description":"Effective buy price, normal / high-tariff (EUR/kWh)."
},
"sell_dal":{
"type":"number",
"title":"Sell Dal",
"description":"Sell price, low-tariff / dal (EUR/kWh)."
},
"sell_normal":{
"type":"number",
"title":"Sell Normal",
"description":"Sell price, normal / high-tariff (EUR/kWh)."
}
},
"type":"object",
"required":[
"buy_dal",
"buy_normal",
"sell_dal",
"sell_normal"
],
"title":"ManualTariffSchema",
"description":"Fixed-tariff breakdown for manual contracts.\n\nPrices are the *effective* buy prices as used by the billing engine\n(energy_buy_x + energy_tax + ode) and the raw sell prices."
"description":"UTC (or server-local naive) datetime from which this meter epoch starts. May be in the past (retroactive declaration)."
},
"reason":{
"$ref":"#/components/schemas/MeterReason",
"description":"Why this epoch was created. One of: initial, meter_swap, home_move, other."
},
"note":{
"anyOf":[
{
"type":"string",
"maxLength":1024
},
{
"type":"null"
}
],
"title":"Note"
},
"commodity":{
"type":"string",
"maxLength":32,
"minLength":1,
"title":"Commodity",
"description":"Energy commodity this meter measures. Defaults to 'electricity'.",
"default":"electricity"
}
},
"type":"object",
"required":[
"label",
"started_at",
"reason"
],
"title":"MeterDeclareRequest",
"description":"Request body for POST /api/energy/meters.\n\nDeclares a new meter epoch (swap, home move, or initial declaration). The\nservice layer closes the current active meter for the given commodity at\n``started_at`` and opens a new one.\n\n``started_at`` follows the Principle-A localisation convention: a\ntimezone-naive value is interpreted as the **server's local wall-clock time**\n(e.g. CEST midnight → stored as UTC the night before); a timezone-aware\nvalue is converted to UTC as-is. Omitting ``started_at`` is not allowed —\nevery meter declaration must carry an explicit start timestamp.\n\n``commodity`` defaults to ``\"electricity\"``; the field is available for\nfuture use with ``gas`` or ``heating``."
},
"MeterListResponse":{
"properties":{
"items":{
"items":{
"$ref":"#/components/schemas/MeterResponse"
},
"type":"array",
"title":"Items"
},
"total":{
"type":"integer",
"title":"Total"
}
},
"type":"object",
"required":[
"items",
"total"
],
"title":"MeterListResponse",
"description":"Response schema for GET /api/energy/meters.\n\nMeters are returned in ascending ``started_at`` order so the caller sees\nthe historical installation sequence."
},
"MeterPatchRequest":{
"properties":{
"label":{
"anyOf":[
{
"type":"string",
"maxLength":255,
"minLength":1
},
{
"type":"null"
}
],
"title":"Label"
},
"note":{
"anyOf":[
{
"type":"string",
"maxLength":1024
},
{
"type":"null"
}
],
"title":"Note"
},
"started_at":{
"anyOf":[
{
"type":"string",
"format":"date-time"
},
{
"type":"null"
}
],
"title":"Started At",
"description":"Retroactive correction of the meter epoch start timestamp. Triggers billing recompute over the affected window."
}
},
"type":"object",
"title":"MeterPatchRequest",
"description":"Request body for PATCH /api/energy/meters/{id}.\n\nAll fields are optional. Only non-``None`` values are applied.\n\nUpdating ``started_at`` is a **retroactive correction**: the service layer\nmaintains timeline continuity (adjusting the preceding meter's ``ended_at``)\nand the API layer triggers ``recompute_range`` over the affected window so\nthat billing attribution is re-judged."
},
"MeterReason":{
"type":"string",
"enum":[
"initial",
"meter_swap",
"home_move",
"other"
],
"title":"MeterReason",
"description":"Allowed values for the meter epoch creation reason."
},
"MeterResponse":{
"properties":{
"id":{
"type":"integer",
"title":"Id"
},
"label":{
"type":"string",
"title":"Label"
},
"commodity":{
"type":"string",
"title":"Commodity"
},
"started_at":{
"type":"string",
"format":"date-time",
"title":"Started At"
},
"ended_at":{
"anyOf":[
{
"type":"string",
"format":"date-time"
},
{
"type":"null"
}
],
"title":"Ended At"
},
"reason":{
"type":"string",
"title":"Reason"
},
"note":{
"anyOf":[
{
"type":"string"
},
{
"type":"null"
}
],
"title":"Note"
},
"created_at":{
"type":"string",
"format":"date-time",
"title":"Created At"
}
},
"type":"object",
"required":[
"id",
"label",
"commodity",
"started_at",
"ended_at",
"reason",
"note",
"created_at"
],
"title":"MeterResponse",
"description":"Response schema for a single Meter epoch row.\n\n``ended_at`` is ``null`` for the currently active meter."
"description":"Response schema for listing Modbus devices."
},
"ModbusDeviceResponse":{
"properties":{
"uuid":{
"type":"string",
"title":"Uuid"
},
"friendly_name":{
"type":"string",
"title":"Friendly Name"
},
"transport":{
"type":"string",
"title":"Transport"
},
"host":{
"type":"string",
"title":"Host"
},
"port":{
"type":"integer",
"title":"Port"
},
"unit_id":{
"type":"integer",
"title":"Unit Id"
},
"profile":{
"type":"string",
"title":"Profile"
},
"poll_interval_s":{
"type":"integer",
"title":"Poll Interval S"
},
"enabled":{
"type":"boolean",
"title":"Enabled"
},
"last_poll_at":{
"anyOf":[
{
"type":"string",
"format":"date-time"
},
{
"type":"null"
}
],
"title":"Last Poll At"
},
"last_poll_ok":{
"anyOf":[
{
"type":"boolean"
},
{
"type":"null"
}
],
"title":"Last Poll Ok"
},
"created_at":{
"type":"string",
"format":"date-time",
"title":"Created At"
},
"updated_at":{
"type":"string",
"format":"date-time",
"title":"Updated At"
}
},
"type":"object",
"required":[
"uuid",
"friendly_name",
"transport",
"host",
"port",
"unit_id",
"profile",
"poll_interval_s",
"enabled",
"last_poll_at",
"last_poll_ok",
"created_at",
"updated_at"
],
"title":"ModbusDeviceResponse",
"description":"Response schema for a single Modbus device."
},
"ModbusDeviceUpdate":{
"properties":{
"friendly_name":{
"anyOf":[
{
"type":"string",
"maxLength":255,
"minLength":1
},
{
"type":"null"
}
],
"title":"Friendly Name"
},
"transport":{
"anyOf":[
{
"type":"string",
"maxLength":16
},
{
"type":"null"
}
],
"title":"Transport"
},
"host":{
"anyOf":[
{
"type":"string",
"maxLength":255,
"minLength":1
},
{
"type":"null"
}
],
"title":"Host"
},
"port":{
"anyOf":[
{
"type":"integer",
"maximum":65535.0,
"minimum":1.0
},
{
"type":"null"
}
],
"title":"Port"
},
"unit_id":{
"anyOf":[
{
"type":"integer",
"maximum":247.0,
"minimum":0.0
},
{
"type":"null"
}
],
"title":"Unit Id"
},
"profile":{
"anyOf":[
{
"type":"string",
"maxLength":64,
"minLength":1
},
{
"type":"null"
}
],
"title":"Profile"
},
"poll_interval_s":{
"anyOf":[
{
"type":"integer",
"maximum":3600.0,
"minimum":1.0
},
{
"type":"null"
}
],
"title":"Poll Interval S"
},
"enabled":{
"anyOf":[
{
"type":"boolean"
},
{
"type":"null"
}
],
"title":"Enabled"
}
},
"type":"object",
"title":"ModbusDeviceUpdate",
"description":"Request body for PATCH /api/modbus/devices/{uuid} — all fields optional."
},
"ModbusLatestResponse":{
"properties":{
"found":{
"type":"boolean",
"title":"Found"
},
"recorded_at":{
"anyOf":[
{
"type":"string",
"format":"date-time"
},
{
"type":"null"
}
],
"title":"Recorded At"
},
"payload":{
"anyOf":[
{
"additionalProperties":true,
"type":"object"
},
{
"type":"null"
}
],
"title":"Payload"
}
},
"type":"object",
"required":[
"found",
"recorded_at",
"payload"
],
"title":"ModbusLatestResponse",
"description":"Response for the /latest endpoint.\n\n``found`` is False and ``recorded_at``/``payload`` are None when the device\nhas no readings yet. Callers should check ``found`` before using the values."
},
"ModbusMetricsResponse":{
"properties":{
"profile":{
"type":"string",
"title":"Profile"
},
"metrics":{
"items":{
"$ref":"#/components/schemas/MetricInfo"
},
"type":"array",
"title":"Metrics"
}
},
"type":"object",
"required":[
"profile",
"metrics"
],
"title":"ModbusMetricsResponse",
"description":"Response schema for GET /api/modbus/devices/{uuid}/metrics."
},
"ModbusProfilesResponse":{
"properties":{
"profiles":{
"items":{
"$ref":"#/components/schemas/ProfileSummary"
},
"type":"array",
"title":"Profiles"
}
},
"type":"object",
"required":[
"profiles"
],
"title":"ModbusProfilesResponse",
"description":"Response schema for GET /api/modbus/profiles."
},
"ModbusReadingResponse":{
"properties":{
"recorded_at":{
"type":"string",
"format":"date-time",
"title":"Recorded At"
},
"payload":{
"additionalProperties":true,
"type":"object",
"title":"Payload"
}
},
"type":"object",
"required":[
"recorded_at",
"payload"
],
"title":"ModbusReadingResponse",
"description":"A single reading row: timestamp + decoded payload."
"description":"Response schema for the readings time-range endpoint."
},
"ModbusTestReadResponse":{
"properties":{
"ok":{
"type":"boolean",
"title":"Ok"
},
"payload":{
"anyOf":[
{
"additionalProperties":true,
"type":"object"
},
{
"type":"null"
}
],
"title":"Payload"
},
"error":{
"anyOf":[
{
"type":"string"
},
{
"type":"null"
}
],
"title":"Error"
}
},
"type":"object",
"required":[
"ok"
],
"title":"ModbusTestReadResponse",
"description":"Response for POST /api/modbus/devices/{uuid}/test.\n\nOn success ``ok=True`` and ``payload`` contains the decoded values.\nOn failure ``ok=False`` and ``error`` describes the problem."
"description":"All-in buy price in EUR/kWh (including taxes)."
},
"sell":{
"type":"number",
"title":"Sell",
"description":"Net sell price in EUR/kWh."
},
"level":{
"anyOf":[
{
"type":"string"
},
{
"type":"null"
}
],
"title":"Level",
"description":"Tibber price level (CHEAP / NORMAL / EXPENSIVE); null for manual."
}
},
"type":"object",
"required":[
"starts_at",
"buy",
"sell"
],
"title":"PricePointSchema",
"description":"A single 15-minute price point (tibber) or placeholder entry."
},
"PricesResponse":{
"properties":{
"kind":{
"anyOf":[
{
"type":"string"
},
{
"type":"null"
}
],
"title":"Kind",
"description":"Active contract kind ('tibber' or 'manual'), or null if no active contract."
},
"currency":{
"type":"string",
"title":"Currency",
"description":"ISO 4217 currency code."
},
"points":{
"items":{
"$ref":"#/components/schemas/PricePointSchema"
},
"type":"array",
"title":"Points",
"description":"15-minute price points for tibber contracts (ascending by starts_at). Empty for manual contracts or when no active contract exists."
},
"tariff":{
"anyOf":[
{
"$ref":"#/components/schemas/ManualTariffSchema"
},
{
"type":"null"
}
],
"description":"Fixed tariff table for manual contracts. Null for tibber contracts and when no active contract exists."
}
},
"type":"object",
"required":[
"kind",
"currency",
"points"
],
"title":"PricesResponse",
"description":"Response for GET /api/energy/prices.\n\n``kind`` mirrors the active contract kind:\n- ``\"tibber\"`` → ``points`` has actual 15-min price entries; ``tariff`` is null.\n- ``\"manual\"`` → ``points`` is empty; ``tariff`` carries the fixed-rate table.\n- ``None`` → no active contract; both ``points`` and ``tariff`` are empty/null.\n\n``currency`` comes from the active contract (or \"EUR\" fallback).\n``points`` is always ascending by ``starts_at``."
"description":"Response schema for GET /api/energy/profiles.\n\n``profiles`` is a list of raw profile dicts as produced by\n``list_profiles()`` (Pydantic model dumps). Each entry contains at minimum\n``kind`` and ``label``; the full nested structure allows the front-end to\nrender a type-appropriate form for each pricing profile."
"description":"Number of 15-minute periods for which a billing record was written (inserted or updated). Periods skipped due to missing contract or missing Tibber price are not counted."
}
},
"type":"object",
"required":[
"recomputed"
],
"title":"RecomputeResponse",
"description":"Response for POST /api/energy/costs/recompute."
"description":"Number of non-degraded billing periods in range."
},
"degraded_count":{
"type":"integer",
"title":"Degraded Count",
"description":"Number of degraded billing periods in range."
},
"days":{
"type":"number",
"title":"Days",
"description":"Interval length in days."
}
},
"type":"object",
"required":[
"currency",
"metered_import",
"metered_export",
"metered_net",
"fixed_costs",
"credits",
"total_payable",
"period_count",
"degraded_count",
"days"
],
"title":"SummaryResponse",
"description":"Response for GET /api/energy/costs/summary.\n\nAll monetary values are in ``currency``.\n\n``total_payable = metered_net + fixed_costs − credits``"
},
"TibberTestPriceSchema":{
"properties":{
"starts_at":{
"type":"string",
"format":"date-time",
"title":"Starts At"
},
"total":{
"type":"number",
"title":"Total"
},
"energy":{
"type":"number",
"title":"Energy"
},
"tax":{
"type":"number",
"title":"Tax"
},
"currency":{
"type":"string",
"title":"Currency"
},
"level":{
"anyOf":[
{
"type":"string"
},
{
"type":"null"
}
],
"title":"Level"
}
},
"type":"object",
"required":[
"starts_at",
"total",
"energy",
"tax",
"currency"
],
"title":"TibberTestPriceSchema",
"description":"Current Tibber price point returned on a successful test.\n\nCarries enough fields for the front-end to confirm the API is working and\ndisplay the live price. The API token is **never** included."
"description":"Three-state response for POST /api/energy/tibber/test.\n\nPossible ``result`` values:\n\n- ``\"success\"`` — Tibber API responded with a valid price.\n- ``\"config-error\"`` — Token is missing or not configured.\n- ``\"failed\"`` — API call failed (auth rejected, network error, timeout, etc.).\n\n``price`` is populated only on ``\"success\"``; it is null otherwise.\n``message`` always contains a human-readable explanation."
"description":"Disable TOTP by proving identity.\n\nExactly one of ``password`` or ``code`` must be provided."
},
"TotpEnableRequest":{
"properties":{
"code":{
"type":"string",
"title":"Code"
}
},
"type":"object",
"required":[
"code"
],
"title":"TotpEnableRequest",
"description":"The user confirms setup by providing the 6-digit TOTP code."
},
"TotpSetupResponse":{
"properties":{
"secret":{
"type":"string",
"title":"Secret"
},
"otpauth_uri":{
"type":"string",
"title":"Otpauth Uri"
},
"recovery_codes":{
"items":{
"type":"string"
},
"type":"array",
"title":"Recovery Codes"
}
},
"type":"object",
"required":[
"secret",
"otpauth_uri",
"recovery_codes"
],
"title":"TotpSetupResponse",
"description":"Returned once after a setup call.\n\n``secret`` and ``recovery_codes`` are **one-time plaintext values**.\nThey are never returned again by any subsequent API call.\nThe frontend must display and instruct the user to save them before confirming."
},
"TotpStatusResponse":{
"properties":{
"enabled":{
"type":"boolean",
"title":"Enabled"
}
},
"type":"object",
"required":[
"enabled"
],
"title":"TotpStatusResponse",
"description":"Minimal status response — never exposes secret or recovery codes."