openapi: 3.1.0
info:
  title: Fluss API
  version: 1.2.0
  description: |
    The Fluss public API allows you to control and monitor your Fluss devices programmatically.

    All endpoints require an API key passed via the `authorization` header.
    You can get an API key by contacting Fluss.
  contact:
    name: Fluss Support

servers:
  - url: https://v1.fluss-api.com
    description: Production

security:
  - ApiKeyAuth: ["FLUSS_API_KEY"]

tags:
  - name: Devices
    description: List and inspect your devices
  - name: Logs
    description: View access logs for your devices
  - name: Control
    description: Trigger, Open, and Close your devices
  - name: Access
    description: Invite users to and revoke users from your devices
  - name: SDK
    description: Endpoints consumed by the public Fluss BLE SDK (not for direct use)
  - name: User
    description: Read and update the authenticated user's profile

paths:
  /v1/list:
    example:

    get:
      operationId: getDeviceList
      summary: List devices
      description: Returns all devices the authenticated user has access to, including device names and the user's permissions for each device.
      tags:
        - Devices
      responses:
        '200':
          description: List of devices
          content:
            application/json:
              schema:
                type: object
                properties:
                  devices:
                    type: array
                    items:
                      $ref: '#/components/schemas/DeviceListItem'
              example:
                devices:
                  - deviceId: "abc123"
                    deviceName: "Front Gate"
                    userPermissions:
                      canOpenMain: true
                      canUseWiFi: true
                      userType: "owner"
                  - deviceId: "def456"
                    deviceName: "Side Gate"
                    userPermissions:
                      canOpenMain: true
                      canUseWiFi: true
                      userType: "tenant"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/status/{deviceId}:
    get:
      operationId: getDeviceStatus
      summary: Get device status
      description: Returns the full status of a device including connectivity, firmware version, signal strength, and open/close state. You must have access to the device.
      tags:
        - Devices
      parameters:
        - $ref: '#/components/parameters/deviceId'
      responses:
        '200':
          description: Device status
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    $ref: '#/components/schemas/DeviceStatus'
              example:
                status:
                  deviceId: "abc123"
                  internetConnected: true
                  connectionTimeStamp: 1711324800000
                  updatedTimeStamp: 1711324860000
                  ipAddress: "192.168.1.50"
                  openCloseStatus: "Closed"
                  fwv: "2.1.4"
                  ssid: "HomeWifi"
                  rssi: -42
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Device does not exist, or no status record is available for it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                deviceNotFound:
                  summary: Device does not exist
                  value:
                    error: "Device Not Found"
                noStatus:
                  summary: No status record for device
                  value:
                    error: "No status found, please ensure your device has a sensor setup and internet connection"
        '503':
          description: Device is not connected to the internet.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Device is not connected to the internet"
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/logs:
    get:
      operationId: getAccessLogs
      summary: Get access logs
      description: |
        Returns access logs for one or more devices within a time range. Results are paginated using a cursor.

        The logs returned depend on the user's permission level for each device:
        - `canViewAccessLogs`: all logs for the device
        - `canUseIntercom`: only the user's own logs
        - `canInviteGuests`: the user's own logs plus logs from guests they invited
        - No special permissions: only the user's own logs

        Logs are not returned for timestamps before the device was claimed.
      tags:
        - Logs
      parameters:
        - name: deviceIds
          in: query
          required: true
          schema:
            type: string
          description: Comma-separated list of device IDs.
          example: "abc123,def456"
        - name: start
          in: query
          required: true
          schema:
            type: number
          description: Start of the time range (epoch ms).
          example: 1711324800000
        - name: stop
          in: query
          required: true
          schema:
            type: number
          description: End of the time range (epoch ms). Must be greater than `start`.
          example: 1711411200000
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Pagination cursor returned from a previous request. Pass this to fetch the next page.
      responses:
        '200':
          description: Access logs for the requested devices
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccessLogResponse'
              example:
                results:
                  - deviceId: "abc123"
                    logs:
                      - deviceId: "abc123"
                        timeStamp: 1711324900000
                        userId: "user1"
                        userName: "John Doe"
                        mobileNumber: "+27821234567"
                        eventType: "Api Trigger"
                        metaData: {}
                nextCursor: null
        '400':
          description: Missing or invalid query parameters
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missingDeviceIds:
                  summary: Missing deviceIds
                  value:
                    error: "deviceIds query parameter is required"
                invalidNumbers:
                  summary: Invalid start/stop
                  value:
                    error: "start and stop query parameters are required and must be numbers"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/DeviceNotFound'
        '422':
          description: Invalid time range
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Invalid time range: stop must be greater than start"
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/trigger/{deviceId}:
    post:
      operationId: triggerDevice
      summary: Trigger device
      description: |
        Sends a trigger command to the device. This is a general-purpose activation that does not depend on the current open/close state.

        Requires `canOpenMain` and `canUseWiFi` permissions. If the user has a schedule, access is validated against it.
      tags:
        - Control
      parameters:
        - $ref: '#/components/parameters/deviceId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                metaData:
                  type: string
                  maxLength: 400
                  description: Optional metadata to attach to the trigger event. Truncated if over 400 characters.
                timeStamp:
                  type: number
                  description: Optional custom timestamp (epoch ms). Defaults to current time.
            example:
              metaData: "Triggered via home automation"
      responses:
        '200':
          description: Trigger sent
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
              example:
                success: "trigger sent"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/DeviceNotFound'
        '424':
          $ref: '#/components/responses/DeviceOffline'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/open/{deviceId}:
    post:
      operationId: openDevice
      summary: Open device
      description: |
        Opens the device. The device must currently be in a `closed` state.

        Returns `409` if the device is already open. The underlying MQTT command sent is a trigger (`tr`).
      tags:
        - Control
      parameters:
        - $ref: '#/components/parameters/deviceId'
      responses:
        '200':
          description: Open command sent
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
              example:
                success: "open sent"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/DeviceNotFound'
        '409':
          description: Device is already open
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Device is already open"
        '424':
          $ref: '#/components/responses/DeviceOffline'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/access:
    post:
      operationId: giveAccess
      summary: Give access to one or more users on one or more devices
      description: |
        Invites users to one or more devices in a single call. Each invitee is identified by mobile number.

        **Device type lock**: every device in the batch must be of the same type. The type is inferred from the deviceId format:
          - 6 digits → intercom
          - 16 digits → device

        The first deviceId determines the batch type. Any subsequent deviceIds whose format does not match are skipped — they appear in `results` with each invitee under `rejected` carrying an `error` reason and a message stating the mismatch. If the first deviceId itself has neither shape, the request is rejected with `400`.

        **Intercom invites ignore `permission`.** When the batch type is `intercom`, every invitee is granted the canonical `IntercomUser` preset (intercom-only access — no main / pedestrian open, no invite, no logs). Any `permission`, `startDate`, `endDate`, or `schedule` fields you send are silently ignored, and the field becomes optional. For 16-digit (`device`) batches, `permission` is still required and is selected from the presets below — the API expands it server-side to a full `InvitePermissions` object. Allowed values:
          - `Full` — permission to open, view, invite, revoke. Use for trusted family members or co-managers.
          - `Always` — permanent open access only. Suitable for live-in residents or staff with no expiry.
          - `Temporary` — open access bounded by `startDate` and `endDate` (epoch ms). Both fields are required.
          - `Repeat` — open access governed by a recurring weekly `schedule`. Required when this preset is used.

        For each device, the authenticated caller must have `canInviteUsers` permission.
        For each invitee, the device's available user slot count (`device.billing.app.usage` minus currently-occupying records) is checked; invitees beyond the slot budget are reported as `rejected` with reason `no_slots`.

        Each invitee is resolved to either:
          - **active**: a registered Fluss user (looked up by mobile) — written to the active access table, push notification enqueued.
          - **pending**: not registered — written to the pending access table keyed by mobile.

        The following invitees are rejected without aborting the rest of the batch:
          - The caller themselves (`cannot_change_self`, 409 captured per-invitee).
          - The device owner (an existing record with `canDelete: true`) (`cannot_change_owner`, 409 captured per-invitee).

        Each successful write increments the device's API call count and emits a `Give Access` audit event and a `receiveInvite` notification event.

        The HTTP response is `200` even when some or all invitees are rejected — inspect each device's `accepted` and `rejected` arrays for outcomes. A device-level error (e.g. caller lacks permission on that device) is recorded under that device's `rejected` list rather than failing the whole batch.
      tags:
        - Access
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GiveAccessRequest'
            examples:
              alwaysAccess:
                summary: Permanent open access for two users on two devices
                value:
                  deviceIds: ["1111222233334444", "5555666677778888"]
                  invitees:
                    - userName: "Jane Doe"
                      mobile: "+27821234567"
                      notes: "House cleaner"
                    - userName: "John Smith"
                      mobile: "+27839876543"
                      notes: "Gardener"
                      unit: "A1"
                  permission: "Always"
              intercomAccess:
                summary: Intercom batch (permission omitted — IntercomUser is forced)
                value:
                  deviceIds: ["123456", "654321"]
                  invitees:
                    - userName: "Apartment 4B"
                      mobile: "+27821112222"
              temporaryAccess:
                summary: Bounded access for a one-week visit
                value:
                  deviceIds: ["abc123"]
                  invitees:
                    - userName: "Cousin Pat"
                      mobile: "+27821112222"
                  permission: "Temporary"
                  startDate: 1755648000000
                  endDate: 1756252800000
              repeatAccess:
                summary: Weekly recurring access (cleaner — Mon & Wed mornings)
                value:
                  deviceIds: ["abc123"]
                  invitees:
                    - userName: "Cleaner"
                      mobile: "+27823334444"
                  permission: "Repeat"
                  schedule:
                    Mon:
                      - startTime: "08:00"
                        endTime: "12:00"
                    Wed:
                      - startTime: "08:00"
                        endTime: "12:00"
      responses:
        '200':
          description: Per-device invite outcomes
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GiveAccessResponse'
              example:
                results:
                  - deviceId: "abc123"
                    accepted:
                      - mobile: "+27821234567"
                        kind: "active"
                        userAccess:
                          deviceId: "abc123"
                          userId: "user-9"
                          userName: "Jane Doe"
                          accessStatus: "sent"
                      - mobile: "+27839876543"
                        kind: "pending"
                        userAccess:
                          deviceId: "abc123"
                          mobile: "+27839876543"
                          userName: "John Smith"
                          accessStatus: "sent"
                    rejected: []
                  - deviceId: "def456"
                    accepted:
                      - mobile: "+27821234567"
                        kind: "active"
                        userAccess:
                          deviceId: "def456"
                          userId: "user-9"
                    rejected:
                      - mobile: "+27839876543"
                        reason: "no_slots"
        '400':
          description: Missing or invalid request fields, or unknown / misconfigured permission preset.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missingFields:
                  summary: deviceIds or invitees missing
                  value:
                    error: "deviceIds and invitees are required"
                missingPermission:
                  summary: Permission missing for a 16-digit (device) batch
                  value:
                    error: "permission is required for device-type access"
                invalidDeviceIdFormat:
                  summary: First deviceId is neither 6 nor 16 digits
                  value:
                    error: "invalid deviceId format 'abc'. Expected 6 digits (intercom) or 16 digits (device)"
                unknownPreset:
                  summary: Permission preset not recognised
                  value:
                    error: "unknown permission preset 'Owner'. Allowed: Full, Always, Temporary, Repeat"
                missingTemporaryDates:
                  summary: Temporary preset is missing startDate / endDate
                  value:
                    error: "Temporary permission requires both startDate and endDate"
                missingSchedule:
                  summary: Repeat preset is missing schedule
                  value:
                    error: "Repeat permission requires a non-empty schedule"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          description: Date range invalid (e.g. `endDate` not after `startDate`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "endDate must be greater than startDate"
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/access/{deviceId}:
    delete:
      operationId: revokeAccess
      summary: Revoke a single user's access to a device
      description: |
        Revokes access for one user (identified by mobile) on a single device.

        The caller must have `canRevokeAccess` permission on the device.

        The mobile is resolved to either an active registered user (record deleted from the active access table) or a pending invitee (record deleted from the pending access table by mobile).

        The following are refused:
          - Caller revoking themselves (`cannot_revoke_self`, 409).
          - Target is the device owner (existing record with `canDelete: true`) (`cannot_revoke_owner`, 409).
          - Target is registered but has no access record on this device (`revoke_target_not_found`, 404). Pending revokes do not 404 — the delete is idempotent at the table level.

        On success the device's API call count is incremented and a `Revoke Access` audit event is emitted.
      tags:
        - Access
      parameters:
        - $ref: '#/components/parameters/deviceId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RevokeAccessRequest'
            example:
              mobile: "+27821234567"
      responses:
        '200':
          description: Access revoked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RevokeAccessResponse'
              example:
                success: "access revoked"
                result:
                  deviceId: "abc123"
                  mobile: "+27821234567"
                  kind: "active"
                  revokedUserId: "user-9"
        '400':
          description: Missing `deviceId` path parameter or `mobile` body field.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "deviceId and mobile are required"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Caller lacks `canRevokeAccess` on this device.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "user does not have permission to revoke access"
        '404':
          description: Target user is registered but has no access record on this device.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "user not registered to the device"
        '409':
          description: Refused — cannot revoke self, or target is the device owner.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                self:
                  summary: Caller tried to revoke their own access
                  value:
                    error: "cannot revoke your own access"
                owner:
                  summary: Target is the device owner
                  value:
                    error: "cannot revoke the owner of the device"
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/close/{deviceId}:
    post:
      operationId: closeDevice
      summary: Close device
      description: |
        Closes the device. The device must currently be in an `open` state.

        Returns `409` if the device is already closed. The underlying MQTT command sent is a trigger (`tr`).
      tags:
        - Control
      parameters:
        - $ref: '#/components/parameters/deviceId'
      responses:
        '200':
          description: Close command sent
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
              example:
                success: "close sent"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/DeviceNotFound'
        '409':
          description: Device is already closed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Device is already closed"
        '424':
          $ref: '#/components/responses/DeviceOffline'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/sdk/devices:
    get:
      operationId: getSdkDevices
      summary: Get the signed device list for the public BLE SDK
      description: |
        Returns the BLE device records the API-key holder is entitled to operate, accompanied by
        an Ed25519-signed envelope. The SDK verifies the signature with its embedded public key
        and uses `payload.deviceIds` as the source of truth; `devices[]` carries the full
        records (including the BLE secret tokens) the SDK needs to scan and write.

        Filtering: only **owned** or **Full-access** devices are returned. Intercom records and
        any access record without a `secretToken` are excluded.

        The signature covers the UTF-8 bytes of the **base64 payload string** — not the decoded
        JSON — so the SDK can verify without worrying about JSON canonicalization.
      tags:
        - SDK
      responses:
        '200':
          description: Signed device envelope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SdkDevicesResponse'
              example:
                payload: "eyJkZXZpY2VJZHMiOlsiYWJjMTIzIl0sImlzc3VlZEF0IjoxNzM5OTk5MDAwMDAwLCJleHBpcmVzQXQiOjE3NDI1OTEwMDAwMDAsImN1c3RvbWVySWQiOiJ1c3JfeHl6In0="
                signature: "qf4bV...AA=="
                devices:
                  - deviceId: "abc123"
                    deviceName: "Front Gate"
                    userId: "usr_xyz"
                    mobile: "+27821234567"
                    secretToken: "f3c2..."
                    userIdSecretToken: "f3c2..."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/sdk/logs:
    post:
      operationId: postSdkLog
      summary: Submit an SDK telemetry event
      description: |
        Records a single SDK lifecycle event (scan/write/trigger/error). Sent best-effort by the
        SDK when the consumer initialises with `log = true`; failures must never block BLE
        operations on the device side.
      tags:
        - SDK
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SdkLogRequest'
            example:
              event: "trigger"
              timestamp: 1740000000000
              deviceName: "Front Gate"
              message: "OK"
      responses:
        '202':
          description: Log accepted for processing
          content:
            application/json:
              schema:
                type: object
                properties:
                  accepted:
                    type: boolean
              example:
                accepted: true
        '400':
          description: Unknown or missing `event`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/user/profile:
    get:
      operationId: getUserProfile
      summary: Get the authenticated user's profile
      description: Returns the authenticated user's profile, including the registered webhook URL when one is set.
      tags:
        - User
      responses:
        '200':
          description: The user's profile
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  profile:
                    $ref: '#/components/schemas/UserProfile'
              example:
                success: true
                profile:
                  userId: "user-9"
                  userName: "Jane Doe"
                  webhookUrl: "https://example.com/webhooks/fluss"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No profile found for the authenticated user.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "Profile not found"
        '500':
          $ref: '#/components/responses/InternalError'
    patch:
      operationId: updateUserProfile
      summary: Update the authenticated user's profile
      description: |
        Updates the authenticated user's profile.

        The optional `webhookUrl` field registers an HTTPS URL that Fluss will POST to on device
        status changes. It must start with `https://` and be a syntactically valid URL. Passing an
        empty string (`""`) removes the webhook entirely.
      tags:
        - User
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateUserProfileRequest'
            example:
              webhookUrl: "https://example.com/webhooks/fluss"
      responses:
        '200':
          description: The updated profile
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  profile:
                    $ref: '#/components/schemas/UserProfile'
              example:
                success: true
                profile:
                  userId: "user-9"
                  userName: "Jane Doe"
                  webhookUrl: "https://example.com/webhooks/fluss"
        '400':
          description: The supplied `webhookUrl` is not a valid https:// URL.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "webhookUrl must be a valid https:// URL"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'

x-webhooks:
  statusChange:
    post:
      summary: Device status change
      description: >
        Fired when a device reports a status change (open, closed, online,
        offline). Fluss will POST the payload to the user's registered
        webhookUrl. The request will time out after 10 seconds. Non-2xx
        responses are logged but not retried.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookStatusChangePayload'
      responses:
        "2XX":
          description: Acknowledged. Any 2xx is treated as success.

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: authorization
      description: Your Fluss API key. Pass it as the `authorization` header value.

  parameters:
    deviceId:
      name: deviceId
      in: path
      required: true
      schema:
        type: string
      description: The unique identifier of the device.
      example: "abc123"

  schemas:
    WebhookStatusChangePayload:
      type: object
      required:
        - event
        - deviceId
        - status
        - timestamp
      properties:
        event:
          type: string
          enum: [status_change]
        deviceId:
          type: string
        status:
          type: string
          enum: [open, closed, online, offline]
        timestamp:
          type: number
          description: Unix epoch milliseconds

    UserProfile:
      type: object
      required:
        - userId
      properties:
        userId:
          type: string
        userName:
          type: string
        webhookUrl:
          type: string
          format: uri
          description: >
            HTTPS URL to receive webhook POST requests on device status changes.
            Must start with https://. Omitted if not set.

    UpdateUserProfileRequest:
      type: object
      properties:
        webhookUrl:
          type: string
          format: uri
          description: >
            HTTPS URL to receive webhook POST requests on device status changes.
            Must start with https://. Pass an empty string ("") to remove the
            registered webhook.

    DeviceListItem:
      type: object
      properties:
        deviceId:
          type: string
        deviceName:
          type: string
        userPermissions:
          $ref: '#/components/schemas/UserPermissions'
      required:
        - deviceId
        - deviceName
        - userPermissions

    UserPermissions:
      type: object
      properties:
        userType:
          type: string
          description: The user's role (e.g. owner, tenant, guest).
        canOpenMain:
          type: boolean
        canOpenPedestrian:
          type: boolean
        canUseWiFi:
          type: boolean
        canInviteUsers:
          type: boolean
          description: Required to invite users via `POST /v1/access`.
        canRevokeAccess:
          type: boolean
          description: Required to revoke users via `DELETE /v1/access/{deviceId}`.
        canDelete:
          type: boolean
          description: True for the device owner. Owners cannot be revoked or have their permissions overwritten via the access endpoints.
        canViewAccessLogs:
          type: boolean
        canOperateSwitch:
          type: boolean
        startDate:
          type: number
          description: Temporary access start (epoch ms).
        endDate:
          type: number
          description: Temporary access end (epoch ms). Records past their `endDate` are excluded from the device's user-slot count.

    NewUserObj:
      type: object
      description: An invitee to be granted access to a device.
      properties:
        userName:
          type: string
          description: Display name to store on the invitee's access record.
        mobile:
          type: string
          description: E.164-formatted mobile number used to look up an existing Fluss account or to key the pending record.
        notes:
          type: string
          description: Free-form notes attached to the invitee's access record.
        unit:
          type: string
          description: Optional intercom unit identifier. When set, the invitee is also registered against the device's unit list.
      required:
        - userName
        - mobile

    PermissionPreset:
      type: string
      enum:
        - Full
        - Always
        - Temporary
        - Repeat
      description: |
        Named permission preset that the API expands server-side into a full `InvitePermissions` object.

          - `Full` — open / view / invite / revoke (no Wi-Fi, not the device owner).
          - `Always` — permanent open access only.
          - `Temporary` — open access bounded by `startDate` + `endDate` (both required when this preset is used).
          - `Repeat` — open access governed by a recurring weekly `schedule` (required when this preset is used).

    ScheduleItem:
      type: object
      properties:
        startTime:
          type: string
          description: Time of day in `HH:MM` 24h format.
          example: "09:00"
        endTime:
          type: string
          description: Time of day in `HH:MM` 24h format. Must be greater than or equal to `startTime`.
          example: "17:00"
      required:
        - startTime
        - endTime

    InviteSchedule:
      type: object
      description: |
        Recurring weekly access schedule used by the `Repeat` preset. Keys are short day names; each value is an array of allowed time ranges for that day.
      additionalProperties:
        type: array
        items:
          $ref: '#/components/schemas/ScheduleItem'
      example:
        Mon:
          - startTime: "08:00"
            endTime: "12:00"
        Wed:
          - startTime: "08:00"
            endTime: "12:00"

    GiveAccessRequest:
      type: object
      properties:
        deviceIds:
          type: array
          minItems: 1
          items:
            type: string
          description: |
            Devices to invite the users to. The first id locks the batch's device type
            (6 digits → intercom, 16 digits → device); mismatched ids are skipped.
        invitees:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/NewUserObj'
        permission:
          allOf:
            - $ref: '#/components/schemas/PermissionPreset'
          description: |
            Required when the batch type is `device` (16-digit ids). Ignored when the batch type is `intercom`
            (6-digit ids) — intercom invitees always receive the canonical `IntercomUser` permission set.
        startDate:
          type: number
          description: Required when `permission` is `Temporary`. Epoch ms. Ignored for intercom batches.
        endDate:
          type: number
          description: Required when `permission` is `Temporary`. Epoch ms. Must be greater than `startDate`. Ignored for intercom batches.
        schedule:
          allOf:
            - $ref: '#/components/schemas/InviteSchedule'
          description: Required when `permission` is `Repeat`. Ignored for intercom batches.
      required:
        - deviceIds
        - invitees

    AcceptedInvite:
      type: object
      properties:
        mobile:
          type: string
        kind:
          type: string
          enum:
            - active
            - pending
          description: |
            - `active`: invitee was an existing Fluss user; their active access record was written.
            - `pending`: invitee is not yet a Fluss user; a pending record keyed by mobile was written.
        userAccess:
          type: object
          description: The newly-written access record (shape depends on `kind`).
      required:
        - mobile
        - kind

    RejectedInvite:
      type: object
      properties:
        mobile:
          type: string
        reason:
          type: string
          enum:
            - no_slots
            - error
          description: |
            - `no_slots`: the device's user-slot budget (`device.billing.app.usage` minus currently-occupying records) was exhausted before this invitee.
            - `error`: per-invitee error such as caller-equals-invitee, owner protection, or upstream lookup failure.
        error:
          type: string
          description: Present when `reason` is `error`. Human-readable message.
      required:
        - mobile
        - reason

    InviteDeviceResult:
      type: object
      properties:
        deviceId:
          type: string
        accepted:
          type: array
          items:
            $ref: '#/components/schemas/AcceptedInvite'
        rejected:
          type: array
          items:
            $ref: '#/components/schemas/RejectedInvite'
      required:
        - deviceId
        - accepted
        - rejected

    GiveAccessResponse:
      type: object
      properties:
        results:
          type: array
          items:
            $ref: '#/components/schemas/InviteDeviceResult'
      required:
        - results

    RevokeAccessRequest:
      type: object
      properties:
        mobile:
          type: string
          description: E.164-formatted mobile number of the user whose access should be removed.
      required:
        - mobile

    RevokeAccessResult:
      type: object
      properties:
        deviceId:
          type: string
        mobile:
          type: string
        kind:
          type: string
          enum:
            - active
            - pending
        revokedUserId:
          type: string
          description: Present when `kind` is `active`.
      required:
        - deviceId
        - mobile
        - kind

    RevokeAccessResponse:
      type: object
      properties:
        success:
          type: string
        result:
          $ref: '#/components/schemas/RevokeAccessResult'
      required:
        - success
        - result

    DeviceStatus:
      type: object
      properties:
        deviceId:
          type: string
        internetConnected:
          type: boolean
          description: Whether the device is currently online.
        connectionTimeStamp:
          type: number
          description: Epoch ms when the device last connected.
        updatedTimeStamp:
          type: number
          description: Epoch ms when the status was last updated.
        ipAddress:
          type: string
        openCloseStatus:
          type: string
          enum:
            - Open
            - Closed
          description: Current physical state of the device.
        fwv:
          type: string
          description: Firmware version.
        ssid:
          type: string
          description: Wi-Fi network the device is connected to.
        rssi:
          type: number
          description: Wi-Fi signal strength (dBm).
      required:
        - internetConnected
        - connectionTimeStamp
        - updatedTimeStamp

    AccessLogResponse:
      type: object
      properties:
        results:
          type: array
          items:
            $ref: '#/components/schemas/DeviceAccessLogResult'
        nextCursor:
          type: string
          nullable: true
          description: Pagination cursor for the next page. `null` when there are no more results.
      required:
        - results
        - nextCursor

    DeviceAccessLogResult:
      type: object
      properties:
        deviceId:
          type: string
        logs:
          type: array
          items:
            $ref: '#/components/schemas/AccessLog'
      required:
        - deviceId
        - logs

    AccessLog:
      type: object
      properties:
        deviceId:
          type: string
        timeStamp:
          type: number
          description: Epoch ms when the event occurred.
        userId:
          type: string
        userName:
          type: string
        mobileNumber:
          type: string
        eventType:
          type: string
          description: The type of event (e.g. "Api Trigger", "Wi-Fi Trigger", "Api Open", "Api Close").
        metaData:
          type: object
          description: Additional metadata about the event.
      required:
        - deviceId
        - timeStamp
        - userId
        - eventType

    SuccessResponse:
      type: object
      properties:
        success:
          type: string

    SdkDeviceRecord:
      type: object
      description: Per-device record consumed by the public Fluss BLE SDK.
      properties:
        deviceId:
          type: string
        deviceName:
          type: string
        userId:
          type: string
          description: The Fluss user (API-key holder) the BLE keys are bound to.
        mobile:
          type: string
        secretToken:
          type: string
          description: BLE write key for this device + user pair.
        userIdSecretToken:
          type: string
          description: |
            Currently identical to `secretToken`; carried separately to match the SDK's
            DeviceAccess shape.
      required:
        - deviceId
        - deviceName
        - userId
        - mobile
        - secretToken
        - userIdSecretToken

    SdkDevicesResponse:
      type: object
      description: |
        Signed envelope plus the per-device records the SDK uses for BLE writes. The SDK verifies
        `signature` over the **UTF-8 bytes of the base64 `payload` string** (not the decoded JSON)
        using the embedded Ed25519 public key, then cross-checks `payload.deviceIds` against
        `devices[].deviceId`.
      properties:
        payload:
          type: string
          description: |
            Base64-encoded JSON of `{ deviceIds, issuedAt, expiresAt, customerId }`.
            Tokens are valid for 30 days from `issuedAt`.
        signature:
          type: string
          description: Base64-encoded Ed25519 signature over the UTF-8 bytes of `payload`.
        devices:
          type: array
          items:
            $ref: '#/components/schemas/SdkDeviceRecord'
      required:
        - payload
        - signature
        - devices

    SdkLogRequest:
      type: object
      properties:
        event:
          type: string
          enum:
            - scanStarted
            - scanStopped
            - write
            - trigger
            - writeFailed
            - triggerFailed
            - error
        timestamp:
          type: number
          description: Epoch ms from the SDK clock.
        deviceName:
          type: string
        message:
          type: string
      required:
        - event
        - timestamp

    ErrorResponse:
      type: object
      properties:
        error:
          type: string

  responses:
    Unauthorized:
      description: Invalid or missing API key, or no access to the device.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: "access denied: you are not registered to the device"

    Forbidden:
      description: User does not have permission for this action.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            wifi:
              summary: Wi-Fi permission denied
              value:
                error: "permission denied, user cannot use wifi"
            main:
              summary: Main trigger permission denied
              value:
                error: "permission denied, user cannot use main trigger"
            schedule:
              summary: Schedule restriction
              value:
                error: "Repeat day access denied"

    DeviceNotFound:
      description: Device does not exist.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: "Device Not Found"

    DeviceOffline:
      description: Device is not connected to the internet.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: "Device not connected to internet"

    InternalError:
      description: An unexpected error occurred.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: "Internal Server Error"
