openapi: 3.0.3
info:
  title: Persium M2M API
  version: "1.0.0"
  description: |
    The Persium machine-to-machine (M2M) API lets customer backends fetch
    stations, sensors, and measurement data for their organisation.

    Authentication uses standard OAuth 2.0 **Client Credentials** against
    Persium's Keycloak. All endpoints are read-only and hard-scoped to the
    calling organisation — query-string organisation overrides are ignored.
  contact:
    name: Persium Support
    email: support@persium.example.com

servers:
  - url: https://api.persium.co.uk/api/m2m/v1
    description: Production

tags:
  - name: Identity
    description: Verify credentials and resolve calling identity.
  - name: Stations
    description: List and inspect the stations owned by your organisation.
  - name: Sensors
    description: Sensors attached to a given station.
  - name: Measurements
    description: Air-quality measurements collected by a station.

security:
  - clientCredentials: []

paths:
  /me:
    get:
      tags: [Identity]
      summary: Get calling identity
      description: |
        Returns the M2M client identifier, the organisation it is scoped to,
        and the human-readable label configured for it. Use this endpoint
        from your CI/CD smoke test to confirm credentials work end-to-end.
      operationId: getMe
      responses:
        "200":
          description: Caller identity.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Me"
              example:
                client_id: acme-corp-backend
                organisation_uuid: 8d2f1a3e-1234-4c1b-9999-abcdefabcdef
                label: Acme Corp production backend
                all_organisations: false
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /stations:
    get:
      tags: [Stations]
      summary: List stations
      description: |
        Returns the paginated list of stations owned by your organisation.

        Any `organisation` or `organisation_uuid` query parameter is
        silently ignored — your credentials are already scoped to a single
        organisation and the server enforces that on every query.
      operationId: listStations
      parameters:
        - in: query
          name: uuid
          required: false
          schema:
            type: string
            format: uuid
          description: |
            Filter to a specific station UUID. Mostly redundant with
            `GET /stations/{uuid}` but supported for parity.
        - in: query
          name: search
          required: false
          schema:
            type: string
          description: Broad case-insensitive search across name and address.
        - in: query
          name: name
          required: false
          schema:
            type: string
          description: Case-insensitive substring match on station name.
        - in: query
          name: source_id
          required: false
          schema:
            type: string
          description: Exact match on the source-system identifier.
        - in: query
          name: source
          required: false
          schema:
            type: string
          description: |
            Exact match on the data source (e.g. `persium`, `cem`, `airnow`).
        - in: query
          name: address
          required: false
          schema:
            type: string
          description: Case-insensitive substring match on the station address.
        - in: query
          name: type
          required: false
          schema:
            type: string
          description: Station type filter.
        - in: query
          name: status
          required: false
          schema:
            type: string
          description: |
            Operational status filter (e.g. `online`, `offline`, `stale`).
        - in: query
          name: project
          required: false
          schema:
            type: string
          description: Case-insensitive substring match on project name.
        - in: query
          name: project_uuid
          required: false
          schema:
            type: string
            format: uuid
          description: Exact match on project UUID.
        - in: query
          name: serial_number
          required: false
          schema:
            type: integer
          description: Exact match on serial number.
        - $ref: "#/components/parameters/PageParam"
        - in: query
          name: per_page
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 20
          description: Items per page. Maximum **200**.
      responses:
        "200":
          description: A page of stations.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Station"
                  pagination:
                    $ref: "#/components/schemas/Pagination"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /stations/{uuid}:
    get:
      tags: [Stations]
      summary: Get one station
      description: |
        Returns the station with the given UUID. Returns **404** if the
        station does not exist **or** belongs to a different organisation —
        we deliberately do not distinguish between these cases.
      operationId: getStation
      parameters:
        - $ref: "#/components/parameters/StationUuidParam"
      responses:
        "200":
          description: A single station.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Station"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /stations/{uuid}/sensors:
    get:
      tags: [Sensors]
      summary: List sensors on a station
      operationId: listStationSensors
      parameters:
        - $ref: "#/components/parameters/StationUuidParam"
      responses:
        "200":
          description: All sensors attached to the station. Not paginated.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Sensor"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /stations/{uuid}/measurements:
    get:
      tags: [Measurements]
      summary: List measurements
      description: |
        Returns a paginated, time-ordered set of measurements for the
        station. Pass an explicit `start_time` and `end_time` for any
        historical analysis — omitting them returns the most recent rows
        regardless of date.
      operationId: listStationMeasurements
      parameters:
        - $ref: "#/components/parameters/StationUuidParam"
        - in: query
          name: start_time
          required: false
          schema:
            type: string
            format: date-time
          description: Inclusive lower bound. ISO 8601, UTC.
          example: "2026-05-01T00:00:00Z"
        - in: query
          name: end_time
          required: false
          schema:
            type: string
            format: date-time
          description: Inclusive upper bound. ISO 8601, UTC.
          example: "2026-05-19T00:00:00Z"
        - $ref: "#/components/parameters/PageParam"
        - in: query
          name: per_page
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 100
          description: Items per page. Maximum **1000**.
      responses:
        "200":
          description: A page of measurements.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Measurement"
                  pagination:
                    $ref: "#/components/schemas/Pagination"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /stations/{uuid}/measurements/latest:
    get:
      tags: [Measurements]
      summary: Get the latest measurement
      description: Returns the single most recent measurement for the station.
      operationId: getLatestStationMeasurement
      parameters:
        - $ref: "#/components/parameters/StationUuidParam"
      responses:
        "200":
          description: The most recent measurement.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Measurement"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

