openapi: 3.1.0
info:
  title: Valkyris API
  version: 2.0.0
  description: Local-first home camera monitoring API. JSON responses use a success/message/data envelope; every response also includes X-Valkyris-Success and X-Valkyris-Message headers.
servers:
  - url: https://valkyris.local:8443/api/v1
security:
  - bearerAuth: []
paths:
  /admin/users:
    get:
      summary: List users, including disabled users (administrator only)
      responses:
        '200': {description: Users with name, enabled, admin and device count}
        '403': {description: Administrator required}
  /admin/users/{id}:
    parameters:
      - {in: path, name: id, required: true, schema: {type: string}}
    put:
      summary: Update user name, access and role (administrator only)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, enabled, admin]
              properties:
                id: {type: string}
                name: {type: string, maxLength: 100}
                enabled: {type: boolean}
                admin: {type: boolean}
                devices: {type: integer, readOnly: true}
      responses:
        '200': {description: Updated}
        '400': {description: Invalid input or last active administrator}
        '403': {description: Administrator required}
    delete:
      summary: Remove user and revoke linked devices (administrator only)
      responses:
        '204': {description: Removed}
        '400': {description: Last active administrator cannot be removed}
        '403': {description: Administrator required}
  /rules/{id}/recipients:
    parameters:
      - {in: path, name: id, required: true, schema: {type: string}}
    put:
      summary: Change notification and alarm recipients (administrator only)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                recipientUserIds: {type: array, nullable: true, items: {type: string}}
      responses:
        '200': {description: Recipients saved}
        '400': {description: Invalid recipients}
        '403': {description: Administrator required}
  /auth/status:
    get:
      security: []
      summary: Check whether the first administrator has been created
      responses:
        '200': {description: Bootstrap status, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}
  /admin/bootstrap:
    post:
      security: []
      summary: Create the first administrator account and device
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/LoginRequest'}
      responses:
        '201': {description: Administrator created, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}
        '409': {description: Administrator already configured}
  /viewer-session:
    get:
      summary: Read current account permissions and credential setup status
      security:
        - viewerCookie: []
      parameters:
        - {in: header, name: X-Valkyris-Viewer, required: true, schema: {type: string, const: '1'}}
      responses:
        '200': {description: Account permissions (admin, viewRules, editRules), username and credentialsConfigured; browser cookie renewed during use}
        '401': {description: Invalid or expired session}
    delete:
      summary: Revoke the current read-only browser session
      responses:
        '204': {description: Session revoked}
        '401': {description: Invalid or expired session}
  /login:
    post:
      security: []
      summary: Authenticate a personal account by username and password
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/LoginRequest'}
      responses:
        '201': {description: Authenticated, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}
        '401': {description: Invalid credentials}
  /pair:
    post:
      security: []
      summary: Register a new account using a single-use invitation; existing accounts must use login
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/PairRequest'}
      responses:
        '201': {description: Paired, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}
  /pairing-sessions:
    post:
      summary: Create a short-lived, administrator-only invitation
      responses: {'201': {description: Created, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
  /cameras:
    get:
      summary: List cameras and discovered capabilities
      responses: {'200': {description: Cameras in APIResponse.data, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
    post:
      summary: Start an asynchronous ONVIF probe and camera creation
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CameraInput'}}}}
      responses: {'202': {description: Validation started, content: {application/json: {schema: {$ref: '#/components/schemas/CameraOperationResponse'}}}}}
  /camera-operations/{id}:
    get:
      summary: Read the result of an asynchronous camera creation
      parameters: [{$ref: '#/components/parameters/id'}]
      responses:
        '200': {description: Current operation state, content: {application/json: {schema: {$ref: '#/components/schemas/CameraOperationResponse'}}}}
        '404': {description: Operation not found or expired, content: {application/json: {schema: {$ref: '#/components/schemas/APIError'}}}}
  /cameras/{id}:
    put:
      summary: Update a camera and restart its setup when connection values change
      parameters: [{$ref: '#/components/parameters/id'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CameraInput'}}}}
      responses: {'200': {description: Updated camera operation, content: {application/json: {schema: {$ref: '#/components/schemas/CameraOperationResponse'}}}}}
    delete:
      summary: Remove a camera
      parameters: [{$ref: '#/components/parameters/id'}]
      responses: {'200': {description: Removed with message envelope, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
  /cameras/{id}/ptz:
    post:
      summary: Move or stop a PTZ camera
      parameters: [{$ref: '#/components/parameters/id'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PTZCommand'}}}}
      responses: {'200': {description: Command accepted with message envelope, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
  /cameras/{id}/ptz/presets:
    get:
      summary: List the named PTZ positions stored on the camera
      parameters: [{$ref: '#/components/parameters/id'}]
      responses: {'200': {description: Presets in APIResponse.data}}
    post:
      summary: Save the camera's current position as a named preset
      parameters: [{$ref: '#/components/parameters/id'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PresetInput'}}}}
      responses:
        '201': {description: Saved preset in APIResponse.data}
        '502': {description: Camera unreachable}
  /cameras/{id}/ptz/presets/{token}/goto:
    post:
      summary: Move the camera to a stored position
      parameters: [{$ref: '#/components/parameters/id'}, {$ref: '#/components/parameters/token'}]
      responses:
        '200': {description: Command accepted}
        '502': {description: Camera unreachable}
  /cameras/{id}/ptz/presets/{token}:
    delete:
      summary: Remove a stored position from the camera
      parameters: [{$ref: '#/components/parameters/id'}, {$ref: '#/components/parameters/token'}]
      responses:
        '200': {description: Removed}
        '502': {description: Camera unreachable}
  /cameras/{id}/ptz/startup:
    put:
      summary: Pin the position applied when the camera starts or restarts
      parameters: [{$ref: '#/components/parameters/id'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/StartupPresetInput'}}}}
      responses: {'200': {description: Updated camera in APIResponse.data}}
  /cameras/{id}/snapshot:
    get:
      summary: Get a current JPEG frame from the authenticated local stream
      parameters: [{$ref: '#/components/parameters/id'}]
      responses: {'200': {description: JPEG image, content: {image/jpeg: {schema: {type: string, contentEncoding: binary}}}}}
  /cameras/{id}/recording:
    get:
      summary: Download the latest 10 seconds from the rolling camera buffer
      parameters: [{$ref: '#/components/parameters/id'}]
      responses:
        '200': {description: MP4 recording, content: {video/mp4: {schema: {type: string, contentEncoding: binary}}}}
        '502': {description: The rolling buffer is not ready, content: {application/json: {schema: {$ref: '#/components/schemas/APIError'}}}}
  /cameras/{id}/live/webrtc/whep:
    post:
      summary: Start an authenticated WHEP session for the live WebRTC stream
      parameters: [{$ref: '#/components/parameters/id'}, {$ref: '#/components/parameters/webrtcProfile'}]
      responses: {'201': {description: WebRTC SDP answer and session location}}
  /cameras/{id}/live/webrtc/whep/{session}:
    patch:
      summary: Send WebRTC ICE updates to an authenticated WHEP session
      parameters: [{$ref: '#/components/parameters/id'}, {$ref: '#/components/parameters/webrtcProfile'}, {$ref: '#/components/parameters/session'}]
      responses: {'204': {description: ICE update accepted}}
    delete:
      summary: Close an authenticated WHEP session
      parameters: [{$ref: '#/components/parameters/id'}, {$ref: '#/components/parameters/webrtcProfile'}, {$ref: '#/components/parameters/session'}]
      responses: {'204': {description: WebRTC session closed}}
  /lights:
    get:
      summary: List lights exposed by the installed local adapter and their latest state
      responses: {'200': {description: Lights in APIResponse.data}}
  /lights/{id}:
    get:
      summary: Get a locally controlled light
      parameters: [{$ref: '#/components/parameters/id'}]
      responses: {'200': {description: Light in APIResponse.data}}
    delete:
      summary: Remove a light from Valkyris (administrator only)
      parameters: [{$ref: '#/components/parameters/id'}]
      responses: {'200': {description: Removed}}
  /lights/{id}/state:
    put:
      summary: Control a light directly over the local network
      parameters: [{$ref: '#/components/parameters/id'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/LightStatePatch'}}}}
      responses:
        '200': {description: Updated light and state}
        '502': {description: Light unavailable on the local network}
  /remotes:
    get:
      summary: List remote controls (smart TVs) and their pairing state
      responses: {'200': {description: Remote controls in APIResponse.data}}
    post:
      summary: Register a remote control (administrator only)
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/RemoteInput'}}}}
      responses: {'201': {description: Created remote control in APIResponse.data}}
  /remotes/{id}:
    get:
      summary: Get a remote control
      parameters: [{$ref: '#/components/parameters/id'}]
      responses: {'200': {description: Remote control in APIResponse.data}}
    put:
      summary: Update a remote control (administrator only)
      parameters: [{$ref: '#/components/parameters/id'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/RemoteUpdate'}}}}
      responses: {'200': {description: Updated remote control in APIResponse.data}}
    delete:
      summary: Remove a remote control (administrator only)
      parameters: [{$ref: '#/components/parameters/id'}]
      responses: {'200': {description: Removed}}
  /remotes/{id}/pair:
    post:
      summary: Pair the controller with the TV (administrator only); accept the prompt shown on the TV
      parameters: [{$ref: '#/components/parameters/id'}]
      responses:
        '200': {description: Paired remote control with a stored token}
        '502': {description: The TV did not authorize or was unreachable}
  /remotes/{id}/keys:
    post:
      summary: Send one remote key to a paired device
      parameters: [{$ref: '#/components/parameters/id'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/RemoteKey'}}}}
      responses:
        '200': {description: Command accepted by the device}
        '502': {description: The device is unreachable or refused the command}
  /detectors:
    get:
      summary: List normalized detector types
      responses: {'200': {description: Detector catalog in APIResponse.data, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
  /rules:
    get:
      summary: List automation rules
      responses: {'200': {description: Rules in APIResponse.data, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
    post:
      summary: Create an automation rule
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Rule'}}}}
      responses: {'201': {description: Created, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
  /rules/{id}:
    put:
      summary: Update an automation rule
      parameters: [{$ref: '#/components/parameters/id'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Rule'}}}}
      responses: {'200': {description: Updated rule, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
    delete:
      summary: Delete an automation rule
      parameters: [{$ref: '#/components/parameters/id'}]
      responses: {'200': {description: Deleted with message envelope, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
  /users:
    get:
      summary: List family users linked to paired devices
      responses: {'200': {description: Users in APIResponse.data, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
  /users/{id}:
    put:
      summary: Update a family user as administrator
      parameters: [{$ref: '#/components/parameters/id'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/User'}}}}
      responses: {'200': {description: Updated user, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
  /users/{id}/history:
    get:
      summary: List a consolidated family location timeline, newest first
      description: The backend discards accuracy above 50 metres and merges consecutive observations within 200 metres before pagination. lastSeenAt is the latest observation in that interval. Old raw records are preserved.
      parameters:
        - {$ref: '#/components/parameters/id'}
        - {name: limit, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}}
        - {name: offset, in: query, schema: {type: integer, minimum: 0, default: 0}}
        - {name: until, in: query, description: Use the first page latest lastSeenAt for stable subsequent pages, schema: {type: string, format: date-time}}
      responses: {'200': {description: Location history in APIResponse.data, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
  /me:
    get:
      summary: Get the family user linked to the authenticated phone
      responses: {'200': {description: Current user, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
    put:
      summary: Update the family user linked to the authenticated phone
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/User'}}}}
      responses: {'200': {description: Updated user, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
  /me/location:
    post:
      summary: Record the authenticated device user's location
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/LocationReport'}}}}
      responses:
        '200':
          description: Reading accepted; pending confirmations require additional fresh readings
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: {type: boolean}
                  message: {type: string}
                  data: {$ref: '#/components/schemas/LocationReportResult'}
  /me/credentials:
    post:
      summary: Set credentials for the authenticated legacy account or change existing credentials
      description: Initial setup requires the existing device session. Subsequent changes require currentPassword. Preserves the user ID and history, and revokes other sessions.
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/AccountCredentials'}}}}
      responses:
        '200': {description: Credentials saved}
        '400': {description: Invalid password or username unavailable}
        '401': {description: Authentication required}
  /me/password:
    post:
      summary: Change the current account password and revoke its other sessions
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ChangePassword'}}}}
      responses: {'200': {description: Password changed, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
  /places:
    get:
      summary: List family alert areas
      responses: {'200': {description: Places in APIResponse.data, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
    post:
      summary: Create a family alert area as administrator
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Place'}}}}
      responses: {'201': {description: Created, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
  /places/{id}:
    put:
      summary: Update a family alert area as administrator
      parameters: [{$ref: '#/components/parameters/id'}]
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Place'}}}}
      responses: {'200': {description: Updated, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
    delete:
      summary: Delete a family alert area as administrator
      parameters: [{$ref: '#/components/parameters/id'}]
      responses: {'200': {description: Deleted, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
  /events:
    get:
      summary: List recent events
      responses: {'200': {description: Events in APIResponse.data, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
  /events/activity:
    get:
      summary: Count all events in twelve equal time intervals
      description: Excludes location events in the database before aggregation. Counts the complete window independently of recent-event list limits. Boundaries use server UTC time; start is inclusive and end exclusive.
      parameters:
        - {name: hours, in: query, required: true, schema: {type: integer, enum: [12, 24, 36, 48]}}
      responses:
        '200':
          description: Twelve ActivityBucket objects in APIResponse.data, oldest first
          content:
            application/json:
              schema:
                allOf:
                  - {$ref: '#/components/schemas/APIResponse'}
                  - properties:
                      data: {type: array, items: {$ref: '#/components/schemas/ActivityBucket'}}
        '400': {description: Invalid period}
  /events/interval:
    get:
      summary: List interval events in pages of 100
      description: Excludes location events in the database before pagination. Start inclusive, end exclusive, maximum 48 hours. Ordered by occurredAt descending then id descending. An empty or short page ends pagination.
      parameters:
        - {name: from, in: query, required: true, schema: {type: string, format: date-time}}
        - {name: to, in: query, required: true, schema: {type: string, format: date-time}}
        - {name: offset, in: query, schema: {type: integer, minimum: 0, default: 0}}
      responses:
        '200': {description: Event array in APIResponse.data, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}
        '400': {description: Invalid interval or offset}
  /events/acknowledge-all:
    post:
      summary: Acknowledge all unacknowledged events
      responses:
        200:
          description: Acknowledged count in APIResponse.data
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIResponse"
  /events/{id}:
    get:
      summary: Get event metadata
      parameters: [{$ref: '#/components/parameters/id'}]
      responses: {'200': {description: Event in APIResponse.data, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
  /events/{id}/acknowledge:
    post:
      summary: Acknowledge and stop an event alarm
      parameters: [{$ref: '#/components/parameters/id'}]
      responses: {'200': {description: Acknowledged with message envelope, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
  /events/{id}/clip:
    get:
      summary: Stream the event MP4 clip
      parameters: [{$ref: '#/components/parameters/id'}]
      responses: {'200': {description: MP4 clip}}
  /events/{id}/snapshot:
    get:
      summary: Download the event JPEG snapshot
      parameters: [{$ref: '#/components/parameters/id'}]
      responses: {'200': {description: JPEG snapshot}}
  /devices/push:
    post:
      summary: Register an FCM device token and payload encryption secret
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PushDevice'}}}}
      responses: {'200': {description: Registered with message envelope, content: {application/json: {schema: {$ref: '#/components/schemas/APIResponse'}}}}}
  /settings/retention:
    get:
      summary: Read the active media retention limits
      responses:
        '200': {description: Retention settings, content: {application/json: {schema: {$ref: '#/components/schemas/RetentionSettingsResponse'}}}}
    put:
      summary: Change media retention limits as the administrator
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/RetentionSettings'}
      responses:
        '200': {description: Retention settings saved, content: {application/json: {schema: {$ref: '#/components/schemas/RetentionSettingsResponse'}}}}
        '400': {description: Invalid limits, content: {application/json: {schema: {$ref: '#/components/schemas/APIError'}}}}
        '403': {description: Administrator access required, content: {application/json: {schema: {$ref: '#/components/schemas/APIError'}}}}
  /settings/push:
    get:
      summary: Read whether a Firebase service account is configured
      responses:
        '200': {description: Push configuration status, content: {application/json: {schema: {$ref: '#/components/schemas/PushConfigurationResponse'}}}}
    put:
      summary: Store an encrypted Firebase service account as the administrator
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/FirebaseServiceAccountUpload'}
      responses:
        '200': {description: Firebase service account stored, content: {application/json: {schema: {$ref: '#/components/schemas/PushConfigurationResponse'}}}}
        '400': {description: Invalid service account, content: {application/json: {schema: {$ref: '#/components/schemas/APIError'}}}}
        '403': {description: Administrator access required, content: {application/json: {schema: {$ref: '#/components/schemas/APIError'}}}}
  /detections:
    post:
      summary: Submit a normalized detection from a trusted local integration
      requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/Detection'}}}}
      responses: {'200': {description: Events created by matching rules}}
  /system/update:
    get:
      summary: Check the latest stable backend and Android release
      parameters:
        - {name: clientVersion, in: query, required: false, schema: {type: string}}
      responses:
        '200': {description: Update state, content: {application/json: {schema: {$ref: '#/components/schemas/UpdateInfoResponse'}}}}
  /realtime:
    get:
      summary: WebSocket stream for event changes
      responses: {'101': {description: Switching Protocols}}
components:
  securitySchemes:
    viewerCookie:
      type: apiKey
      in: cookie
      name: __Host-valkyris-viewer
      description: Secure HttpOnly SameSite=Strict cookie; requires X-Valkyris-Viewer 1 for cookie-authenticated calls. The browser can consult data, negotiate WHEP and control registered lights and remote controls, but cannot register or configure devices.
    bearerAuth: {type: http, scheme: bearer}
  parameters:
    webrtcProfile:
      name: profile
      in: query
      description: Optional browser-compatible H264 baseline stream, transcoded on demand. Preserve the returned Location for session operations.
      schema: {type: string, enum: [browser]}
    id: {name: id, in: path, required: true, schema: {type: string, format: uuid}}
    session: {name: session, in: path, required: true, schema: {type: string}}
    token: {name: token, in: path, required: true, schema: {type: string}, description: PTZ preset token stored on the camera}
  schemas:
    APIResponse:
      type: object
      required: [success, message, data]
      properties:
        success: {type: boolean, const: true}
        message: {type: string}
        data: {}
    APIError:
      type: object
      required: [success, message, error]
      properties:
        success: {type: boolean, const: false}
        message: {type: string}
        error: {type: string}
    CapabilitySet:
      type: object
      required: [snapshot, events, ptz, zoom, audio]
      properties:
        snapshot: {type: boolean}
        events: {type: boolean}
        ptz: {type: boolean}
        zoom: {type: boolean}
        audio: {type: boolean}
    AlertPresentation:
      type: object
      description: Per-rule alert presentation in actions.alerts. Empty text uses localized app defaults. Combined text is limited to 1600 UTF-8 bytes.
      properties:
        notificationTitle: {type: string, maxLength: 80, default: ''}
        notificationBody: {type: string, maxLength: 240, default: ''}
        alarmTitle: {type: string, maxLength: 80, default: ''}
        alarmBody: {type: string, maxLength: 240, default: ''}
        alarmSound: {type: string, enum: [alarm, ringtone, silent], default: alarm}
        vibrate: {type: boolean, default: true}
        fullScreen: {type: boolean, default: true}
    Camera:
      type: object
      required: [id, name, icon, host, capabilities, enabled]
      properties:
        alerts: {$ref: '#/components/schemas/AlertPresentation', deprecated: true, description: Legacy camera settings retained for migration only}
        id: {type: string, format: uuid}
        name: {type: string}
        icon: {type: string, enum: [camera, nursery, baby, bottle, dog, bedroom, office, entrance, living_room, yard, garage, kitchen, bathroom]}
        host: {type: string}
        port: {type: integer}
        profileToken: {type: string}
        capabilities: {$ref: '#/components/schemas/CapabilitySet'}
        setupStatus: {type: string, enum: [pending, ready, failed]}
        setupStep: {type: string, enum: [queued, probing, stream, ready, failed]}
        setupError: {type: string, description: Persisted diagnostic from the latest setup attempt}
        setupUpdatedAt: {type: string, format: date-time}
        startupPresetToken: {type: string, description: PTZ preset applied when the camera starts or restarts; empty disables it}
        enabled: {type: boolean}
    CameraInput:
      type: object
      required: [name, host, username, password]
      properties:
        alerts: {$ref: '#/components/schemas/AlertPresentation', deprecated: true, description: Legacy camera settings retained for migration only}
        name: {type: string}
        icon: {type: string, enum: [camera, nursery, baby, bottle, dog, bedroom, office, entrance, living_room, yard, garage, kitchen, bathroom], default: camera}
        host: {type: string}
        port: {type: integer, default: 2020}
        username: {type: string}
        password: {type: string, format: password}
        rtspUri: {type: string, format: uri, description: Optional override; defaults to the primary Tapo stream on port 554}
    CameraOperation:
      type: object
      required: [id, status, message, createdAt, updatedAt]
      properties:
        id: {type: string, format: uuid}
        status: {type: string, enum: [pending, completed, failed]}
        message: {type: string}
        camera: {$ref: '#/components/schemas/Camera'}
        createdAt: {type: string, format: date-time}
        updatedAt: {type: string, format: date-time}
    CameraOperationResponse:
      type: object
      required: [success, message, data]
      properties:
        success: {type: boolean, const: true}
        message: {type: string}
        data: {$ref: '#/components/schemas/CameraOperation'}
    LightColor:
      type: object
      required: [hue, saturation, value]
      properties:
        hue: {type: integer, minimum: 0, maximum: 360}
        saturation: {type: integer, minimum: 0, maximum: 100}
        value: {type: integer, minimum: 1, maximum: 100}
    LightState:
      type: object
      properties:
        online: {type: boolean, readOnly: true}
        power: {type: boolean}
        mode: {type: string, enum: [white, color]}
        brightness: {type: integer, minimum: 1, maximum: 100}
        temperatureKelvin: {type: integer, minimum: 2700, maximum: 6500}
        color: {$ref: '#/components/schemas/LightColor'}
        updatedAt: {type: string, format: date-time, readOnly: true}
    LightStatePatch:
      type: object
      description: Include only the values to change. Setting temperature selects white mode; setting color selects color mode.
      properties:
        power: {type: boolean}
        mode: {type: string, enum: [white, color]}
        brightness: {type: integer, minimum: 1, maximum: 100}
        temperatureKelvin: {type: integer, minimum: 2700, maximum: 6500}
        color: {$ref: '#/components/schemas/LightColor'}
    Light:
      type: object
      properties:
        id: {type: string, format: uuid}
        name: {type: string}
        room: {type: string}
        setupStatus: {type: string, enum: [pending, ready, failed]}
        setupError: {type: string}
        enabled: {type: boolean}
        lastSeenAt: {type: string, format: date-time}
        state: {$ref: '#/components/schemas/LightState'}
    Remote:
      type: object
      required: [id, name, kind, host, port, setupStatus, tokenConfigured, enabled]
      properties:
        id: {type: string, format: uuid}
        name: {type: string}
        kind: {type: string, enum: [smart_tv]}
        host: {type: string}
        port: {type: integer, default: 8002}
        setupStatus: {type: string, enum: [pending, ready, failed]}
        setupError: {type: string}
        tokenConfigured: {type: boolean, description: Whether the TV already authorized this controller}
        enabled: {type: boolean}
        lastSeenAt: {type: string, format: date-time}
        createdAt: {type: string, format: date-time}
        updatedAt: {type: string, format: date-time}
    RemoteInput:
      type: object
      required: [name, host]
      properties:
        name: {type: string, maxLength: 60}
        kind: {type: string, enum: [smart_tv], default: smart_tv}
        host: {type: string, description: TV IPv4/IPv6/hostname, with or without a scheme and port}
        port: {type: integer, default: 8002}
    RemoteUpdate:
      type: object
      properties:
        name: {type: string}
        host: {type: string}
        port: {type: integer}
        enabled: {type: boolean}
    RemoteKey:
      type: object
      required: [key]
      properties:
        key: {type: string, description: Whitelisted remote key such as KEY_HOME, KEY_VOLUP or KEY_ENTER}
    UpdateInfo:
      type: object
      required: [currentVersion, latestVersion, available, serverUpdateAvailable, apkUpdateAvailable, releaseUrl, message]
      properties:
        currentVersion: {type: string}
        clientVersion: {type: string}
        latestVersion: {type: string}
        available: {type: boolean}
        serverUpdateAvailable: {type: boolean}
        apkUpdateAvailable: {type: boolean}
        releaseUrl: {type: string, format: uri}
        apkUrl: {type: string, format: uri}
        publishedAt: {type: string, format: date-time}
        message: {type: string}
    UpdateInfoResponse:
      type: object
      required: [success, message, data]
      properties:
        success: {type: boolean, const: true}
        message: {type: string}
        data: {$ref: '#/components/schemas/UpdateInfo'}
    PTZCommand:
      type: object
      required: [action]
      properties:
        action: {type: string, enum: [move, relative, stop]}
        pan: {type: number, minimum: -1, maximum: 1}
        tilt: {type: number, minimum: -1, maximum: 1}
        zoom: {type: number, minimum: -1, maximum: 1}
    PTZPreset:
      type: object
      required: [token, name]
      properties:
        token: {type: string}
        name: {type: string}
    PresetInput:
      type: object
      properties:
        name: {type: string, maxLength: 60, description: Defaults to a generic label when empty}
    StartupPresetInput:
      type: object
      properties:
        token: {type: string, description: Preset token to apply on startup; empty clears it}
    Rule:
      type: object
      required: [cameraId, name, detectorTypes, actions]
      properties:
        id: {type: string, format: uuid}
        cameraId: {type: string, format: uuid}
        name: {type: string}
        detectorTypes: {type: array, items: {type: string}}
        confirmations: {type: integer, minimum: 1, default: 1}
        cooldownSeconds: {type: integer, minimum: 10, maximum: 3600, default: 60}
        schedule: {$ref: '#/components/schemas/Schedule'}
        motion: {$ref: '#/components/schemas/MotionSettings'}
        actions: {$ref: '#/components/schemas/Actions'}
        enabled: {type: boolean}
    MotionSettings:
      type: object
      description: Optional sampled persistent motion in a fixed image region; not baby identification, pose recognition or medical risk detection. Only valid for the motion detector. Missing/null keeps ordinary motion detection.
      required: [region, minDurationSeconds, minChangedFraction]
      properties:
        region:
          type: object
          required: [x, y, width, height]
          properties:
            x: {type: number, minimum: 0, maximum: 0.95}
            y: {type: number, minimum: 0, maximum: 0.95}
            width: {type: number, minimum: 0.05, maximum: 1}
            height: {type: number, minimum: 0.05, maximum: 1}
          description: Normalized image coordinates; x+width and y+height must not exceed 1.
        minDurationSeconds: {type: integer, minimum: 2, maximum: 300, default: 10}
        minChangedFraction: {type: number, minimum: 0.01, maximum: 0.5, default: 0.05}
    LocationReport:
      type: object
      required: [latitude, longitude, accuracy, occurredAt]
      properties:
        latitude: {type: number, minimum: -90, maximum: 90}
        longitude: {type: number, minimum: -180, maximum: 180}
        accuracy: {type: number, minimum: 0}
        occurredAt: {type: string, format: date-time}
    UserLocation:
      type: object
      required: [latitude, longitude]
      properties:
        latitude: {type: number, minimum: -90, maximum: 90}
        longitude: {type: number, minimum: -180, maximum: 180}
        accuracy: {type: number, minimum: 0}
        address: {type: string, maxLength: 320, readOnly: true, description: Address resolved by the backend}
        lastSeenAt: {type: string, format: date-time}
        occurredAt: {type: string, format: date-time}
    User:
      type: object
      required: [name]
      properties:
        id: {type: string, format: uuid}
        name: {type: string}
        color: {type: string}
        avatarData: {type: string, description: Reduced JPEG profile image encoded as a data URL}
        enabled: {type: boolean}
    ChangePassword:
      type: object
      required: [currentPassword, newPassword]
      properties:
        currentPassword: {type: string, format: password}
        newPassword: {type: string, format: password}
    Place:
      type: object
      required: [name, latitude, longitude, radiusMeters]
      properties:
        id: {type: string, format: uuid}
        name: {type: string}
        latitude: {type: number, minimum: -90, maximum: 90}
        longitude: {type: number, minimum: -180, maximum: 180}
        radiusMeters: {type: number, minimum: 20, maximum: 5000}
    Schedule:
      type: object
      description: Empty means always active. Otherwise days use Sunday=0 and identify the starting day of each interval; end is exclusive. Overnight intervals continue on the following day. Requires valid IANA timezone and distinct HH:mm start/end.
      properties:
        days: {type: array, items: {type: integer, minimum: 0, maximum: 6}}
        start: {type: string, pattern: '^\\d{2}:\\d{2}$'}
        end: {type: string, pattern: '^\\d{2}:\\d{2}$'}
        timezone: {type: string}
    Actions:
      type: object
      properties:
        alerts: {$ref: '#/components/schemas/AlertPresentation'}
        record: {type: boolean}
        notify: {type: boolean}
        alarm: {type: boolean}
        recipientUserIds:
          type: array
          nullable: true
          description: Null or absent sends to all active users; an empty list sends to nobody.
          items: {type: string, format: uuid}
    ActivityBucket:
      type: object
      required: [start, end, count]
      properties:
        start: {type: string, format: date-time}
        end: {type: string, format: date-time}
        count: {type: integer, minimum: 0}
    Event:
      type: object
      properties:
        id: {type: string, format: uuid}
        cameraId: {type: string, format: uuid}
        ruleId: {type: string, format: uuid}
        type: {type: string}
        confidence: {type: number}
        occurredAt: {type: string, format: date-time}
        snapshotPath: {type: string}
        clipPath: {type: string}
        acknowledgedAt: {type: string, format: date-time}
    PushDevice:
      type: object
      required: [token, secret]
      properties:
        token: {type: string, description: Firebase Cloud Messaging registration token}
        secret: {type: string}
    FirebaseServiceAccountUpload:
      type: object
      required: [serviceAccountBase64]
      properties:
        serviceAccountBase64: {type: string, contentEncoding: base64, description: Firebase service account JSON encoded as Base64}
    PushConfiguration:
      type: object
      required: [configured]
      properties:
        configured: {type: boolean}
    PushConfigurationResponse:
      type: object
      required: [success, message, data]
      properties:
        success: {type: boolean, const: true}
        message: {type: string}
        data: {$ref: '#/components/schemas/PushConfiguration'}
    RetentionSettings:
      type: object
      required: [maxAgeDays, maxStorageGB]
      properties:
        maxAgeDays: {type: integer, minimum: 1, maximum: 365, default: 7}
        maxStorageGB: {type: integer, minimum: 1, maximum: 1000, default: 5}
    RetentionSettingsResponse:
      type: object
      required: [success, message, data]
      properties:
        success: {type: boolean, const: true}
        message: {type: string}
        data: {$ref: '#/components/schemas/RetentionSettings'}
    PairingSession:
      type: object
      properties:
        id: {type: string, format: uuid}
        code: {type: string}
        expiresAt: {type: string, format: date-time}
    AuthStatus:
      type: object
      required: [initialized]
      properties:
        initialized: {type: boolean}
    LocationReportResult:
      type: object
      properties:
        transitions: {type: integer, description: Confirmed area changes emitted by this reading}
        pendingConfirmations: {type: integer, description: Pending changes requiring further fresh readings}
    AccountCredentials:
      type: object
      required: [newPassword]
      properties:
        username: {type: string, minLength: 3, maxLength: 40}
        currentPassword: {type: string, format: password, writeOnly: true}
        newPassword: {type: string, format: password, minLength: 12, writeOnly: true, description: At most 72 UTF-8 bytes}
    ManagedUser:
      type: object
      properties:
        id: {type: string}
        name: {type: string}
        enabled: {type: boolean}
        admin: {type: boolean}
        viewRules: {type: boolean, description: May read rules; administrators always have access}
        editRules: {type: boolean, description: May create, edit, delete rules and change recipients; implies viewRules}
        username: {type: string}
        credentialsConfigured: {type: boolean, readOnly: true}
        password: {type: string, format: password, writeOnly: true, description: Optional administrator credential reset; revokes all user sessions}
        devices: {type: integer, readOnly: true}
    LoginRequest:
      type: object
      required: [username, password, deviceName]
      properties:
        username: {type: string, minLength: 3, maxLength: 40}
        userName: {type: string, description: Display name used only during account creation}
        browserAdmin: {type: boolean, description: Request an HttpOnly browser session. Permissions always come from the authenticated account.}
        password: {type: string, format: password}
        deviceName: {type: string}
        readOnly: {type: boolean, default: false, description: "Issue a renewable 30-day HttpOnly cookie session; requires X-Valkyris-Viewer: 1; no token is returned in JSON."}
        locale: {type: string, default: pt-BR}
    PairRequest:
      type: object
      required: [code, deviceName, username, password]
      properties:
        username: {type: string, minLength: 3, maxLength: 40}
        password: {type: string, format: password, minLength: 12, writeOnly: true, description: At most 72 UTF-8 bytes}
        userName: {type: string, description: Display name}
        code: {type: string}
        deviceName: {type: string}
        locale: {type: string, default: pt-BR}
    PairResponse:
      type: object
      properties:
        deviceId: {type: string, format: uuid}
        token: {type: string}
        admin: {type: boolean}
        readOnly: {type: boolean, default: false}
    MediaAsset:
      type: object
      properties:
        type: {type: string, enum: [snapshot, clip]}
        path: {type: string}
        createdAt: {type: string, format: date-time}
    Detection:
      type: object
      required: [cameraId, type, confidence]
      properties:
        cameraId: {type: string, format: uuid}
        type: {type: string}
        confidence: {type: number, minimum: 0, maximum: 1}
        metadata: {type: object, additionalProperties: true}
