diff --git a/Cargo.toml b/Cargo.toml index 5533dab..19a44bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ tokio = { version = "1", features = ["full"] } tower-http = { version = "0.6", features = ["compression-gzip", "cors", "trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +utoipa-swagger-ui = { version = "=8.0.3", default-features = false, features = ["axum", "debug-embed", "vendored"] } url = "2" uuid = { version = "1", features = ["v4", "serde"] } diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index bc4df66..fed51be 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,12 +1,13 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore d3c8a734104ca6d68b1a07c41ef6832ca475e3fc257ccbbaa0afc3a7ad85bd17 ./Cargo.lock -0c1a0a807005fc51ac02dfa22bb1a1fa4ae372c200a456faa49b18389bc43b15 ./Cargo.toml +31eed008472815e59f3a6b7eaa1f9186c407f49a15d50d77d0aaa136d1c3ca82 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -a9d1837cefe92369e324a2f656cc4ce399884736d13c064eae44dae7e38faa7b ./README.md +5467c49d65a990966d3778fb928f6c67cbca7be94d2d54022d479821f0457816 ./README.md 41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs -797eb20f62d4e9a7351dc76989c2f60e1cf52a48a9bac6fa9d3a51f9c5b837e3 ./docs/API.md +34a90343baf4d189c58887bc0cf6f65e824614676afc94b5ea8363e9d262ccc6 ./docs/API.md 2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md +f10b54081d00e6d20aac14d351cad767b874544196628d71631b14125f1e88ba ./docs/openapi.json a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py 6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py @@ -71,7 +72,7 @@ ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/week fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh -ec5301fbad7adf463ee1205539481746388f757173301efb3c1ded6176487944 ./scripts/FILE_MANIFEST.sha256 +a35385734b1855dbea6d895075d54b17dce1621797cd65581a9404acff2ce645 ./scripts/FILE_MANIFEST.sha256 bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh @@ -82,10 +83,10 @@ bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/inst e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh -e16483e9afcea50ca92fa2ccf4ec5e5a2cc46b68b157865d20b09829b5fe9ed3 ./scripts/smoke.sh +c714e3cf2fba84ded718d102bd620cf879c1a8f3a5ae8f435be7863d8a0cd8e0 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh 4877f9e8217b6a77fb416722c3778373edac874ed3a583bb016ea76d4ffee7d4 ./scripts/verify_flow_logic.py -204dc035f6cbc657afb5ff17dfd19d5f567e6fae4318347e65158cacf2d60139 ./src/api.rs +3256e9b9a34e4ef9b69c27d630dad4a9b17a29afc894f56c65206e7aa78eab21 ./src/api.rs 825370f409124a719043200742c01c3441bc65f483108106140d13fe7bbcba91 ./src/api/assets.rs 6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs 63cb241c4536abed402f2a0ad37e7f299343df264eab6a33bb5354c15421e045 ./src/api/automations.rs @@ -98,6 +99,7 @@ a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/hist b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/house.rs 88e04a59bfedd5e6c54e6a2938b1031964be3a1505db2ca67d6290aa042cbe3b ./src/api/integrations.rs bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs +45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs 50cfd47e44e22cc802f22f97267c157fa5a6d19b51be921a6ca6b1ea7c9799d1 ./src/api/public_settings.rs a1d1a4ac071493b052e6bf20b03be67cf75726de95649e9a73aeba8d22395a15 ./src/api/schedules.rs 430f446371489a2b2f1532d589c71f60c98c1a92765ccb5e486b08e2da8f31c3 ./src/api/settings.rs diff --git a/README.md b/README.md index 9cac888..5b92dd4 100644 --- a/README.md +++ b/README.md @@ -375,6 +375,15 @@ Start here: **[docs/API.md — complete API reference](docs/API.md)** +Interactive API documentation is built into the controller: + +- Swagger UI: `http://127.0.0.1:8787/api-docs` +- OpenAPI 3.1 JSON: `http://127.0.0.1:8787/api-docs/openapi.json` + +Swagger UI includes endpoint descriptions, authentication schemes, request/response models, +examples and documented error responses. If `GREE_CONTROLLER_BASE_PATH` is configured, prefix +both paths with that base path. + Public health check: ```bash diff --git a/docs/API.md b/docs/API.md index 67553a6..3d5a70f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -20,6 +20,18 @@ Content-Type: application/json If `GREE_CONTROLLER_BASE_PATH=/gree` is configured, every HTTP and WebSocket path below is prefixed with `/gree`. +Interactive and machine-readable API documentation is served by the application itself: + +```text +GET /api-docs Swagger UI +GET /api-docs/openapi.json OpenAPI 3.1 document +``` + +The OpenAPI document contains endpoint descriptions, authentication schemes, parameters, +request/response models, examples and common error responses. Its `servers` entry follows the +configured `GREE_CONTROLLER_BASE_PATH`, so requests sent from Swagger UI target the current +controller instance correctly. + ## Authentication There are three access levels. @@ -30,6 +42,8 @@ No token is required for: ```text GET /api/health +GET /api-docs +GET /api-docs/openapi.json GET / GET /index.html GET /app.js diff --git a/docs/openapi.json b/docs/openapi.json new file mode 100644 index 0000000..615c7db --- /dev/null +++ b/docs/openapi.json @@ -0,0 +1,6905 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "GREE Controller API", + "version": "0.0.0", + "summary": "Local HTTP/WebSocket API for GREE HVAC control", + "description": "Self-hosted API used by the GREE Controller Web UI and Home Assistant integration.\n\n### Authentication\nNormal administrator endpoints accept `Authorization: Bearer ` or `x-api-token: ` when `GREE_CONTROLLER_APP_TOKEN` is configured. If the app token is empty, those endpoints intentionally run in trusted-LAN mode without authentication.\n\nThe restricted `/api/integrations/home-assistant/*` control surface always requires either a generated access token or the administrator app token. Generated tokens do **not** grant normal administrator API access.\n\nIn Swagger UI use **Authorize** and enter the token in either the bearer field or `x-api-token` field.\n\n### Control ownership\nThermostat zones, groups, house rules, direct device control, schedules and visual Flows share an ownership/arbitration model. Prefer zone/group/house endpoints for thermostat intent; direct device commands are technical/manual control and can deliberately take ownership.", + "license": { + "name": "MIT" + } + }, + "servers": [ + { + "url": "/", + "description": "This GREE Controller instance" + } + ], + "tags": [ + { + "name": "System", + "description": "Health, bootstrap and runtime diagnostics." + }, + { + "name": "Devices", + "description": "GREE discovery, binding, polling and direct technical device control." + }, + { + "name": "Zones", + "description": "Thermostat configuration, Quick Thermostat and ownership hand-back." + }, + { + "name": "Compressor protection", + "description": "Pending compressor-protection queue inspection/cancellation actions." + }, + { + "name": "Groups", + "description": "Scoped multi-zone thermostat groups." + }, + { + "name": "House", + "description": "Whole-house thermostat mode, profile and bulk power actions." + }, + { + "name": "Schedules", + "description": "Native weekly zone schedules and built-in templates." + }, + { + "name": "Automations", + "description": "Legacy temperature/time automations and Flow-generated runtime records." + }, + { + "name": "Flows", + "description": "Visual source graphs, atomic compilation, portability, dry-run and diagnostics." + }, + { + "name": "History", + "description": "Readings, chart history and resolved control plan." + }, + { + "name": "Events", + "description": "Event log and retention." + }, + { + "name": "Settings", + "description": "Runtime settings and full configuration backup/restore." + }, + { + "name": "Diagnostics", + "description": "Debug settings and integration connection tests." + }, + { + "name": "Access tokens", + "description": "Generated restricted tokens for Home Assistant." + }, + { + "name": "Home Assistant", + "description": "Always-authenticated restricted integration control surface." + }, + { + "name": "WebSocket", + "description": "Live event stream." + }, + { + "name": "Catalogs", + "description": "Public embedded language and Flow-preset catalogs." + } + ], + "paths": { + "/api/health": { + "get": { + "tags": [ + "System" + ], + "summary": "Health check", + "description": "Public lightweight process and control-engine health check. `control_ready=false` means the process is alive but initial physical device synchronization has not completed.", + "operationId": "health", + "security": [], + "responses": { + "200": { + "description": "Controller health", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + } + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/bootstrap": { + "get": { + "tags": [ + "System" + ], + "summary": "Get application bootstrap", + "description": "Returns the complete initial Web UI snapshot: devices, zones, groups, schedules, automations, access-token metadata, public-safe settings, outdoor temperature and system diagnostics.", + "operationId": "bootstrap", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Initial application snapshot", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/system/info": { + "get": { + "tags": [ + "System" + ], + "summary": "Get system diagnostics", + "description": "Returns the same system diagnostic object exposed inside bootstrap, including version, uptime, authentication mode, database/bind information and GREE frame counters.", + "operationId": "systemInfo", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "System diagnostics", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/discovery": { + "post": { + "tags": [ + "Devices" + ], + "summary": "Discover GREE devices", + "description": "Broadcasts GREE discovery, merges known devices, attempts binding for devices without a key, persists results and reports newly discovered controller IDs.", + "operationId": "discoverDevices", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiscoveryRequest" + }, + "example": { + "timeout_ms": 6000, + "broadcast": "255.255.255.255:7000", + "protocol_version": 0, + "passes": 3 + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Discovery result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiscoveryResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/devices": { + "get": { + "tags": [ + "Devices" + ], + "summary": "List devices", + "description": "Lists all known physical and simulated GREE devices with their latest persisted/runtime state.", + "operationId": "listDevices", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Device list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Device" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "tags": [ + "Devices" + ], + "summary": "Add device manually", + "description": "Registers a device without broadcast discovery. MAC addresses are normalized and duplicate MACs are rejected.", + "operationId": "addDevice", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualDeviceRequest" + }, + "example": { + "name": "Living room", + "mac": "AABBCCDDEEFF", + "ip": "192.168.50.30", + "port": 7000, + "protocol_version": 1, + "key": null, + "simulated": false + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "201": { + "description": "Created device", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Device" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/devices/{id}": { + "get": { + "tags": [ + "Devices" + ], + "summary": "Get device", + "description": "Returns one device by controller ID.", + "operationId": "getDevice", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Device ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Device", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Device" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "tags": [ + "Devices" + ], + "summary": "Patch device configuration", + "description": "Updates technical device fields. Changing protocol version clears the binding key/capability cache. Disabling a device follows the controller's safe-disable path.", + "operationId": "patchDevice", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Device ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DevicePatch" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated device", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Device" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "tags": [ + "Devices" + ], + "summary": "Delete device", + "description": "Deletes a device only after thermostat/automation safety checks. Referenced or unsafely owned devices are rejected.", + "operationId": "deleteDevice", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Device ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "204": { + "description": "Device deleted" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/devices/{id}/bind": { + "post": { + "tags": [ + "Devices" + ], + "summary": "Bind or re-bind device", + "description": "Performs GREE binding and returns the updated device. Simulated devices are returned unchanged.", + "operationId": "bindDevice", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Device ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Bound device", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Device" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/devices/{id}/poll": { + "post": { + "tags": [ + "Devices" + ], + "summary": "Poll device now", + "description": "Immediately polls a unit and persists/returns its refreshed state.", + "operationId": "pollDevice", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Device ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated device", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Device" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/devices/{id}/probe": { + "post": { + "tags": [ + "Devices" + ], + "summary": "Probe device latency", + "description": "Performs a minimal non-mutating GREE status round-trip. It does not update online/error counters, readings, capabilities, thermostat ownership or persisted state.", + "operationId": "probeDevice", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Device ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Probe result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "device_id": { + "type": "string" + }, + "response_time_ms": { + "type": "integer", + "minimum": 0 + }, + "ok": { + "type": "boolean" + } + }, + "required": [ + "device_id", + "response_time_ms", + "ok" + ], + "additionalProperties": false + }, + "example": { + "device_id": "gree-aabbccddeeff", + "response_time_ms": 18, + "ok": true + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/devices/{id}/command": { + "post": { + "tags": [ + "Devices" + ], + "summary": "Send direct device command", + "description": "Administrator manual/direct control. Only changed fields are sent. Climate-relevant direct commands may take thermostat ownership so automation does not immediately fight the user. A disabled thermostat zone requires explicit `manual_override=true`.", + "operationId": "commandDevice", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Device ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualDeviceCommand" + }, + "example": { + "power": true, + "mode": "cool", + "target_temperature": 22, + "fan_speed": 3, + "manual_override": false + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated device", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Device" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/zones": { + "get": { + "tags": [ + "Zones" + ], + "summary": "List thermostat zones", + "description": "Returns all thermostat zones including live sensor, demand, ownership, lockout and override state.", + "operationId": "listZones", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Zone list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Zone" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "tags": [ + "Zones" + ], + "summary": "Create thermostat zone", + "description": "Creates a thermostat zone after validating temperature ranges, sensor source and one-zone-per-device ownership.", + "operationId": "createZone", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ZoneInput" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "201": { + "description": "Created zone", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Zone" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/zones/{id}": { + "get": { + "tags": [ + "Zones" + ], + "summary": "Get thermostat zone", + "description": "Returns one thermostat zone.", + "operationId": "getZone", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Zone ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Zone", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Zone" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Zones" + ], + "summary": "Replace zone configuration", + "description": "Replaces editable zone configuration while preserving and reconciling runtime safety/ownership state. If `revision` is supplied, stale updates can return 409.", + "operationId": "updateZone", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Zone ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ZoneInput" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated zone", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Zone" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "tags": [ + "Zones" + ], + "summary": "Delete thermostat zone", + "description": "Safely powers the owned device off before detaching thermostat ownership, then removes related group membership when safe.", + "operationId": "deleteZone", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Zone ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "204": { + "description": "Zone deleted" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/zones/{id}/control": { + "post": { + "tags": [ + "Zones" + ], + "summary": "Control thermostat zone", + "description": "Quick Thermostat API for setpoint/preset/mode/power and ownership hand-back. It can also start or cancel persisted Temporary Quick Thermostat sessions. Manual setpoints retain 0.1 C precision; the physical GREE setpoint is still rounded to protocol resolution.", + "operationId": "controlZone", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Zone ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ZoneControlPatch" + }, + "example": { + "setpoint": 22.5, + "power": true, + "mode": "house", + "preset": "comfort", + "clear_override": false + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated zone", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Zone" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/zones/{id}/schedule-template": { + "post": { + "tags": [ + "Schedules" + ], + "summary": "Apply built-in schedule template", + "description": "Replaces schedules for one zone with a built-in family/child/bedroom/workday/always template after overlap validation.", + "operationId": "applyScheduleTemplate", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Zone ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleTemplateRequest" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Zone and generated schedules", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "zone": { + "$ref": "#/components/schemas/Zone" + }, + "schedules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Schedule" + } + } + }, + "required": [ + "zone", + "schedules" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/zones/{id}/compressor-queue/cancel": { + "post": { + "tags": [ + "Compressor protection" + ], + "summary": "Cancel zone compressor queue", + "description": "Cancels the currently pending compressor-protection task for one zone. The same intent stays suppressed until a new explicit control request or changed target/mode re-arms it.", + "operationId": "cancelZoneCompressorQueue", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Zone ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Cancellation result", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/compressor-queue/cancel-all": { + "post": { + "tags": [ + "Compressor protection" + ], + "summary": "Cancel all compressor queues", + "description": "Cancels every currently pending compressor-protection task. Safety OFF commands are never delayed by compressor protection.", + "operationId": "cancelAllCompressorQueues", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Cancellation summary", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/groups": { + "get": { + "tags": [ + "Groups" + ], + "summary": "List climate groups", + "description": "Returns configured climate groups.", + "operationId": "listGroups", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Group list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ClimateGroup" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "tags": [ + "Groups" + ], + "summary": "Create climate group", + "description": "Creates a group containing one or more existing zones. Duplicate IDs are normalized away.", + "operationId": "createGroup", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupInput" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "201": { + "description": "Created group", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClimateGroup" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/groups/{id}": { + "get": { + "tags": [ + "Groups" + ], + "summary": "Get climate group", + "description": "Returns one climate group.", + "operationId": "getGroup", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Group ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Group", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClimateGroup" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Groups" + ], + "summary": "Replace climate group", + "description": "Replaces group name/member zones/control state after validating references.", + "operationId": "updateGroup", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Group ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupInput" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated group", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClimateGroup" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "tags": [ + "Groups" + ], + "summary": "Delete climate group", + "description": "Deletes a climate group and releases scoped group ownership. Returns 204 on success.", + "operationId": "deleteGroup", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Group ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "204": { + "description": "Group deleted" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/groups/{id}/control": { + "post": { + "tags": [ + "Groups" + ], + "summary": "Control climate group", + "description": "Applies scoped group power/mode/preset/custom setpoint to member zones. Group OFF stores members as individually-off thermostats rather than creating a persistent global gate.", + "operationId": "controlGroup", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Group ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupControlPatch" + }, + "example": { + "power": true, + "mode": "house", + "preset": "custom", + "setpoint": 22.3 + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Group control result", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/house/control": { + "post": { + "tags": [ + "House" + ], + "summary": "Set house thermostat mode", + "description": "Sets the house rule (`cool`, `heat`, `off`) used by free zones that inherit global mode. Explicit local/group/direct ownership is preserved.", + "operationId": "controlHouse", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HouseControlPatch" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Public runtime settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeSettings" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/house/power": { + "post": { + "tags": [ + "House" + ], + "summary": "Bulk house power", + "description": "One-shot whole-house thermostat ON/OFF. OFF stores every thermostat as indefinite local OFF before sending device OFF so normal demand cannot immediately restart it. ON releases those local OFF markers and sends ON.", + "operationId": "powerHouse", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HousePowerPatch" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Bulk power result", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/house/preset": { + "post": { + "tags": [ + "House" + ], + "summary": "Apply house preset", + "description": "Applies `auto`, `comfort`, `sleep` or `away` to eligible free house-controlled zones. Scoped local/group/direct ownership is not overwritten.", + "operationId": "presetHouse", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HousePresetPatch" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "House preset result", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/schedules": { + "get": { + "tags": [ + "Schedules" + ], + "summary": "List schedules", + "description": "Lists legacy/native thermostat schedules, including Flow ownership metadata when generated from a visual Flow.", + "operationId": "listSchedules", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Schedule list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Schedule" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "tags": [ + "Schedules" + ], + "summary": "Create schedule", + "description": "Creates a weekly schedule. Times are local HH:MM, midnight crossing is supported and enabled schedules for the same zone may not overlap.", + "operationId": "createSchedule", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleInput" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "201": { + "description": "Created schedule", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Schedule" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/schedules/{id}": { + "get": { + "tags": [ + "Schedules" + ], + "summary": "Get schedule", + "description": "Returns one schedule.", + "operationId": "getSchedule", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Schedule ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Schedule", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Schedule" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Schedules" + ], + "summary": "Replace schedule", + "description": "Replaces a legacy/native schedule after validation. Flow-generated schedules are read-only here; edit the owning Flow.", + "operationId": "updateSchedule", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Schedule ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleInput" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated schedule", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Schedule" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "tags": [ + "Schedules" + ], + "summary": "Delete schedule", + "description": "Deletes a legacy/native schedule. Flow-generated schedules must be changed by editing/deleting the Flow.", + "operationId": "deleteSchedule", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Schedule ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "204": { + "description": "Schedule deleted" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/automations": { + "get": { + "tags": [ + "Automations" + ], + "summary": "List automations", + "description": "Lists legacy automations and Flow-generated automations with source ownership metadata.", + "operationId": "listAutomations", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Automation list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Automation" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "tags": [ + "Automations" + ], + "summary": "Create automation", + "description": "Creates a temperature/time automation targeting either a direct device or a climate group.", + "operationId": "createAutomation", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationInput" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "201": { + "description": "Created automation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Automation" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/automations/{id}": { + "get": { + "tags": [ + "Automations" + ], + "summary": "Get automation", + "description": "Returns one automation.", + "operationId": "getAutomation", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Automation ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Automation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Automation" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Automations" + ], + "summary": "Replace automation", + "description": "Replaces a legacy automation. Flow-generated automations are read-only here; edit the owning Flow.", + "operationId": "updateAutomation", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Automation ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutomationInput" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated automation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Automation" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "tags": [ + "Automations" + ], + "summary": "Delete automation", + "description": "Deletes a legacy automation. Flow-generated automations must be changed by editing/deleting the Flow.", + "operationId": "deleteAutomation", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Automation ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "204": { + "description": "Automation deleted" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/flows": { + "get": { + "tags": [ + "Flows" + ], + "summary": "List visual Flows", + "description": "Lists visual Flow source graphs and compiled-output metadata.", + "operationId": "listFlows", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Flow list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Flow" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "tags": [ + "Flows" + ], + "summary": "Create visual Flow", + "description": "Creates a Flow source graph. Executable Flows validate the DAG and atomically compile native schedules/automations; draft Flows are forced disabled and compile no outputs.", + "operationId": "createFlow", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlowInput" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "201": { + "description": "Created Flow", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Flow" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/flows/{id}": { + "get": { + "tags": [ + "Flows" + ], + "summary": "Get visual Flow", + "description": "Returns one Flow source graph plus compiled-output metadata.", + "operationId": "getFlow", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Flow ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Flow", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Flow" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Flows" + ], + "summary": "Update visual Flow", + "description": "Updates a Flow atomically. `expected_revision` is required; a mismatch returns 409 so stale editor tabs cannot overwrite newer graphs.", + "operationId": "updateFlow", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Flow ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlowInput" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated Flow", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Flow" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "tags": [ + "Flows" + ], + "summary": "Delete visual Flow", + "description": "Deletes the Flow and its generated schedules/automations atomically.", + "operationId": "deleteFlow", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Flow ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "204": { + "description": "Flow deleted" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/flows/{id}/export": { + "get": { + "tags": [ + "Flows" + ], + "summary": "Export Flow source", + "description": "Exports a versioned `gree-controller-flow` document containing only the source graph, never generated schedules/automations.", + "operationId": "exportFlow", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Flow ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Portable Flow document", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlowExport" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/flows/import": { + "post": { + "tags": [ + "Flows" + ], + "summary": "Import Flow source", + "description": "Imports either a `gree-controller-flow` envelope or a direct Flow source payload. A new Flow ID/revision is created; executable imports are validated and recompiled, drafts remain disabled.", + "operationId": "importFlow", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/FlowExport" + }, + { + "$ref": "#/components/schemas/FlowInput" + } + ] + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "201": { + "description": "Imported Flow", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Flow" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/flows/simulate": { + "post": { + "tags": [ + "Flows" + ], + "summary": "Dry-run Flow", + "description": "Validates, compiles and evaluates a Flow at an optional RFC3339 time with per-node overrides. Returns trace, match status, `would_execute` and `blocked_reason`. Never mutates thermostat/device/group/schedule/automation state.", + "operationId": "simulateFlow", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlowSimulationInput" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Dry-run result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlowSimulationResult" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/flows/{id}/logs": { + "get": { + "tags": [ + "Flows" + ], + "summary": "Get Flow-related events", + "description": "Returns recent events associated with the Flow or its generated automations. Limit defaults to 100 and is clamped to 1..250.", + "operationId": "flowLogs", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Flow ID", + "schema": { + "type": "string" + }, + "example": "uuid" + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum returned events", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 250 + }, + "example": 100 + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Flow event list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventLog" + } + } + }, + "required": [ + "events" + ], + "additionalProperties": false + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/readings": { + "get": { + "tags": [ + "History" + ], + "summary": "Get device readings", + "description": "Legacy/lightweight device history. `hours` defaults to 24 and is clamped to 10 years; `limit` defaults to 1500.", + "operationId": "readings", + "parameters": [ + { + "name": "device_id", + "in": "query", + "required": false, + "description": "Optional device filter", + "schema": { + "type": "string" + } + }, + { + "name": "hours", + "in": "query", + "required": false, + "description": "Lookback window in hours", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 87600 + }, + "example": 24 + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Row limit", + "schema": { + "type": "integer", + "minimum": 1 + }, + "example": 1500 + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Device readings", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "readings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Reading" + } + } + }, + "required": [ + "readings" + ], + "additionalProperties": false + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/history": { + "get": { + "tags": [ + "History" + ], + "summary": "Get rich history", + "description": "Chart/history API for overview, zones, devices or Home Assistant sensors. Rows are bucketed by lookback window. When InfluxDB is enabled, older data may be merged with SQLite; an Influx failure falls back to available SQLite and may return `storage_warning`.", + "operationId": "history", + "parameters": [ + { + "name": "scope", + "in": "query", + "required": false, + "description": "History family; defaults to zones", + "schema": { + "type": "string", + "enum": [ + "overview", + "zones", + "zone", + "devices", + "sensors" + ] + }, + "example": "zones" + }, + { + "name": "zone_id", + "in": "query", + "required": false, + "description": "Zone filter", + "schema": { + "type": "string" + } + }, + { + "name": "device_id", + "in": "query", + "required": false, + "description": "Device filter", + "schema": { + "type": "string" + } + }, + { + "name": "entity_id", + "in": "query", + "required": false, + "description": "Home Assistant entity filter", + "schema": { + "type": "string" + } + }, + { + "name": "hours", + "in": "query", + "required": false, + "description": "Lookback window", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 87600 + }, + "example": 24 + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum rows", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 20000 + }, + "example": 12000 + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "History payload", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/control-plan": { + "get": { + "tags": [ + "History" + ], + "summary": "Get resolved control plan", + "description": "Machine-readable desired-vs-actual thermostat plan with ownership, blocking reasons, schedules, next events and per-zone control decisions. This is the preferred endpoint for clients that need to explain why a zone is or is not running.", + "operationId": "controlPlan", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Resolved control plan", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/events": { + "get": { + "tags": [ + "Events" + ], + "summary": "List event log", + "description": "Returns recent controller event rows. Limit defaults to 100.", + "operationId": "events", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum events", + "schema": { + "type": "integer", + "minimum": 1 + }, + "example": 100 + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Event list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventLog" + } + } + }, + "required": [ + "events" + ], + "additionalProperties": false + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/events/retention": { + "get": { + "tags": [ + "Events" + ], + "summary": "Get event retention", + "description": "Returns event-log retention in days.", + "operationId": "getEventRetention", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Retention", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventRetention" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Events" + ], + "summary": "Update event retention", + "description": "Updates retention, clamps it to 1..3650 days and prunes old rows immediately. The response also reports removed rows.", + "operationId": "updateEventRetention", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventRetention" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated retention/prune result", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/settings": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get runtime settings", + "description": "Returns the public-safe runtime settings document. Integration/provider secrets are blanked and represented by `*_configured` flags where relevant.", + "operationId": "getSettings", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Public runtime settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeSettings" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "summary": "Update runtime settings", + "description": "Accepts the complete RuntimeSettings document. Secret fields may be left blank to preserve saved secrets. House mode/power are controlled by dedicated House endpoints; intervals, retention and safety values are validated/clamped.", + "operationId": "updateSettings", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeSettings" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Public runtime settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeSettings" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/settings/export": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Export configuration", + "description": "Exports versioned controller configuration including binding keys and integration credentials. Metric history, event rows and generated access-token records are excluded. Treat the response as a credential/secret backup.", + "operationId": "exportSettings", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Configuration backup", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurationExport" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/settings/import": { + "post": { + "tags": [ + "Settings" + ], + "summary": "Import configuration", + "description": "Validates and atomically replaces controller configuration while preserving metric/event history and generated access-token records. Devices losing thermostat ownership are stopped safely; transient ownership/live state is reconciled before control resumes.", + "operationId": "importSettings", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurationExport" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Import result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "example": true + } + }, + "required": [ + "ok" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/debug": { + "get": { + "tags": [ + "Diagnostics" + ], + "summary": "Get debug settings", + "description": "Returns live API/GREE diagnostics settings.", + "operationId": "getDebug", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Debug settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DebugSettings" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Diagnostics" + ], + "summary": "Update debug settings", + "description": "Persists debug settings. `overlay_enabled` emits `api.request` WebSocket events; `gree_frames` emits sanitized GREE protocol debug events.", + "operationId": "updateDebug", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DebugSettings" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Debug settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DebugSettings" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/integrations/home-assistant/test": { + "post": { + "tags": [ + "Diagnostics" + ], + "summary": "Test Home Assistant temperature", + "description": "Tests the configured Home Assistant connection and resolves a temperature from the supplied entity or controller default/alias.", + "operationId": "testHomeAssistant", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HaTestRequest" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Test result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "temperature_c": { + "type": [ + "number", + "null" + ] + }, + "entity_id": { + "type": "string" + } + }, + "required": [ + "ok", + "entity_id" + ], + "additionalProperties": false + }, + "example": { + "ok": true, + "temperature_c": 23.4, + "entity_id": "sensor.room_temperature" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/integrations/home-assistant/entity": { + "post": { + "tags": [ + "Diagnostics" + ], + "summary": "Inspect Home Assistant entity", + "description": "Reads the raw current Home Assistant entity document used by Flow shared-input diagnostics, including availability, attributes and timestamps.", + "operationId": "inspectHomeAssistantEntity", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HaEntityRequest" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Entity state", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + }, + "example": { + "ok": true, + "entity_id": "climate.gas_boiler", + "state": "heat", + "available": true, + "attributes": { + "hvac_action": "heating" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/integrations/notifications/test": { + "post": { + "tags": [ + "Diagnostics" + ], + "summary": "Send notification test", + "description": "Tests the selected notification provider. Blank secret/webhook fields reuse saved secrets instead of clearing them.", + "operationId": "testNotifications", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationSettings" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Notification test result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "example": true + } + }, + "required": [ + "ok" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/access-tokens": { + "get": { + "tags": [ + "Access tokens" + ], + "summary": "List restricted access tokens", + "description": "Lists metadata for generated restricted Home Assistant tokens. Full token secrets are never returned after creation.", + "operationId": "listAccessTokens", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Token metadata", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiTokenInfo" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "tags": [ + "Access tokens" + ], + "summary": "Create restricted access token", + "description": "Creates a restricted Home Assistant API token. The full secret is returned once; only its SHA-256 hash and display prefix are stored.", + "operationId": "createAccessToken", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAccessTokenRequest" + }, + "example": { + "name": "Home Assistant" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "201": { + "description": "Created token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAccessTokenResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/access-tokens/{id}": { + "delete": { + "tags": [ + "Access tokens" + ], + "summary": "Revoke restricted access token", + "description": "Revokes a generated restricted token by ID.", + "operationId": "deleteAccessToken", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Access token ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "204": { + "description": "Token revoked" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/integrations/home-assistant/devices": { + "get": { + "tags": [ + "Home Assistant" + ], + "summary": "List devices for Home Assistant", + "description": "This restricted Home Assistant surface always requires either a generated controller access token or the administrator app token. Generated tokens are intentionally not accepted by normal administrator endpoints. Returns Device[].", + "operationId": "haListDevices", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Device list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Device" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/integrations/home-assistant/devices/{id}/command": { + "post": { + "tags": [ + "Home Assistant" + ], + "summary": "Control device from Home Assistant", + "description": "This restricted Home Assistant surface always requires either a generated controller access token or the administrator app token. Generated tokens are intentionally not accepted by normal administrator endpoints. Accepts DeviceCommand. Direct control is rejected when the device belongs to a disabled thermostat zone; the restricted surface cannot bypass that safety rule.", + "operationId": "haCommandDevice", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Device ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceCommand" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated device", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Device" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/integrations/home-assistant/control-plan": { + "get": { + "tags": [ + "Home Assistant" + ], + "summary": "Get control plan for Home Assistant", + "description": "This restricted Home Assistant surface always requires either a generated controller access token or the administrator app token. Generated tokens are intentionally not accepted by normal administrator endpoints. Returns the same machine-readable control plan as the administrator endpoint.", + "operationId": "haControlPlan", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Resolved control plan", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/integrations/home-assistant/groups": { + "get": { + "tags": [ + "Home Assistant" + ], + "summary": "List derived climate groups", + "description": "This restricted Home Assistant surface always requires either a generated controller access token or the administrator app token. Generated tokens are intentionally not accepted by normal administrator endpoints. Returns HA-oriented group objects with effective power/mode/preset, temperatures, member detail and next events.", + "operationId": "haListGroups", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Derived group list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/integrations/home-assistant/groups/{id}/control": { + "post": { + "tags": [ + "Home Assistant" + ], + "summary": "Control climate group from Home Assistant", + "description": "This restricted Home Assistant surface always requires either a generated controller access token or the administrator app token. Generated tokens are intentionally not accepted by normal administrator endpoints. Uses the same scoped group thermostat semantics as the administrator group-control endpoint.", + "operationId": "haControlGroup", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Group ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupControlPatch" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Group control result", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/integrations/home-assistant/house/control": { + "post": { + "tags": [ + "Home Assistant" + ], + "summary": "Set house mode from Home Assistant", + "description": "This restricted Home Assistant surface always requires either a generated controller access token or the administrator app token. Generated tokens are intentionally not accepted by normal administrator endpoints. Uses the same cool/heat/off house-mode semantics as the administrator endpoint.", + "operationId": "haControlHouse", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HouseControlPatch" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Public runtime settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeSettings" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/integrations/home-assistant/house/preset": { + "post": { + "tags": [ + "Home Assistant" + ], + "summary": "Set house preset from Home Assistant", + "description": "This restricted Home Assistant surface always requires either a generated controller access token or the administrator app token. Generated tokens are intentionally not accepted by normal administrator endpoints. Uses the same auto/comfort/sleep/away semantics as the administrator endpoint.", + "operationId": "haPresetHouse", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HousePresetPatch" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "House preset result", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/integrations/home-assistant/house/power": { + "post": { + "tags": [ + "Home Assistant" + ], + "summary": "Bulk house power from Home Assistant", + "description": "This restricted Home Assistant surface always requires either a generated controller access token or the administrator app token. Generated tokens are intentionally not accepted by normal administrator endpoints. Uses the same one-shot whole-house thermostat ON/OFF semantics as the administrator endpoint.", + "operationId": "haPowerHouse", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HousePowerPatch" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Bulk power result", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/integrations/home-assistant/zones/{id}/control": { + "post": { + "tags": [ + "Home Assistant" + ], + "summary": "Control thermostat zone from Home Assistant", + "description": "This restricted Home Assistant surface always requires either a generated controller access token or the administrator app token. Generated tokens are intentionally not accepted by normal administrator endpoints. Uses the same ZoneControlPatch semantics as the administrator Quick Thermostat endpoint and records Home Assistant as the control source.", + "operationId": "haControlZone", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Zone ID", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ZoneControlPatch" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Updated zone", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Zone" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/ws": { + "get": { + "tags": [ + "WebSocket" + ], + "summary": "Open live event stream", + "description": "Upgrades to WebSocket. The first server frame is `bootstrap`; later frames use `{event,timestamp,data}`. When an administrator app token is configured, pass it as the `token` query parameter. Generated restricted Home Assistant tokens are not accepted here.", + "operationId": "websocket", + "parameters": [ + { + "name": "token", + "in": "query", + "required": false, + "description": "Administrator app token when authentication is enabled", + "schema": { + "type": "string" + } + } + ], + "security": [ + { + "WebSocketQueryToken": [] + } + ], + "responses": { + "101": { + "description": "Switching Protocols" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/lang/index.json": { + "get": { + "tags": [ + "Catalogs" + ], + "summary": "List embedded language packs", + "description": "Public catalog used by the Web UI before authentication.", + "operationId": "languageIndex", + "security": [], + "responses": { + "200": { + "description": "Language catalog", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/lang/{file}": { + "get": { + "tags": [ + "Catalogs" + ], + "summary": "Get embedded language pack", + "description": "Returns one embedded JSON language pack such as `en.json` or `pl.json`.", + "operationId": "languageFile", + "parameters": [ + { + "name": "file", + "in": "path", + "required": true, + "description": "Language file name, e.g. en.json", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [], + "responses": { + "200": { + "description": "Language pack", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "404": { + "description": "Language pack not found" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/presets/index.json": { + "get": { + "tags": [ + "Catalogs" + ], + "summary": "List Flow presets", + "description": "Public catalog of embedded visual Flow templates/presets.", + "operationId": "presetIndex", + "security": [], + "responses": { + "200": { + "description": "Preset catalog", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/presets/{file}": { + "get": { + "tags": [ + "Catalogs" + ], + "summary": "Get Flow preset", + "description": "Returns one embedded Flow preset JSON document.", + "operationId": "presetFile", + "parameters": [ + { + "name": "file", + "in": "path", + "required": true, + "description": "Preset file name", + "schema": { + "type": "string" + }, + "example": "uuid" + } + ], + "security": [], + "responses": { + "200": { + "description": "Flow preset", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "404": { + "description": "Preset not found" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "BearerToken": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "token", + "description": "Administrator app token on normal API routes. On restricted Home Assistant routes, either the administrator token or a generated restricted token is accepted." + }, + "ApiTokenHeader": { + "type": "apiKey", + "in": "header", + "name": "x-api-token", + "description": "Alternative to the Bearer header. Token permissions are identical to the same token sent as Bearer." + }, + "WebSocketQueryToken": { + "type": "apiKey", + "in": "query", + "name": "token", + "description": "Administrator app token for `/ws` when app authentication is enabled. Restricted Home Assistant tokens are not accepted." + } + }, + "schemas": { + "Error": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message", + "example": "invalid request: unsupported mode" + } + }, + "required": [ + "error" + ], + "additionalProperties": false + }, + "Health": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Process health", + "enum": [ + "ok" + ], + "example": "ok" + }, + "name": { + "type": "string", + "example": "gree-controller" + }, + "version": { + "type": "string", + "example": "0.11.6" + }, + "uptime_seconds": { + "type": "integer", + "minimum": 0, + "example": 1234 + }, + "control_ready": { + "type": "boolean", + "description": "True after initial physical device synchronization", + "example": true + }, + "time": { + "type": "string", + "example": "2026-09-03T09:30:00Z", + "format": "date-time" + } + }, + "required": [ + "status", + "name", + "version", + "uptime_seconds", + "control_ready", + "time" + ], + "additionalProperties": false + }, + "DeviceCommand": { + "type": "object", + "description": "Sparse direct-device command. Only supplied fields are changed.", + "properties": { + "power": { + "type": [ + "boolean", + "null" + ], + "description": "Power state" + }, + "mode": { + "type": [ + "string", + "null" + ], + "description": "GREE mode", + "enum": [ + "auto", + "cool", + "dry", + "fan", + "heat", + null + ] + }, + "target_temperature": { + "type": [ + "number", + "null" + ], + "description": "Target temperature in Celsius; normalized to 8..30", + "minimum": 8, + "maximum": 30 + }, + "fan_speed": { + "type": [ + "integer", + "null" + ], + "description": "0=Auto, 1..5 fixed speed", + "minimum": 0, + "maximum": 5 + }, + "swing_vertical": { + "type": [ + "boolean", + "null" + ] + }, + "swing_horizontal": { + "type": [ + "boolean", + "null" + ] + }, + "quiet": { + "type": [ + "boolean", + "null" + ] + }, + "turbo": { + "type": [ + "boolean", + "null" + ] + }, + "light": { + "type": [ + "boolean", + "null" + ] + }, + "air": { + "type": [ + "boolean", + "null" + ] + }, + "xfan": { + "type": [ + "boolean", + "null" + ] + }, + "health": { + "type": [ + "boolean", + "null" + ] + }, + "sleep": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "ManualDeviceCommand": { + "type": "object", + "description": "Administrator direct-device command. `manual_override` is a safety acknowledgement for disabled thermostat zones.", + "properties": { + "power": { + "type": [ + "boolean", + "null" + ], + "description": "Power state" + }, + "mode": { + "type": [ + "string", + "null" + ], + "description": "GREE mode", + "enum": [ + "auto", + "cool", + "dry", + "fan", + "heat", + null + ] + }, + "target_temperature": { + "type": [ + "number", + "null" + ], + "description": "Target temperature in Celsius; normalized to 8..30", + "minimum": 8, + "maximum": 30 + }, + "fan_speed": { + "type": [ + "integer", + "null" + ], + "description": "0=Auto, 1..5 fixed speed", + "minimum": 0, + "maximum": 5 + }, + "swing_vertical": { + "type": [ + "boolean", + "null" + ] + }, + "swing_horizontal": { + "type": [ + "boolean", + "null" + ] + }, + "quiet": { + "type": [ + "boolean", + "null" + ] + }, + "turbo": { + "type": [ + "boolean", + "null" + ] + }, + "light": { + "type": [ + "boolean", + "null" + ] + }, + "air": { + "type": [ + "boolean", + "null" + ] + }, + "xfan": { + "type": [ + "boolean", + "null" + ] + }, + "health": { + "type": [ + "boolean", + "null" + ] + }, + "sleep": { + "type": [ + "boolean", + "null" + ] + }, + "manual_override": { + "type": "boolean", + "description": "Required as true for deliberate direct control of a device owned by a disabled thermostat zone.", + "example": false + } + } + }, + "DevicePatch": { + "type": "object", + "description": "Editable technical device fields.", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "ip": { + "type": [ + "string", + "null" + ], + "example": "192.168.50.31" + }, + "port": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 65535, + "example": 7000 + }, + "protocol_version": { + "type": [ + "integer", + "null" + ], + "description": "0=auto/unknown, 1=AES-ECB, 2=AES-GCM", + "minimum": 0, + "maximum": 2 + }, + "key": { + "type": [ + "string", + "null" + ], + "description": "Binding key. JSON null clears the saved key." + }, + "enabled": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "ManualDeviceRequest": { + "type": "object", + "description": "Manual device registration request. Port defaults to 7000, protocol to 1, simulated to false.", + "properties": { + "name": { + "type": "string", + "example": "Living room" + }, + "mac": { + "type": "string", + "example": "AABBCCDDEEFF" + }, + "ip": { + "type": "string", + "example": "192.168.50.30" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "example": 7000 + }, + "protocol_version": { + "type": "integer", + "description": "1=legacy AES-ECB, 2=AES-GCM", + "minimum": 1, + "maximum": 2, + "example": 1 + }, + "key": { + "type": [ + "string", + "null" + ] + }, + "simulated": { + "type": "boolean", + "example": false + } + }, + "required": [ + "name", + "mac", + "ip" + ] + }, + "Device": { + "type": "object", + "description": "Physical or simulated GREE unit plus latest communication/state snapshot.", + "properties": { + "id": { + "type": "string", + "description": "Stable controller ID", + "example": "gree-aabbccddeeff" + }, + "mac": { + "type": "string", + "example": "AABBCCDDEEFF" + }, + "name": { + "type": "string", + "example": "Living room" + }, + "ip": { + "type": "string", + "example": "192.168.50.30" + }, + "port": { + "type": "integer", + "example": 7000 + }, + "protocol_version": { + "type": "integer", + "minimum": 0, + "maximum": 2, + "example": 2 + }, + "model": { + "type": "string", + "example": "GREE" + }, + "firmware": { + "type": "string" + }, + "key": { + "type": [ + "string", + "null" + ], + "description": "GREE binding key; treat as secret" + }, + "cid": { + "type": [ + "string", + "null" + ] + }, + "enabled": { + "type": "boolean" + }, + "simulated": { + "type": "boolean" + }, + "power": { + "type": "boolean" + }, + "mode": { + "type": "string", + "enum": [ + "auto", + "cool", + "dry", + "fan", + "heat" + ] + }, + "target_temperature": { + "type": "number", + "minimum": 8, + "maximum": 30 + }, + "fan_speed": { + "type": "integer", + "minimum": 0, + "maximum": 5 + }, + "swing_vertical": { + "type": "boolean" + }, + "swing_horizontal": { + "type": "boolean" + }, + "quiet": { + "type": "boolean" + }, + "turbo": { + "type": "boolean" + }, + "light": { + "type": "boolean" + }, + "air": { + "type": "boolean" + }, + "xfan": { + "type": "boolean" + }, + "health": { + "type": "boolean" + }, + "sleep": { + "type": "boolean" + }, + "current_temperature": { + "type": [ + "number", + "null" + ] + }, + "outdoor_temperature": { + "type": [ + "number", + "null" + ] + }, + "online": { + "type": "boolean" + }, + "response_time_ms": { + "type": [ + "integer", + "null" + ] + }, + "last_seen": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "communication_failures": { + "type": "integer", + "minimum": 0 + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "mac", + "name", + "ip", + "port", + "enabled", + "power", + "mode", + "target_temperature", + "online", + "created_at", + "updated_at" + ] + }, + "DiscoveryRequest": { + "type": "object", + "description": "All fields optional. Missing values use runtime GREE settings.", + "properties": { + "timeout_ms": { + "type": "integer", + "description": "Effective range 500..30000 ms", + "minimum": 500, + "maximum": 30000, + "example": 6000 + }, + "broadcast": { + "type": "string", + "description": "IPv4 socket address; omitted values use runtime settings", + "example": "255.255.255.255:7000" + }, + "protocol_version": { + "type": "integer", + "description": "0=auto/both, 1=AES-ECB, 2=AES-GCM", + "minimum": 0, + "maximum": 2, + "example": 0 + }, + "passes": { + "type": "integer", + "description": "Discovery passes", + "minimum": 1, + "maximum": 10, + "example": 3 + } + } + }, + "DiscoveryResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 0, + "example": 1 + }, + "devices": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Device" + } + }, + "new_device_ids": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "gree-aabbccddeeff" + ] + } + }, + "required": [ + "count", + "devices", + "new_device_ids" + ], + "additionalProperties": false + }, + "TemporaryQuickThermostatRequest": { + "type": "object", + "description": "Persisted temporary thermostat session with delayed/absolute start and several finish rules.", + "properties": { + "start_kind": { + "type": "string", + "enum": [ + "now", + "delay", + "at" + ], + "example": "now" + }, + "start_delay_minutes": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "start_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "finish_kind": { + "type": "string", + "enum": [ + "duration", + "until", + "temperature_reached", + "temperature_stable", + "schedule_boundary" + ], + "example": "duration" + }, + "duration_minutes": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "example": 90 + }, + "until": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "target_temperature": { + "type": [ + "number", + "null" + ], + "minimum": 8, + "maximum": 30, + "example": 23.0 + }, + "temperature_operator": { + "type": [ + "string", + "null" + ], + "enum": [ + "within", + "at_or_below", + "at_or_above", + null + ], + "example": "within" + }, + "tolerance_c": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "example": 0.3 + }, + "hold_minutes": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "max_duration_minutes": { + "type": [ + "integer", + "null" + ], + "description": "Optional fail-safe for temperature-based sessions", + "minimum": 1 + } + }, + "required": [ + "start_kind", + "finish_kind" + ] + }, + "ZoneControlPatch": { + "type": "object", + "description": "Quick thermostat control. Fields may be combined; clear flags hand ownership back to automation at different layers.", + "properties": { + "setpoint": { + "type": [ + "number", + "null" + ], + "description": "Quick custom target; kept at 0.1 C precision", + "minimum": 8, + "maximum": 30, + "example": 22.5 + }, + "power": { + "type": [ + "boolean", + "null" + ], + "description": "Local Quick Thermostat power ownership" + }, + "mode": { + "type": [ + "string", + "null" + ], + "enum": [ + "house", + "cool", + "heat", + null + ] + }, + "enabled": { + "type": [ + "boolean", + "null" + ] + }, + "preset": { + "type": [ + "string", + "null" + ], + "enum": [ + "auto", + "comfort", + "sleep", + "away", + "custom", + null + ] + }, + "clear_override": { + "type": [ + "boolean", + "null" + ] + }, + "clear_device_manual_override": { + "type": [ + "boolean", + "null" + ] + }, + "clear_local_thermostat_override": { + "type": [ + "boolean", + "null" + ] + }, + "temporary_quick_thermostat": { + "oneOf": [ + { + "$ref": "#/components/schemas/TemporaryQuickThermostatRequest" + }, + { + "type": "null" + } + ] + }, + "clear_temporary_quick_thermostat": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "ZoneInput": { + "type": "object", + "description": "Editable thermostat-zone configuration. Runtime ownership/demand/lockout fields are returned separately on `Zone`.", + "properties": { + "name": { + "type": "string", + "example": "Living room" + }, + "device_id": { + "type": "string", + "example": "gree-aabbccddeeff" + }, + "enabled": { + "type": "boolean", + "example": true + }, + "mode": { + "type": "string", + "enum": [ + "cool", + "heat" + ], + "example": "cool" + }, + "inherit_house_mode": { + "type": "boolean", + "example": true + }, + "setpoint": { + "type": "number", + "minimum": 8, + "maximum": 30, + "example": 23.0 + }, + "cool_comfort_setpoint": { + "type": "number", + "minimum": 8, + "maximum": 30, + "example": 23.0 + }, + "cool_sleep_setpoint": { + "type": "number", + "minimum": 8, + "maximum": 30, + "example": 24.5 + }, + "cool_away_setpoint": { + "type": "number", + "minimum": 8, + "maximum": 30, + "example": 27.0 + }, + "heat_comfort_setpoint": { + "type": "number", + "minimum": 8, + "maximum": 30, + "example": 21.0 + }, + "heat_sleep_setpoint": { + "type": "number", + "minimum": 8, + "maximum": 30, + "example": 19.0 + }, + "heat_away_setpoint": { + "type": "number", + "minimum": 8, + "maximum": 30, + "example": 17.0 + }, + "hysteresis": { + "type": "number", + "minimum": 0.1, + "maximum": 5, + "example": 0.6 + }, + "separate_hysteresis": { + "type": "boolean", + "example": false + }, + "cool_hysteresis": { + "type": "number", + "minimum": 0.1, + "maximum": 5, + "example": 0.6 + }, + "heat_hysteresis": { + "type": "number", + "minimum": 0.1, + "maximum": 5, + "example": 0.6 + }, + "min_on_seconds": { + "type": "integer", + "minimum": 0, + "example": 180 + }, + "min_off_seconds": { + "type": "integer", + "minimum": 0, + "example": 180 + }, + "min_adjust_seconds": { + "type": "integer", + "minimum": 0, + "example": 120 + }, + "standby_offset_c": { + "type": "number", + "minimum": 0.5, + "maximum": 8, + "example": 2.0 + }, + "smart_fan": { + "type": "boolean", + "example": true + }, + "sensor_source": { + "type": "string", + "enum": [ + "device", + "home_assistant", + "combined" + ], + "example": "combined" + }, + "ha_entity_id": { + "type": [ + "string", + "null" + ], + "example": "sensor.living_room_temperature" + }, + "external_sensor_weight": { + "type": "number", + "minimum": 0, + "maximum": 1, + "example": 0.4 + }, + "max_sensor_difference": { + "type": "number", + "minimum": 0.1, + "maximum": 20, + "example": 3.0 + }, + "sensor_stale_after_seconds": { + "type": "integer", + "minimum": 1, + "example": 300 + }, + "revision": { + "type": [ + "integer", + "null" + ], + "description": "Optimistic concurrency revision when updating", + "minimum": 1, + "example": 12 + } + }, + "required": [ + "name", + "device_id" + ] + }, + "Zone": { + "type": "object", + "description": "Thermostat zone. In addition to editable configuration it exposes live sensors, resolved target, demand, ownership, temporary/manual overrides and compressor protection state.", + "properties": { + "id": { + "type": "string", + "example": "zone-1" + }, + "name": { + "type": "string", + "example": "Living room" + }, + "device_id": { + "type": "string", + "example": "gree-aabbccddeeff" + }, + "enabled": { + "type": "boolean" + }, + "mode": { + "type": "string", + "enum": [ + "cool", + "heat" + ] + }, + "inherit_house_mode": { + "type": "boolean" + }, + "setpoint": { + "type": "number" + }, + "active_preset": { + "type": "string", + "example": "comfort" + }, + "manual_preset": { + "type": [ + "string", + "null" + ] + }, + "manual_setpoint": { + "type": [ + "number", + "null" + ] + }, + "current_temperature": { + "type": [ + "number", + "null" + ] + }, + "device_temperature": { + "type": [ + "number", + "null" + ] + }, + "external_temperature": { + "type": [ + "number", + "null" + ] + }, + "effective_mode": { + "type": "string" + }, + "effective_setpoint": { + "type": [ + "number", + "null" + ] + }, + "device_setpoint": { + "type": [ + "number", + "null" + ] + }, + "demand": { + "type": "boolean" + }, + "local_thermostat_power": { + "type": [ + "boolean", + "null" + ] + }, + "local_thermostat_resume_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "device_manual_override": { + "type": "boolean" + }, + "device_manual_override_since": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "control_owner": { + "type": "string", + "example": "automation" + }, + "control_source": { + "type": "string" + }, + "control_reason": { + "type": "string" + }, + "control_resume_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "lockout_until": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "lockout_reason": { + "type": [ + "string", + "null" + ] + }, + "compressor_pending_action": { + "type": [ + "string", + "null" + ] + }, + "compressor_pending_since": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "compressor_pending_until": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "device_id", + "enabled", + "mode", + "revision", + "created_at", + "updated_at" + ] + }, + "GroupInput": { + "type": "object", + "description": "Climate group definition. Every zone ID must exist.", + "properties": { + "name": { + "type": "string", + "example": "Bedrooms" + }, + "zone_ids": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "example": [ + "zone-1", + "zone-2" + ] + }, + "power_enabled": { + "type": "boolean", + "description": "Initial group control state", + "example": true + } + }, + "required": [ + "name", + "zone_ids" + ] + }, + "ClimateGroup": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "uuid" + }, + "name": { + "type": "string", + "example": "Bedrooms" + }, + "zone_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "power_enabled": { + "type": "boolean", + "description": "Group bulk-power/control state; not a permanent device power gate." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "zone_ids", + "power_enabled", + "created_at", + "updated_at" + ], + "additionalProperties": false + }, + "GroupControlPatch": { + "type": "object", + "description": "Scoped group thermostat control. Group OFF stores members as individually-off thermostats; individual zones can later be re-enabled independently.", + "properties": { + "power": { + "type": [ + "boolean", + "null" + ] + }, + "mode": { + "type": [ + "string", + "null" + ], + "enum": [ + "house", + "cool", + "heat", + null + ] + }, + "preset": { + "type": [ + "string", + "null" + ], + "enum": [ + "auto", + "comfort", + "sleep", + "away", + "custom", + null + ] + }, + "setpoint": { + "type": [ + "number", + "null" + ], + "description": "Requires preset=custom", + "minimum": 8, + "maximum": 30, + "example": 22.3 + } + } + }, + "HouseControlPatch": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "cool", + "heat", + "off" + ], + "example": "cool" + } + }, + "required": [ + "mode" + ], + "additionalProperties": false + }, + "HousePowerPatch": { + "type": "object", + "properties": { + "power": { + "type": "boolean", + "example": false + } + }, + "required": [ + "power" + ], + "additionalProperties": false + }, + "HousePresetPatch": { + "type": "object", + "properties": { + "preset": { + "type": "string", + "enum": [ + "auto", + "comfort", + "sleep", + "away" + ], + "example": "sleep" + } + }, + "required": [ + "preset" + ], + "additionalProperties": false + }, + "ScheduleTemplateRequest": { + "type": "object", + "properties": { + "template": { + "type": "string", + "enum": [ + "family", + "child", + "bedroom", + "workday", + "always" + ], + "example": "family" + } + }, + "required": [ + "template" + ], + "additionalProperties": false + }, + "ScheduleInput": { + "type": "object", + "description": "Weekly thermostat schedule. Enabled schedules for the same zone cannot overlap.", + "properties": { + "zone_id": { + "type": "string", + "example": "zone-1" + }, + "name": { + "type": "string", + "example": "Night" + }, + "enabled": { + "type": "boolean", + "example": true + }, + "weekdays": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 7 + }, + "minItems": 1, + "example": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ] + }, + "start_time": { + "type": "string", + "description": "Local HH:MM", + "example": "22:30", + "pattern": "^[0-2][0-9]:[0-5][0-9]$" + }, + "end_time": { + "type": "string", + "description": "Local HH:MM; crossing midnight is supported", + "example": "06:30", + "pattern": "^[0-2][0-9]:[0-5][0-9]$" + }, + "preset": { + "type": "string", + "enum": [ + "comfort", + "sleep", + "away", + "custom" + ], + "example": "sleep" + }, + "setpoint": { + "type": "number", + "description": "Used directly only for custom preset", + "minimum": 8, + "maximum": 30, + "example": 24.5 + } + }, + "required": [ + "zone_id", + "name", + "weekdays", + "start_time", + "end_time", + "setpoint" + ] + }, + "Schedule": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "example": "zone-1" + }, + "name": { + "type": "string", + "example": "Night" + }, + "enabled": { + "type": "boolean", + "example": true + }, + "weekdays": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 7 + }, + "minItems": 1, + "example": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ] + }, + "start_time": { + "type": "string", + "description": "Local HH:MM", + "example": "22:30", + "pattern": "^[0-2][0-9]:[0-5][0-9]$" + }, + "end_time": { + "type": "string", + "description": "Local HH:MM; crossing midnight is supported", + "example": "06:30", + "pattern": "^[0-2][0-9]:[0-5][0-9]$" + }, + "preset": { + "type": "string", + "enum": [ + "comfort", + "sleep", + "away", + "custom" + ], + "example": "sleep" + }, + "setpoint": { + "type": "number", + "description": "Used directly only for custom preset", + "minimum": 8, + "maximum": 30, + "example": 24.5 + }, + "id": { + "type": "string", + "example": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "flow_id": { + "type": [ + "string", + "null" + ], + "description": "Present when generated by a visual Flow; then edit the Flow instead of the legacy schedule endpoint." + }, + "flow_node_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "zone_id", + "name", + "weekdays", + "start_time", + "end_time", + "preset", + "setpoint", + "created_at", + "updated_at" + ] + }, + "AutomationInput": { + "type": "object", + "description": "Legacy automation request. Action target is either a direct device or a group.", + "properties": { + "name": { + "type": "string", + "example": "Hot room" + }, + "enabled": { + "type": "boolean", + "example": true + }, + "trigger_kind": { + "type": "string", + "enum": [ + "temperature_above", + "temperature_below", + "time" + ], + "example": "temperature_above" + }, + "trigger_device_id": { + "type": [ + "string", + "null" + ], + "example": "gree-aabbccddeeff" + }, + "threshold": { + "type": [ + "number", + "null" + ], + "example": 27.0 + }, + "at_time": { + "type": [ + "string", + "null" + ], + "example": "20:30" + }, + "action_device_id": { + "type": "string", + "description": "Direct-device target; leave empty for group action", + "example": "gree-aabbccddeeff" + }, + "action_group_id": { + "type": [ + "string", + "null" + ] + }, + "action_preset": { + "type": [ + "string", + "null" + ], + "enum": [ + "auto", + "comfort", + "sleep", + "away", + null + ] + }, + "action": { + "$ref": "#/components/schemas/DeviceCommand" + }, + "cooldown_seconds": { + "type": "integer", + "minimum": 30, + "example": 300 + } + }, + "required": [ + "name", + "trigger_kind" + ] + }, + "Automation": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Hot room" + }, + "enabled": { + "type": "boolean", + "example": true + }, + "trigger_kind": { + "type": "string", + "enum": [ + "temperature_above", + "temperature_below", + "time" + ], + "example": "temperature_above" + }, + "trigger_device_id": { + "type": [ + "string", + "null" + ], + "example": "gree-aabbccddeeff" + }, + "threshold": { + "type": [ + "number", + "null" + ], + "example": 27.0 + }, + "at_time": { + "type": [ + "string", + "null" + ], + "example": "20:30" + }, + "action_device_id": { + "type": "string", + "description": "Direct-device target; leave empty for group action", + "example": "gree-aabbccddeeff" + }, + "action_group_id": { + "type": [ + "string", + "null" + ] + }, + "action_preset": { + "type": [ + "string", + "null" + ], + "enum": [ + "auto", + "comfort", + "sleep", + "away", + null + ] + }, + "action": { + "$ref": "#/components/schemas/DeviceCommand" + }, + "cooldown_seconds": { + "type": "integer", + "minimum": 30, + "example": 300 + }, + "id": { + "type": "string", + "example": "uuid" + }, + "last_fired_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "flow_id": { + "type": [ + "string", + "null" + ], + "description": "Present for Flow-generated automations; edit the Flow instead." + }, + "flow_node_id": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "trigger_kind", + "action", + "cooldown_seconds", + "created_at", + "updated_at" + ] + }, + "FlowNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "condition" + }, + "kind": { + "type": "string", + "description": "Condition, logic or action block kind", + "example": "constant" + }, + "x": { + "type": "number", + "example": 20 + }, + "y": { + "type": "number", + "example": 20 + }, + "config": { + "type": "object", + "additionalProperties": true, + "description": "Block-specific configuration" + } + }, + "required": [ + "id", + "kind", + "config" + ], + "additionalProperties": false + }, + "FlowEdge": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "edge-1" + }, + "from": { + "type": "string", + "example": "condition" + }, + "to": { + "type": "string", + "example": "action" + } + }, + "required": [ + "id", + "from", + "to" + ], + "additionalProperties": false + }, + "FlowInput": { + "type": "object", + "description": "Visual Flow source graph. Executable saves validate the DAG and atomically compile schedules/automations.", + "properties": { + "name": { + "type": "string", + "example": "Comfort Flow" + }, + "enabled": { + "type": "boolean", + "example": true + }, + "draft": { + "type": "boolean", + "description": "Drafts are forced disabled and compile no outputs", + "example": false + }, + "description": { + "type": "string", + "example": "Comfort when occupied" + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FlowNode" + } + }, + "edges": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FlowEdge" + } + }, + "expected_revision": { + "type": [ + "integer", + "null" + ], + "description": "Required for updates to prevent stale editor tabs overwriting newer graphs", + "minimum": 1 + } + }, + "required": [ + "name" + ] + }, + "Flow": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Comfort Flow" + }, + "enabled": { + "type": "boolean", + "example": true + }, + "draft": { + "type": "boolean", + "description": "Drafts are forced disabled and compile no outputs", + "example": false + }, + "description": { + "type": "string", + "example": "Comfort when occupied" + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FlowNode" + } + }, + "edges": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FlowEdge" + } + }, + "expected_revision": { + "type": [ + "integer", + "null" + ], + "description": "Required for updates to prevent stale editor tabs overwriting newer graphs", + "minimum": 1 + }, + "id": { + "type": "string", + "example": "uuid" + }, + "summary": { + "type": "string" + }, + "compiled_schedule_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "compiled_automation_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "revision": { + "type": "integer", + "minimum": 1, + "example": 1 + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "enabled", + "draft", + "nodes", + "edges", + "revision", + "created_at", + "updated_at" + ] + }, + "FlowExport": { + "type": "object", + "properties": { + "format": { + "type": "string", + "enum": [ + "gree-controller-flow" + ], + "example": "gree-controller-flow" + }, + "version": { + "type": "integer", + "example": 1 + }, + "exported_at": { + "type": "string", + "format": "date-time" + }, + "flow": { + "$ref": "#/components/schemas/FlowInput" + } + }, + "required": [ + "format", + "version", + "exported_at", + "flow" + ], + "additionalProperties": false + }, + "FlowSimulationInput": { + "type": "object", + "description": "Dry-run input. Simulation validates/compiles/evaluates the graph but never mutates controller state.", + "properties": { + "flow": { + "$ref": "#/components/schemas/FlowInput" + }, + "flow_id": { + "type": [ + "string", + "null" + ] + }, + "at": { + "type": [ + "string", + "null" + ], + "description": "RFC3339 simulation time", + "format": "date-time" + }, + "overrides": { + "type": "object", + "additionalProperties": true, + "description": "Map of Flow node IDs to simulated values" + }, + "log": { + "type": "boolean", + "example": true + } + }, + "required": [ + "flow" + ] + }, + "FlowSimulationResult": { + "type": "object", + "properties": { + "dry_run": { + "type": "boolean", + "example": true + }, + "at": { + "type": "string", + "format": "date-time" + }, + "flow_id": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "type": "string" + }, + "compiled": { + "type": "object", + "properties": { + "schedules": { + "type": "integer", + "minimum": 0 + }, + "automations": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "node_id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "matched": { + "type": "boolean" + }, + "would_execute": { + "type": "boolean" + }, + "blocked_reason": { + "type": [ + "string", + "null" + ] + }, + "config": { + "type": "object", + "additionalProperties": true + }, + "trace": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "note": { + "type": "string" + } + }, + "required": [ + "dry_run", + "at", + "actions" + ] + }, + "Reading": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "device_id": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "indoor_temperature": { + "type": [ + "number", + "null" + ] + }, + "outdoor_temperature": { + "type": [ + "number", + "null" + ] + }, + "target_temperature": { + "type": "number" + }, + "power": { + "type": "boolean" + }, + "source": { + "type": "string" + } + }, + "required": [ + "id", + "device_id", + "timestamp", + "target_temperature", + "power", + "source" + ] + }, + "EventLog": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "level": { + "type": "string", + "example": "info" + }, + "kind": { + "type": "string", + "example": "device.updated" + }, + "message": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "id", + "timestamp", + "level", + "kind", + "message", + "metadata" + ] + }, + "EventRetention": { + "type": "object", + "properties": { + "days": { + "type": "integer", + "minimum": 1, + "maximum": 3650, + "example": 30 + } + }, + "required": [ + "days" + ], + "additionalProperties": false + }, + "DebugSettings": { + "type": "object", + "properties": { + "overlay_enabled": { + "type": "boolean", + "example": true + }, + "gree_frames": { + "type": "boolean", + "example": false + } + }, + "required": [ + "overlay_enabled", + "gree_frames" + ], + "additionalProperties": false + }, + "RuntimeSettings": { + "type": "object", + "description": "Public-safe runtime settings. Secret fields are returned blank with corresponding *_configured flags where applicable.", + "properties": { + "controller_id": { + "type": "string", + "example": "gree-controller" + }, + "simulator_enabled": { + "type": "boolean" + }, + "poll_interval_seconds": { + "type": "integer", + "minimum": 2, + "maximum": 3600, + "example": 15 + }, + "zone_interval_seconds": { + "type": "integer", + "minimum": 2, + "maximum": 3600, + "example": 5 + }, + "discovery_timeout_ms": { + "type": "integer", + "minimum": 300, + "maximum": 30000, + "example": 3000 + }, + "discovery_broadcast": { + "type": "string", + "example": "255.255.255.255:7000" + }, + "house_mode": { + "type": "string", + "enum": [ + "cool", + "heat", + "off" + ] + }, + "house_power_enabled": { + "type": "boolean", + "description": "Legacy compatibility field; normalized true. Use /api/house/power for bulk ON/OFF." + }, + "control_strategy": { + "type": "string", + "enum": [ + "setpoint" + ] + }, + "history_retention_days": { + "type": "integer", + "minimum": 1, + "maximum": 3650 + }, + "event_log_retention_days": { + "type": "integer", + "minimum": 1, + "maximum": 3650 + }, + "compressor_protection_enabled": { + "type": "boolean" + }, + "compressor_protection_seconds": { + "type": "integer", + "minimum": 30, + "maximum": 1800, + "example": 180 + }, + "debug": { + "$ref": "#/components/schemas/DebugSettings" + }, + "night_mode": { + "type": "object", + "additionalProperties": true + }, + "notifications": { + "type": "object", + "additionalProperties": true, + "description": "Notification provider settings; secrets are blanked in public responses." + }, + "influxdb": { + "type": "object", + "additionalProperties": true, + "description": "InfluxDB v1/v2 history settings; secrets are blanked in public responses." + }, + "home_assistant": { + "type": "object", + "additionalProperties": true, + "description": "Home Assistant connection, sensor aliases and shared Flow inputs; token is blanked in public responses." + } + } + }, + "ConfigurationExport": { + "type": "object", + "description": "Full configuration backup. Contains binding keys and integration credentials; treat as a secret.", + "properties": { + "format_version": { + "type": "integer", + "example": 1 + }, + "exported_at": { + "type": "string", + "format": "date-time" + }, + "settings": { + "$ref": "#/components/schemas/RuntimeSettings" + }, + "devices": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Device" + } + }, + "zones": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Zone" + } + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ClimateGroup" + } + }, + "schedules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Schedule" + } + }, + "automations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Automation" + } + } + }, + "required": [ + "format_version", + "exported_at", + "settings", + "devices", + "zones", + "groups", + "schedules", + "automations" + ] + }, + "ApiTokenInfo": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "uuid" + }, + "name": { + "type": "string", + "example": "Home Assistant" + }, + "token_prefix": { + "type": "string", + "example": "gree_controller_abc..." + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "token_prefix", + "created_at" + ], + "additionalProperties": false + }, + "CreateAccessTokenRequest": { + "type": "object", + "description": "If name is omitted, `Home Assistant` is used.", + "properties": { + "name": { + "type": "string", + "description": "Optional 1..80 character display name", + "example": "Home Assistant" + } + } + }, + "CreateAccessTokenResponse": { + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "Full generated secret. Returned exactly once; only its SHA-256 hash is stored.", + "example": "gree_controller_FULL_SECRET_SHOWN_ONCE" + }, + "item": { + "$ref": "#/components/schemas/ApiTokenInfo" + } + }, + "required": [ + "token", + "item" + ], + "additionalProperties": false + }, + "HaTestRequest": { + "type": "object", + "properties": { + "entity_id": { + "type": [ + "string", + "null" + ], + "description": "Optional. Defaults/aliases are resolved when omitted.", + "example": "sensor.room_temperature" + } + } + }, + "HaEntityRequest": { + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "example": "climate.gas_boiler" + } + }, + "required": [ + "entity_id" + ], + "additionalProperties": false + }, + "NotificationSettings": { + "type": "object", + "description": "Notification test/settings shape. Provider secrets/webhook fields are also accepted; blank secret fields reuse saved secrets during a test.", + "properties": { + "enabled": { + "type": "boolean" + }, + "mode": { + "type": "string", + "enum": [ + "problems", + "important" + ] + }, + "provider": { + "type": "string", + "enum": [ + "pushover", + "slack", + "discord" + ] + }, + "cooldown_seconds": { + "type": "integer", + "minimum": 30, + "maximum": 86400 + }, + "communication_failure_threshold": { + "type": "integer", + "minimum": 2, + "maximum": 100 + }, + "target_timeout_minutes": { + "type": "integer", + "minimum": 5, + "maximum": 1440 + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Validation error or unsafe/invalid operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "Unauthorized": { + "description": "Missing or incorrect token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": "unauthorized" + } + } + } + }, + "NotFound": { + "description": "Resource ID does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "Conflict": { + "description": "Revision/concurrency conflict or conflicting resource state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "BadGateway": { + "description": "GREE/Home Assistant/integration communication failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "InternalError": { + "description": "Unexpected server or storage error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": "internal server error" + } + } + } + } + } + } +} diff --git a/scripts/FILE_MANIFEST.sha256 b/scripts/FILE_MANIFEST.sha256 index a40ad2c..fefd757 100644 --- a/scripts/FILE_MANIFEST.sha256 +++ b/scripts/FILE_MANIFEST.sha256 @@ -8,6 +8,6 @@ bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./install.sh e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./network-debug.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./service.sh -e16483e9afcea50ca92fa2ccf4ec5e5a2cc46b68b157865d20b09829b5fe9ed3 ./smoke.sh +c714e3cf2fba84ded718d102bd620cf879c1a8f3a5ae8f435be7863d8a0cd8e0 ./smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./update.sh 4877f9e8217b6a77fb416722c3778373edac874ed3a583bb016ea76d4ffee7d4 ./verify_flow_logic.py diff --git a/scripts/smoke.sh b/scripts/smoke.sh index c387981..36c2657 100644 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -33,6 +33,19 @@ for _ in $(seq 1 80); do done grep -q '"status":"ok"' "$TMP/health.json" +curl -fsS "http://127.0.0.1:$PORT/api-docs/openapi.json" >"$TMP/openapi.json" +python3 - "$TMP/openapi.json" <<'PYOPENAPI' +import json, sys + +doc = json.load(open(sys.argv[1], encoding="utf-8")) +assert doc["openapi"] == "3.1.0" +assert "/api/zones/{id}/control" in doc["paths"] +assert "BearerToken" in doc["components"]["securitySchemes"] +assert doc["servers"][0]["url"] == "/" +PYOPENAPI +curl -fsSL "http://127.0.0.1:$PORT/api-docs" >"$TMP/swagger.html" +grep -qi 'swagger-ui' "$TMP/swagger.html" + curl -fsS "http://127.0.0.1:$PORT/api/bootstrap" >"$TMP/bootstrap.json" grep -q 'sim-salon' "$TMP/bootstrap.json" diff --git a/src/api.rs b/src/api.rs index ab87d38..5da02fe 100644 --- a/src/api.rs +++ b/src/api.rs @@ -28,6 +28,8 @@ use crate::{ state::AppState, }; +mod openapi; + const INDEX_HTML: &str = include_str!("../web/index.html"); const NOT_FOUND_HTML: &str = include_str!("../web/404.html"); const APP_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/app.bundle.js")); @@ -139,6 +141,7 @@ pub fn router(state: AppState) -> Router { .route("/lang/:file", get(language_file)) .route("/presets/index.json", get(preset_index)) .route("/presets/:file", get(preset_file)) + .merge(openapi::swagger_ui(&state.config.base_path)) .merge(protected) .merge(home_assistant_api); diff --git a/src/api/openapi.rs b/src/api/openapi.rs new file mode 100644 index 0000000..ca9339b --- /dev/null +++ b/src/api/openapi.rs @@ -0,0 +1,103 @@ +use serde_json::{json, Value}; +use utoipa_swagger_ui::{Config as SwaggerConfig, SwaggerUi}; + +const OPENAPI_JSON: &str = include_str!("../../docs/openapi.json"); + +pub(super) fn swagger_ui(base_path: &str) -> SwaggerUi { + let docs_url = format!("{}/api-docs/openapi.json", normalized_base_path(base_path)); + SwaggerUi::new("/api-docs") + // Keep the route itself relative to the application router so Axum's + // outer base-path nesting prefixes it exactly once. Swagger UI may use + // the externally visible, base-path-aware URL when fetching the spec. + .external_url_unchecked("/api-docs/openapi.json", document(base_path)) + .config( + SwaggerConfig::new([docs_url]) + .filter(true) + .try_it_out_enabled(true) + .display_request_duration(true) + .persist_authorization(true), + ) +} + +fn document(base_path: &str) -> Value { + let mut document: Value = serde_json::from_str(OPENAPI_JSON) + .expect("embedded OpenAPI document must contain valid JSON"); + document["info"]["version"] = Value::String(env!("CARGO_PKG_VERSION").to_string()); + document["servers"] = json!([{ + "url": server_base_path(base_path), + "description": "This GREE Controller instance" + }]); + document +} + +fn normalized_base_path(base_path: &str) -> String { + let base = base_path.trim().trim_end_matches('/'); + if base.is_empty() || base == "/" { + String::new() + } else if base.starts_with('/') { + base.to_string() + } else { + format!("/{base}") + } +} + +fn server_base_path(base_path: &str) -> String { + let base = normalized_base_path(base_path); + if base.is_empty() { + "/".into() + } else { + base + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn openapi_document_is_valid_json_and_version_is_runtime_version() { + let document = document(""); + assert_eq!(document["openapi"], "3.1.0"); + assert_eq!(document["info"]["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(document["servers"][0]["url"], "/"); + } + + #[test] + fn openapi_respects_configured_base_path() { + let document = document("/gree/"); + assert_eq!(document["servers"][0]["url"], "/gree"); + assert_eq!(normalized_base_path("/gree/"), "/gree"); + } + + #[test] + fn every_documented_operation_has_summary_description_and_responses() { + let document = document(""); + let paths = document["paths"].as_object().expect("OpenAPI paths object"); + for (path, item) in paths { + let methods = item.as_object().expect("OpenAPI path item"); + for (method, operation) in methods { + if !matches!(method.as_str(), "get" | "post" | "put" | "patch" | "delete") { + continue; + } + assert!( + operation.get("summary").and_then(Value::as_str).is_some(), + "{method} {path} is missing summary" + ); + assert!( + operation + .get("description") + .and_then(Value::as_str) + .is_some(), + "{method} {path} is missing description" + ); + assert!( + operation + .get("responses") + .and_then(Value::as_object) + .is_some(), + "{method} {path} is missing responses" + ); + } + } + } +}