components:
  securitySchemes:
    clientCredentials:
      type: oauth2
      description: |
        Standard OAuth 2.0 Client Credentials. Exchange your `client_id` and
        `client_secret` at the Keycloak token endpoint, then pass the returned
        access token as `Authorization: Bearer <token>` on every request.
      flows:
        clientCredentials:
          tokenUrl: https://auth.persium.example.com/realms/persium/protocol/openid-connect/token
          scopes: {}

  parameters:
    StationUuidParam:
      in: path
      name: uuid
      required: true
      schema:
        type: string
        format: uuid
      description: The station UUID.

    PageParam:
      in: query
      name: page
      required: false
      schema:
        type: integer
        minimum: 1
        default: 1
      description: 1-based page number.

  responses:
    BadRequest:
      description: Malformed query parameters.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "invalid value for per_page"
    Unauthorized:
      description: Token missing, expired, invalid, or client not registered.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "unauthorized"
    Forbidden:
      description: The client has been disabled. Contact Persium support.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "client disabled"
    NotFound:
      description: |
        The resource does not exist **or** belongs to a different organisation.
        Persium does not distinguish between the two to avoid leaking station
        existence across organisations.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "station not found"

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable description of what went wrong.

    Pagination:
      type: object
      required: [page, per_page, total]
      properties:
        page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        total:
          type: integer
          description: Total number of matching records across all pages.
          example: 47

    Me:
      type: object
      required: [client_id, organisation_uuid, label, all_organisations]
      properties:
        client_id:
          type: string
          example: acme-corp-backend
        organisation_uuid:
          type: string
          format: uuid
        label:
          type: string
          example: Acme Corp production backend
        all_organisations:
          type: boolean
          description: |
            Reserved for Persium-issued elevated clients with cross-organisation
            access. Always `false` for customer clients.
          example: false

    LocationDetail:
      type: object
      required: [uuid, long_name]
      properties:
        uuid:
          type: string
          format: uuid
        long_name:
          type: string

    Location:
      type: object
      properties:
        city:
          $ref: "#/components/schemas/LocationDetail"
        state:
          $ref: "#/components/schemas/LocationDetail"
        country:
          $ref: "#/components/schemas/LocationDetail"

    AQIValue:
      type: object
      required: [value, base_on]
      properties:
        value:
          type: integer
          description: |
            The computed AQI index. `-1` indicates "no data" — the station
            has not reported enough recent measurements to compute it.
          example: 4
        base_on:
          type: string
          description: Pollutant code that drove the index, e.g. "PM25", "O3".
          example: PM25

    USAQIValue:
      allOf:
        - $ref: "#/components/schemas/AQIValue"
        - type: object
          properties:
            color:
              type: string
              description: Suggested colour for visualising the index.
              example: "#FFFF00"

    Station:
      type: object
      required:
        [
          uuid,
          name,
          source,
          latitude,
          longitude,
          status,
          daqi,
          caqi,
          usaqi,
        ]
      properties:
        uuid:
          type: string
          format: uuid
        name:
          type: string
          example: Station Alpha
        type:
          type: string
          nullable: true
          description: Free-form station classification when set.
        source:
          type: string
          description: |
            The data source the station was ingested from
            (e.g. `persium`, `cem`, `eea`, `airnow`).
          example: persium
        latitude:
          type: number
          format: double
          example: 51.5074
        longitude:
          type: number
          format: double
          example: -0.1278
        altitude:
          type: number
          format: double
          nullable: true
        address:
          type: string
          nullable: true
        photo:
          type: string
          nullable: true
          description: |
            Absolute URL to a representative photo for the station. May be
            an empty string if no photo is available.
        status:
          type: string
          example: online
          description: Operational status of the station.
        network:
          type: string
          nullable: true
        serial_number:
          type: integer
          format: int64
          nullable: true
        installed_date:
          type: string
          format: date-time
          nullable: true
        last_updated:
          type: string
          format: date-time
          nullable: true
          description: Timestamp of the most recent measurement, UTC.
        daqi:
          $ref: "#/components/schemas/AQIValue"
        caqi:
          $ref: "#/components/schemas/AQIValue"
        usaqi:
          $ref: "#/components/schemas/USAQIValue"
        location:
          $ref: "#/components/schemas/Location"

    SensorType:
      type: object
      required: [uuid, name]
      properties:
        uuid:
          type: string
          format: uuid
        name:
          type: string
          example: NO2

    SensorVendor:
      type: object
      required: [uuid, name, code]
      properties:
        uuid:
          type: string
          format: uuid
        name:
          type: string
        code:
          type: string

    Sensor:
      type: object
      required: [uuid, station_uuid, sensor_type]
      properties:
        uuid:
          type: string
          format: uuid
        name:
          type: string
          nullable: true
        serial_number:
          type: string
          nullable: true
        status:
          type: string
          nullable: true
        station_uuid:
          type: string
          format: uuid
        sensor_type:
          $ref: "#/components/schemas/SensorType"
        vendor:
          $ref: "#/components/schemas/SensorVendor"
        category:
          type: string
          nullable: true
          description: |
            High-level grouping of what the sensor measures
            (`pollutant`, `atmospheric`, `engineering`, `orientation`).
        installed_date:
          type: string
          format: date-time
          nullable: true
        note:
          type: string
          nullable: true

    PollutantData:
      type: object
      required: [name, code, daqi_color, caqi_color, usaqi_color]
      properties:
        name:
          type: string
          example: PM2.5
        code:
          type: string
          example: PM25
        ugm3:
          type: number
          format: double
          nullable: true
          description: Concentration in µg/m³, when applicable.
        ppm:
          type: number
          format: double
          nullable: true
        percent:
          type: number
          format: double
          nullable: true
        s_percent:
          type: string
          nullable: true
        daqi:
          type: integer
          nullable: true
        daqi_color:
          type: string
        caqi:
          type: integer
          nullable: true
        caqi_color:
          type: string
        usaqi:
          type: integer
          nullable: true
        usaqi_color:
          type: string

    ParameterData:
      type: object
      required: [name, code, value, unit]
      properties:
        name:
          type: string
          example: Temperature
        code:
          type: string
          example: TEMP
        value:
          type: number
          format: double
        unit:
          type: string
          example: "°C"
        min_value:
          type: number
          format: double
          nullable: true
        max_value:
          type: number
          format: double
          nullable: true

    Measurement:
      type: object
      required: [station_data_uuid, time, daqi, caqi, usaqi]
      properties:
        station_data_uuid:
          type: string
          format: uuid
        time:
          type: string
          format: date-time
          description: Measurement timestamp in UTC.
        daqi:
          $ref: "#/components/schemas/AQIValue"
        caqi:
          $ref: "#/components/schemas/AQIValue"
        usaqi:
          $ref: "#/components/schemas/USAQIValue"
        pollutants:
          type: array
          nullable: true
          items:
            $ref: "#/components/schemas/PollutantData"
        atmospherics:
          type: array
          nullable: true
          items:
            $ref: "#/components/schemas/ParameterData"
          description: Temperature, humidity, pressure, noise, etc.
        engineerings:
          type: array
          nullable: true
          items:
            $ref: "#/components/schemas/ParameterData"
          description: Battery, signal strength, and other diagnostic readings.
        orientations:
          type: array
          nullable: true
          items:
            $ref: "#/components/schemas/ParameterData"
          description: Wind direction and similar bearing-style readings.
