{
  "openapi": "3.1.1",
  "info": {
    "title": "Geyserwala App API",
    "version": "1.0.0",
    "description": "This API observes and controls Geyserwala Connect devices associated with one hosted App. The opaque App token embedded in the URL is a revocable bearer credential. Keep it confidential: do not disclose it in logs, source code, shared browser history, or referrer URLs.\n\nThe paths in this document are exhaustive. The API has no action-specific endpoints such as /api/set, /api/boost, /api/timer, or /api/mode. Change setpoint, boost, mode, or element pause by PATCHing the corresponding field to /api/value. Manage timers only through /api/value/timer and /api/value/timer/{timerId}.\n\nA client MUST NOT make more than 1,000 requests for one App token in one UTC calendar day. The limit applies to every API operation, including successful calls, failed calls, and retries.\n\nPolling is strongly discouraged. Make requests only when needed to satisfy a specific user request. Request only necessary fields and batch related writes into one PATCH request. Do not establish periodic polling or background monitoring. After a transport or server error, use backoff; do not blindly retry writes whose outcome is uncertain. Respect HTTP 429 and Retry-After if returned.\n\nReads return the device's last reported state, which may be stale if the device is offline. A successful write indicates command acceptance for delivery, not physical execution. Verify subsequent device-reported state before claiming a change occurred, without rapid repeated requests.\n\nAgent guidance: call GET /api/info first. If the App has multiple devices, an LLM MUST obtain an explicit user selection before any control operation. An LLM MUST send the selected id in X-DeviceId. Use GET /api/value?f=... with the smallest useful field set; omitting f returns an empty object. Never infer meanings for absent fields or opaque graph data. Do not change heating controls without explicit user intent."
  },
  "servers": [
    {
      "url": "/geyserwala/{token}",
      "description": "Replace token with the opaque token from the original App URL, not the placeholder default.",
      "variables": {
        "token": {
          "default": "demo",
          "description": "App URL bearer credential. The default is a placeholder, not a valid credential."
        }
      }
    }
  ],
  "tags": [
    {
      "name": "App",
      "description": "Discover devices associated with the App token."
    },
    {
      "name": "Values",
      "description": "Read reported device values and request control changes."
    },
    {
      "name": "Timers",
      "description": "Manage heating timers."
    },
    {
      "name": "Notifications",
      "description": "Read active notifications."
    }
  ],
  "paths": {
    "/api/info": {
      "get": {
        "operationId": "getAppInfo",
        "tags": [
          "App"
        ],
        "summary": "Get App and device information",
        "description": "Returns devices associated with the App token.",
        "parameters": [
          {
            "$ref": "#/components/parameters/DeviceIdOptional"
          }
        ],
        "responses": {
          "200": {
            "description": "App information.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AppInfo"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/value": {
      "get": {
        "operationId": "getValues",
        "tags": [
          "Values"
        ],
        "summary": "Read selected reported values",
        "description": "Returns only requested f fields. Without f, returns an empty object. Values are last reported, not synchronous physical reads.",
        "parameters": [
          {
            "$ref": "#/components/parameters/DeviceIdOptional"
          },
          {
            "$ref": "#/components/parameters/ValueFields"
          }
        ],
        "responses": {
          "200": {
            "description": "Selected reported values.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Values"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "patch": {
        "operationId": "updateValues",
        "tags": [
          "Values"
        ],
        "summary": "Request device control changes",
        "description": "This is the complete control operation for setpoint, boost, mode, and element pause. The API has no action-specific control endpoints. Accepts supported control fields and returns accepted subset. Unknown fields are ignored by the service. A 200 response does not confirm physical execution. After uncertain outcomes, do not blindly retry.",
        "parameters": [
          {
            "$ref": "#/components/parameters/DeviceIdOptional"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ValueWrite"
              },
              "examples": {
                "setpoint": {
                  "value": {
                    "setpoint": 55
                  }
                },
                "pauseElement": {
                  "value": {
                    "element-pause": 3600
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted control fields.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValueWrite"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/value/{key}": {
      "get": {
        "operationId": "getValue",
        "tags": [
          "Values"
        ],
        "summary": "Read one reported value",
        "description": "Returns the value directly rather than an object keyed by key. Use typed timer endpoints for timers. Graph data is opaque and not a stable analytics format.",
        "parameters": [
          {
            "$ref": "#/components/parameters/DeviceIdOptional"
          },
          {
            "$ref": "#/components/parameters/ValueKey"
          }
        ],
        "responses": {
          "200": {
            "description": "Requested reported value.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Value"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/value/timer": {
      "get": {
        "operationId": "listTimers",
        "tags": [
          "Timers"
        ],
        "summary": "List heating timers",
        "description": "Timer ids are device-generated identifiers, not array positions.",
        "parameters": [
          {
            "$ref": "#/components/parameters/DeviceIdOptional"
          }
        ],
        "responses": {
          "200": {
            "description": "Timers.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Timer"
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "post": {
        "operationId": "createTimer",
        "tags": [
          "Timers"
        ],
        "summary": "Create a heating timer",
        "description": "Creates a timer. Times use the device local wall clock. A timer heats on enabled days within its window while tank temperature is below temp. Do not blindly retry after uncertain outcomes, as duplicate timers could be created.",
        "parameters": [
          {
            "$ref": "#/components/parameters/DeviceIdOptional"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TimerInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Created timer.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Timer"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/value/timer/{timerId}": {
      "get": {
        "operationId": "getTimer",
        "tags": [
          "Timers"
        ],
        "summary": "Read one heating timer",
        "parameters": [
          {
            "$ref": "#/components/parameters/DeviceIdOptional"
          },
          {
            "$ref": "#/components/parameters/TimerId"
          }
        ],
        "responses": {
          "200": {
            "description": "Timer.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Timer"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "put": {
        "operationId": "replaceTimer",
        "tags": [
          "Timers"
        ],
        "summary": "Update a heating timer",
        "description": "Include the same id in the body. The command is asynchronous; later device reporting may be needed to verify it was applied.",
        "parameters": [
          {
            "$ref": "#/components/parameters/DeviceIdOptional"
          },
          {
            "$ref": "#/components/parameters/TimerId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Timer"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted timer.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Timer"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      },
      "delete": {
        "operationId": "deleteTimer",
        "tags": [
          "Timers"
        ],
        "summary": "Remove a heating timer",
        "description": "Removal is asynchronous; acceptance does not prove the device has removed it.",
        "parameters": [
          {
            "$ref": "#/components/parameters/DeviceIdOptional"
          },
          {
            "$ref": "#/components/parameters/TimerId"
          }
        ],
        "responses": {
          "200": {
            "description": "Removal command accepted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Success"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/notifications": {
      "get": {
        "operationId": "getNotifications",
        "tags": [
          "Notifications"
        ],
        "summary": "Read active notifications or summary",
        "description": "lastSeen filters notifications created strictly after the supplied Unix timestamp. view=summary returns count, latestCreatedAt, and highestSeverity.",
        "parameters": [
          {
            "$ref": "#/components/parameters/DeviceIdOptional"
          },
          {
            "$ref": "#/components/parameters/LastSeen"
          },
          {
            "$ref": "#/components/parameters/NotificationView"
          }
        ],
        "responses": {
          "200": {
            "description": "Notifications or summary.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationResponse"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    }
  },
  "components": {
    "parameters": {
      "DeviceIdOptional": {
        "name": "X-DeviceId",
        "in": "header",
        "required": false,
        "description": "Device id returned by GET /api/info. An LLM MUST obtain an explicit user selection and send this header before a control operation when the App has multiple devices.",
        "schema": {
          "type": "string"
        }
      },
      "ValueFields": {
        "name": "f",
        "in": "query",
        "required": true,
        "description": "Comma-separated field names. Unknown fields are omitted. Without f the endpoint returns an empty object.",
        "schema": {
          "type": "string",
          "example": "name,tank-temp,setpoint,mode,element-demand,lastseen"
        }
      },
      "ValueKey": {
        "name": "key",
        "in": "path",
        "required": true,
        "description": "Supported reported value name.",
        "schema": {
          "type": "string",
          "enum": [
            "setpoint",
            "boost-demand",
            "mode",
            "element-pause",
            "lastseen",
            "status",
            "version",
            "tank-temp",
            "tank-temp-graph",
            "element-demand",
            "collector-temp",
            "collector-temp-graph",
            "pump-status",
            "ldr-reading",
            "dc-element-status",
            "element-graph",
            "setpoint-max",
            "setpoint-eta",
            "features"
          ]
        }
      },
      "TimerId": {
        "name": "timerId",
        "in": "path",
        "required": true,
        "description": "Device-assigned timer identifier.",
        "schema": {
          "type": "integer",
          "minimum": 1
        }
      },
      "LastSeen": {
        "name": "lastSeen",
        "in": "query",
        "required": false,
        "description": "Unix timestamp in seconds; only notifications created strictly after this time are returned.",
        "schema": {
          "type": "integer",
          "minimum": 0
        }
      },
      "NotificationView": {
        "name": "view",
        "in": "query",
        "required": false,
        "description": "Use summary for a compact notification summary; list returns notification items.",
        "schema": {
          "type": "string",
          "enum": [
            "summary",
            "list"
          ]
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Invalid request parameter or JSON body."
      },
      "NotFound": {
        "description": "App, selected device, requested value, or timer does not exist."
      },
      "ServerError": {
        "description": "Service could not process request. Back off; do not blindly retry writes."
      }
    },
    "schemas": {
      "Celsius": {
        "type": "number",
        "description": "Temperature in degrees Celsius. Valid limits depend on device and firmware."
      },
      "UnixTimestamp": {
        "type": "integer",
        "minimum": 0,
        "description": "Unix timestamp in whole seconds, UTC."
      },
      "AppInfo": {
        "type": "object",
        "required": [
          "name",
          "devices"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "User-assigned App name."
          },
          "devices": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AppDevice"
            }
          }
        }
      },
      "AppDevice": {
        "type": "object",
        "required": [
          "id",
          "name"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Use as X-DeviceId for explicit selection."
          },
          "name": {
            "type": "string",
            "description": "User-assigned device name."
          }
        }
      },
      "Features": {
        "type": "object",
        "description": "Feature availability. False or absent means do not assume capability is available.",
        "properties": {
          "f-timers": {
            "type": "boolean"
          },
          "f-web": {
            "type": "boolean"
          },
          "f-element-graph": {
            "type": "boolean"
          },
          "f-premium": {
            "type": "boolean",
            "description": "True while Geyserwala Plus access is active."
          }
        },
        "additionalProperties": true
      },
      "OpaqueGraph": {
        "type": "object",
        "description": "Firmware-defined compact history data; encoding and sampling are not a stable public analytics contract.",
        "additionalProperties": true,
        "x-llm-guidance": "Do not decode, aggregate, or make control decisions from this object unless an explicit graph format is published."
      },
      "Values": {
        "type": "object",
        "description": "Sparse object of requested and available reported fields. Absence does not mean false or zero.",
        "properties": {
          "name": {
            "type": "string",
            "description": "User-assigned device name."
          },
          "setpoint": {
            "$ref": "#/components/schemas/Celsius"
          },
          "setpoint-max": {
            "$ref": "#/components/schemas/Celsius"
          },
          "tank-temp": {
            "$ref": "#/components/schemas/Celsius"
          },
          "collector-temp": {
            "$ref": "#/components/schemas/Celsius"
          },
          "boost-demand": {
            "type": "boolean",
            "description": "True requests electric heating until setpoint is reached when mode permits boosting. Heating wil continue for a maximum of 2 hours."
          },
          "element-demand": {
            "type": "boolean",
            "readOnly": true,
            "description": "Controller requests heating element; not proof of physical energisation."
          },
          "element-pause": {
            "type": "integer",
            "minimum": 0,
            "description": "Remaining pause duration in seconds; zero means no pause."
          },
          "mode": {
            "$ref": "#/components/schemas/GeyserwalaMode"
          },
          "pump-status": {
            "type": "boolean",
            "readOnly": true,
            "description": "Controller reports circulation pump active."
          },
          "status": {
            "type": "string",
            "readOnly": true,
            "description": "Human-readable status; not a stable machine interface."
          },
          "version": {
            "type": "string",
            "readOnly": true
          },
          "lastseen": {
            "$ref": "#/components/schemas/UnixTimestamp"
          },
          "setpoint-eta": {
            "type": "integer",
            "minimum": 0,
            "readOnly": true,
            "description": "Estimated seconds until setpoint when boosting; may be absent."
          },
          "features": {
            "$ref": "#/components/schemas/Features"
          },
          "timer": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Timer"
            }
          },
          "tank-temp-graph": {
            "$ref": "#/components/schemas/OpaqueGraph"
          },
          "collector-temp-graph": {
            "$ref": "#/components/schemas/OpaqueGraph"
          },
          "element-graph": {
            "$ref": "#/components/schemas/OpaqueGraph"
          },
          "ldr-reading": {
            "type": "number",
            "readOnly": true,
            "description": "Hardware-specific raw light sensor reading."
          },
          "dc-element-status": {
            "type": "boolean",
            "readOnly": true,
            "description": "Controller reports DC element active."
          }
        },
        "additionalProperties": true,
        "x-llm-guidance": "Request documented fields only. Do not infer meaning of unknown fields or send them in PATCH requests."
      },
      "Value": {
        "description": "One reported value; type depends on key.",
        "anyOf": [
          {
            "$ref": "#/components/schemas/Celsius"
          },
          {
            "type": "boolean"
          },
          {
            "type": "integer"
          },
          {
            "type": "string"
          },
          {
            "$ref": "#/components/schemas/OpaqueGraph"
          },
          {
            "$ref": "#/components/schemas/Features"
          }
        ]
      },
      "ValueWrite": {
        "type": "object",
        "minProperties": 1,
        "additionalProperties": false,
        "properties": {
          "setpoint": {
            "$ref": "#/components/schemas/Celsius"
          },
          "boost-demand": {
            "type": "boolean",
            "description": "True requests boost to current setpoint; false cancels boost."
          },
          "mode": {
            "$ref": "#/components/schemas/GeyserwalaMode"
          },
          "element-pause": {
            "type": "integer",
            "minimum": 0,
            "description": "Requested pause seconds; zero resumes normal element operation."
          }
        },
        "x-llm-guidance": "Include only explicitly requested changes. Before setting setpoint, read setpoint-max and do not exceed it. Setpoint is a target, not the current tank temperature."
      },
      "GeyserwalaMode": {
        "type": "string",
        "enum": [
          "TIMER",
          "SOLAR",
          "STANDBY",
          "SETPOINT",
          "HOLIDAY"
        ],
        "description": "Heating-control mode. SOLAR and STANDBY have the same heating behavior. Devices with a collector or PV panel present SOLAR; devices without either feature present STANDBY. Clients SHOULD treat SOLAR and STANDBY as equivalent unless they are choosing display text."
      },
      "TimeOfDay": {
        "type": "array",
        "description": "Device local time as [hour,minute], 24-hour clock.",
        "prefixItems": [
          {
            "type": "integer",
            "minimum": 0,
            "maximum": 23
          },
          {
            "type": "integer",
            "minimum": 0,
            "maximum": 59
          }
        ],
        "minItems": 2,
        "maxItems": 2
      },
      "DaysOfWeek": {
        "type": "array",
        "description": "Seven booleans, Sunday through Saturday.",
        "items": {
          "type": "boolean"
        },
        "minItems": 7,
        "maxItems": 7
      },
      "TimerFlags": {
        "type": "object",
        "required": [
          "disable",
          "on-time"
        ],
        "additionalProperties": false,
        "properties": {
          "disable": {
            "type": "boolean",
            "description": "True keeps timer but prevents it from running."
          },
          "on-time": {
            "type": "boolean",
            "description": "Geyserwala Plus timer behaviour; preserve existing value unless explicitly asked to change."
          }
        }
      },
      "TimerInput": {
        "type": "object",
        "required": [
          "begin",
          "end",
          "dow",
          "temp",
          "flags"
        ],
        "properties": {
          "begin": {
            "$ref": "#/components/schemas/TimeOfDay"
          },
          "end": {
            "$ref": "#/components/schemas/TimeOfDay"
          },
          "dow": {
            "$ref": "#/components/schemas/DaysOfWeek"
          },
          "temp": {
            "$ref": "#/components/schemas/Celsius"
          },
          "flags": {
            "$ref": "#/components/schemas/TimerFlags"
          }
        },
        "x-llm-guidance": "Preserve existing on-time flag unless user explicitly requests a change. Times use device local clock."
      },
      "Timer": {
        "allOf": [
          {
            "$ref": "#/components/schemas/TimerInput"
          },
          {
            "type": "object",
            "required": [
              "id"
            ],
            "properties": {
              "id": {
                "type": "integer",
                "minimum": 1,
                "description": "Device-assigned timer id. Include in PUT request body."
              }
            }
          }
        ]
      },
      "NotificationResponse": {
        "oneOf": [
          {
            "type": "object",
            "required": [
              "notifications"
            ],
            "properties": {
              "notifications": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Notification"
                }
              }
            }
          },
          {
            "type": "object",
            "required": [
              "summary"
            ],
            "properties": {
              "summary": {
                "$ref": "#/components/schemas/NotificationSummary"
              }
            }
          }
        ]
      },
      "Notification": {
        "type": "object",
        "required": [
          "createdAt",
          "severity",
          "title",
          "body"
        ],
        "properties": {
          "createdAt": {
            "$ref": "#/components/schemas/UnixTimestamp"
          },
          "severity": {
            "type": "string",
            "enum": [
              "info",
              "warning",
              "critical"
            ]
          },
          "title": {
            "type": "string"
          },
          "body": {
            "type": "string",
            "description": "HTML content. Treat as untrusted when processing outside the Geyserwala App."
          },
          "action": {
            "$ref": "#/components/schemas/NotificationAction"
          }
        }
      },
      "NotificationAction": {
        "type": "object",
        "required": [
          "label",
          "path"
        ],
        "properties": {
          "label": {
            "type": "string"
          },
          "path": {
            "type": "string",
            "description": "App-relative navigation path."
          }
        }
      },
      "NotificationSummary": {
        "type": "object",
        "required": [
          "count",
          "latestCreatedAt",
          "highestSeverity"
        ],
        "properties": {
          "count": {
            "type": "integer",
            "minimum": 0
          },
          "latestCreatedAt": {
            "$ref": "#/components/schemas/UnixTimestamp"
          },
          "highestSeverity": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "info",
              "warning",
              "critical",
              null
            ]
          }
        }
      },
      "Success": {
        "type": "object",
        "required": [
          "success"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": true
          }
        }
      }
    }
  },
  "x-thingwala-rate-limit": {
    "scope": "App token",
    "limit": 1000,
    "period": "UTC calendar day",
    "appliesTo": "All API operations, including successful calls, failed calls, and retries."
  },
  "x-llm-guidance": [
    "Treat the App token as a confidential, revocable bearer credential.",
    "Call GET /api/info to discover devices before requesting values or issuing commands.",
    "If GET /api/info returns multiple devices, obtain an explicit user selection before a control operation and send that device id in X-DeviceId.",
    "Do not make more than 1,000 API requests per App token per UTC calendar day. Count retries and failed requests.",
    "The documented paths are exhaustive. Do not infer action-specific endpoints; send setpoint, boost, mode, and element-pause changes with PATCH /api/value.",
    "Polling and unattended background monitoring are strongly discouraged. Make requests only as needed for a user request.",
    "Use the smallest useful f field selection and batch related writes.",
    "Before changing a setpoint, read setpoint-max and do not exceed the reported maximum.",
    "After an uncertain write outcome, inspect reported state before considering a retry; do not blindly repeat timer creation.",
    "Command acceptance does not prove physical execution. Report device state as last reported, potentially stale.",
    "Do not interpret undocumented graph representations or absent fields.",
    "Do not change heating controls without explicit user intent."
  ],
  "x-authentication": {
    "type": "url-capability",
    "credential": "App token",
    "location": "server URL path",
    "revocable": true
  }
}
