openapi: 3.1.0
info:
  title: BEN MyMeter API
  version: "1.0"
  description: |
    Online Metering Data Collection Platform — machine/IoT API.

    Authentication: every request carries `Authorization: Bearer <token>`.
    Tokens are created in *Settings → API tokens*, are bound to exactly one
    tenant, and carry scopes. Reporting requires the `data:report` scope;
    the read/query endpoints require `data:read`.

    Rate limits: 60 requests/minute per token. Responses include
    `X-RateLimit-Limit` / `X-RateLimit-Remaining`.

servers:
  - url: /api/v1

security:
  - bearerAuth: []

paths:
  /ping:
    get:
      summary: Authenticated liveness probe
      operationId: ping
      responses:
        "200":
          description: API reachable and token valid
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  application: { type: string }
                  time: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /reportData:
    post:
      summary: Report consumption / saldo readings (batch)
      operationId: reportData
      description: |
        Reports one or many readings for meters of the token's tenant.

        - Meter identifiers unknown to the tenant are **auto-provisioned**
          (filed under "Ungrouped meters"). Auto-provisioning is bounded
          per tenant (default 100 new meters/hour); beyond the cap, items
          for NEW identifiers return `status: rejected_provision_limit`
          while readings for existing meters are unaffected.
        - On the hosted service a **plan meter limit** applies as well.
          At the limit, items for NEW identifiers return
          `status: rejected_plan_limit` and the workspace owner needs to
          upgrade. Readings for meters that ALREADY EXIST are never
          refused for a plan or payment reason — meter history cannot be
          reconstructed once a device has moved on, so the commercial
          lever is access to the data, never acceptance of it.
        - **Idempotent:** a reading is identified by
          `(meter, kind, recorded_at)`; retries of the same report are
          answered with `status: duplicate` and never double-count.
        - **Model-aware:** every item is validated against the meter's
          assigned device profile (see `profile` on `/meters`). Meters
          on the *generic* profile report the plain
          `kind`/`value`/`unit` shape; meters assigned another model
          report a `payload` object matching that profile's schema
          (e.g. the IZAR register reading plus status flags). The
          payload is normalized into canonical readings; the raw
          payload is retained on the stored reading for traceability.
        - **Pulse counters** (#44) report `pulses` (absolute counter)
          and/or `pulse_delta` (increment since the last report); the
          per-meter **pulse weight** ("1 pulse = weight × unit",
          configured in the meter settings) converts counts into
          measurements. Items for meters without that configuration
          are answered per item with `status: rejected_unconfigured` —
          the meter itself IS provisioned, so it can be configured and
          the next report ingests. Multi-channel pulse devices report
          each channel as its own meter identifier.
        - The optional `profile` field declares the device's model
          explicitly: an unknown meter is then auto-provisioned
          directly onto that profile (nothing left to review), and a
          meter still on the unconfirmed provisioning default is
          upgraded to the declared model. A declaration that
          contradicts a *user-confirmed* profile assignment is
          rejected — reassign the meter in settings first.
        - **Atomic validation:** the whole batch is validated before
          anything is stored. Any schema violation rejects the entire
          request with a 422 naming the offending field
          (e.g. `readings.3.payload.value`); nothing is written.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [readings]
              properties:
                readings:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    oneOf:
                      - { $ref: "#/components/schemas/ReadingReport" }
                      - { $ref: "#/components/schemas/ProfilePayloadReport" }
            examples:
              hourly-consumption:
                value:
                  readings:
                    - meter: "MTR-00012345"
                      kind: consumption
                      value: 1.25
                      unit: kwh
                      recorded_at: "2026-08-15T10:00:00Z"
              saldo:
                value:
                  readings:
                    - meter: "MTR-00012345"
                      kind: saldo
                      value: 4521.75
                      unit: kwh
                      recorded_at: "2026-08-15T10:00:00Z"
              izar-register-with-status:
                description: >
                  Profile payload for a meter assigned the IZAR model
                  (#43): register reading in m³ plus device status
                  flags. The reading is stored as a canonical saldo
                  value; the status flags are stored as a structured
                  status record (raw flags + canonical codes, #45) —
                  current status on `/meters`, history on
                  `/statusReports`. IZAR flag vocabulary: `leakage`,
                  `burst`, `no_flow`/`dry_running`, `backflow`/
                  `reverse_flow`, `manipulation`/`magnetic`/`removal`,
                  `battery_low`/`battery_eol`, `blocked`, `frost`,
                  `alarm`, `no_error` (explicit all-clear). Canonical
                  names (`leak`, `tamper`, …) are also accepted as-is;
                  flags outside the vocabulary are stored raw and
                  surface as the canonical `error` status. This example
                  normalizes to `[leak, low_battery]`.
                value:
                  readings:
                    - meter: "IZAR-88231"
                      profile: izar
                      unit: m3
                      payload:
                        value: 4521.5
                        status: [leakage, battery_low]
                      recorded_at: "2026-08-15T10:00:00Z"
              pulse-counter:
                description: >
                  Profile payload for a pulse-counter meter (#44):
                  the absolute pulse counter state. With a configured
                  pulse weight of e.g. 10 l this stores a saldo
                  reading of 128,450 l (canonical 128.45 m³); a
                  `pulse_delta` field would store a consumption
                  reading instead. Counter rollover/reset and gaps are
                  handled by the saldo machinery (#29) — configure the
                  meter's rollover maximum for wrapping counters.
                value:
                  readings:
                    - meter: "PULSE-7"
                      profile: pulse_counter
                      payload:
                        pulses: 12845
                      recorded_at: "2026-08-15T10:00:00Z"
      responses:
        "201":
          description: At least one reading stored
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportDataResult" }
        "200":
          description: Nothing new — every entry was a duplicate (retry)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportDataResult" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403":
          description: Token lacks the `data:report` scope
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: >
            Validation failed — structural or against the meter's
            profile schema. `errors` keys name the offending field
            (e.g. `readings.3.payload.value`); the whole batch was
            rejected, nothing was stored.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  errors:
                    type: object
                    additionalProperties:
                      type: array
                      items: { type: string }
        "429": { $ref: "#/components/responses/RateLimited" }

  /meters:
    get:
      summary: List the tenant's meters
      operationId: listMeters
      description: |
        Meter inventory of the token's tenant, ordered by identifier.
        Requires the `data:read` scope. Use the returned `id` values to
        filter `/readings`.
      responses:
        "200":
          description: Meter list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Meter" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/MissingScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /readings:
    get:
      summary: Query readings (raw or aggregated)
      operationId: queryReadings
      description: |
        Readings of the token's tenant within a time range, optionally
        filtered to one meter or one group subtree and/or one kind.
        Requires the `data:read` scope.

        - Defaults: `to` = now, `from` = `to` − 7 days; the range is
          capped at 366 days.
        - `aggregate=raw` returns individual readings (oldest first),
          including canonical values and quality flags (#29).
        - `aggregate=hourly|daily` returns one bucket per
          (bucket, meter, kind) with `sum`, `min`, `max`, `avg` and
          `reading_count`, computed over CANONICAL values (kwh / m3,
          #29) — readings without a canonical value (unit mismatch,
          not yet backfilled) are excluded from buckets. For
          `consumption` the meaningful figure is usually `sum`; for
          `saldo` (a counter state) use `min`/`max`/`avg` — summing
          counter states is meaningless.
        - `tz=<IANA zone>` buckets in local wall time — daily buckets
          on DST-change days correctly span 23/25 hours; `bucket_start`
          is then local time. Raw rows gain `recorded_at_local`.
          Storage and defaults stay UTC.
        - `derive=consumption` (requires `meter_id`, no `aggregate`)
          returns the consumption series derived from consecutive
          SALDO readings, bridging counter rollovers and recorded
          meter exchanges (#29). Intervals longer than the meter's
          expected cadence are marked `gap: true`;
          `interpolate=linear` (opt-in) splits such deltas into
          cadence slots, each marked `interpolated: true`. Unknown
          intervals (exchange without baselines, unexplained decrease)
          have `consumption: null` — values are never invented.
      parameters:
        - { name: meter_id, in: query, schema: { type: integer }, description: Filter to one meter (mutually exclusive with group_id) }
        - { name: group_id, in: query, schema: { type: integer }, description: Filter to a group INCLUDING its subtree }
        - { name: kind, in: query, schema: { type: string, enum: [consumption, saldo] } }
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - { name: aggregate, in: query, schema: { type: string, enum: [raw, hourly, daily], default: raw } }
        - { name: tz, in: query, schema: { type: string }, description: "IANA timezone for bucketing/presentation (#29), e.g. Europe/Vienna" }
        - { name: derive, in: query, schema: { type: string, enum: [consumption] }, description: "Derive consumption from saldo readings of one meter (#29)" }
        - { name: interpolate, in: query, schema: { type: string, enum: [linear] }, description: "Only with derive: split gap deltas into cadence slots, marked interpolated" }
        - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
        - { name: per_page, in: query, schema: { type: integer, minimum: 1, maximum: 1000, default: 100 } }
      responses:
        "200":
          description: Readings page
          content:
            application/json:
              schema:
                type: object
                properties:
                  aggregate: { type: string, enum: [raw, hourly, daily] }
                  from: { type: string, format: date-time }
                  to: { type: string, format: date-time }
                  data:
                    type: array
                    items:
                      oneOf:
                        - { $ref: "#/components/schemas/RawReading" }
                        - { $ref: "#/components/schemas/AggregatedBucket" }
                        - { $ref: "#/components/schemas/DerivedConsumptionRow" }
                  meta: { $ref: "#/components/schemas/PageMeta" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/MissingScope" }
        "422":
          description: Validation failed (unknown meter/group, bad range, …)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /statusReports:
    get:
      summary: Query device-status history
      operationId: queryStatusReports
      description: |
        Device-status reports of the token's tenant, newest first.
        Requires the `data:read` scope.

        Each row is one status the device itself reported (#45): the
        provider's raw flags verbatim plus the normalized canonical
        codes every profile maps onto. An empty `statuses` list means
        the device reported all-clear — a leak appearing and later
        clearing is therefore two traceable rows. Device status is
        distinct from the platform-computed data-quality flags on
        `/readings` (#29); both surface as metering issues in the UI.
      parameters:
        - { name: meter_id, in: query, schema: { type: integer }, description: Filter to one meter }
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
        - { name: per_page, in: query, schema: { type: integer, minimum: 1, maximum: 1000, default: 100 } }
      responses:
        "200":
          description: Status report page
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/StatusReport" }
                  meta: { $ref: "#/components/schemas/PageMeta" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/MissingScope" }
        "422":
          description: Validation failed (unknown meter, …)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: API token from Settings → API tokens (prefix `bmm_`)

  responses:
    Unauthenticated:
      description: Missing, unknown, revoked or expired token
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Per-token rate limit exceeded
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    MissingScope:
      description: Token lacks the scope required by this endpoint
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      properties:
        message: { type: string }

    Meter:
      type: object
      properties:
        id: { type: integer }
        meter_identifier: { type: string }
        name: { type: string, nullable: true }
        unit: { type: string, nullable: true }
        profile:
          type: object
          nullable: true
          description: >
            Device profile (#41) — which meter model's data format this
            meter reports. `needs_review: true` means the profile was
            defaulted at auto-provisioning and has not been confirmed
            by a user yet.
          properties:
            key: { type: string, example: izar }
            version: { type: integer }
            name: { type: string }
            needs_review: { type: boolean }
        group:
          type: object
          nullable: true
          description: null = the "Ungrouped meters" bucket
          properties:
            id: { type: integer }
            name: { type: string }
        status:
          type: object
          nullable: true
          description: >
            Latest device-reported status (#45), normalized to the
            canonical vocabulary. Empty `statuses` = the device reports
            all-clear; null = this meter never reported status.
            History (including raw provider flags): `/statusReports`.
          properties:
            statuses:
              type: array
              items: { $ref: "#/components/schemas/MeterStatusCode" }
            reported_at: { type: string, format: date-time }

    RawReading:
      type: object
      properties:
        meter_id: { type: integer }
        meter_identifier: { type: string }
        kind: { type: string, enum: [consumption, saldo] }
        value: { type: number, description: Raw value exactly as reported }
        unit: { type: string, nullable: true }
        canonical_value:
          type: number
          nullable: true
          description: Value in the family's canonical unit (#29); null = unit mismatch or not backfilled
        canonical_unit: { type: string, nullable: true, enum: [kwh, m3, null] }
        quality_flags:
          type: array
          nullable: true
          description: Data quality flags (#29, see ADR 0007); null = clean
          items:
            type: string
            enum: [unit_mismatch, negative_consumption, implausible_rate, saldo_mismatch, saldo_decrease, rollover_wrap, meter_exchange]
        recorded_at: { type: string, format: date-time }
        recorded_at_local:
          type: string
          description: Only present when tz was given — recorded_at in that zone

    DerivedConsumptionRow:
      type: object
      description: One interval between consecutive saldo readings (derive=consumption, #29)
      properties:
        period_start: { type: string, format: date-time }
        period_end: { type: string, format: date-time }
        consumption: { type: number, nullable: true, description: Canonical units; null = unknown interval }
        unit: { type: string, nullable: true, enum: [kwh, m3, null] }
        gap: { type: boolean, description: Interval exceeds the meter's expected cadence }
        rollover: { type: boolean, description: Delta bridges a counter rollover }
        exchange: { type: boolean, description: Delta bridges a recorded meter exchange }
        unknown: { type: boolean, description: True when consumption could not be derived }
        interpolated: { type: boolean, description: Row was synthesized by interpolate=linear }

    AggregatedBucket:
      type: object
      properties:
        bucket_start:
          type: string
          description: Bucket start, "YYYY-MM-DD HH:MM:SS" in UTC
        meter_id: { type: integer }
        kind: { type: string, enum: [consumption, saldo] }
        reading_count: { type: integer }
        sum: { type: number }
        min: { type: number }
        max: { type: number }
        avg: { type: number }

    MeterStatusCode:
      type: string
      description: >
        Canonical device-status vocabulary (#45): every device profile
        maps its raw flags onto this set. Raw flags a profile cannot
        interpret map to `error` (surfaced, never dropped).
      enum: [leak, burst, dry, reverse, tamper, low_battery, error]

    StatusReport:
      type: object
      description: One device-reported status of a meter (#45)
      properties:
        meter_id: { type: integer }
        meter_identifier: { type: string }
        raw_flags:
          type: array
          description: Provider flags verbatim, exactly as reported
          items: { type: string }
        statuses:
          type: array
          description: Canonical codes; empty = device reported all-clear
          items: { $ref: "#/components/schemas/MeterStatusCode" }
        reported_at: { type: string, format: date-time }

    PageMeta:
      type: object
      properties:
        current_page: { type: integer }
        per_page: { type: integer }
        total: { type: integer }
        last_page: { type: integer }

    ProfilePayloadReport:
      type: object
      description: >
        Report item for a meter assigned a non-generic device profile
        (#41/#42). The `payload` object must match the profile's schema
        exactly — required fields present, no unknown fields, types per
        field. `unit` is required whenever the profile carries a
        unit-bearing measurement (e.g. the IZAR register reading);
        pulse-counter reports take their unit from the meter's
        pulse-weight configuration instead (#44) and omit `unit`.
        Must not carry `kind`/`value` — the profile defines the
        meaning.
      required: [meter, payload, recorded_at]
      properties:
        meter:
          type: string
          maxLength: 255
          description: Device identifier, unique within the tenant
        profile:
          type: string
          example: izar
          description: >
            Optional explicit model declaration (catalog key). Unknown
            meters are provisioned onto this profile; unconfirmed
            provisioning defaults are upgraded to it; a mismatch with a
            user-confirmed assignment is rejected with 422.
        payload:
          type: object
          description: "Profile-schema fields, e.g. IZAR: value (register reading, number) + status (list of flag strings)"
          additionalProperties: true
        unit:
          type: string
          enum: [kwh, wh, m3, l]
          description: Unit of the payload's measurement fields
        recorded_at:
          type: string
          format: date-time
          description: >
            Device-side measurement time (ISO 8601). Must not lie more
            than 5 minutes in the future.

    ReadingReport:
      type: object
      description: >
        Plain report item — the format of the *generic* device profile
        (meters without an assigned model). Unchanged since v1.0.
      required: [meter, kind, value, unit, recorded_at]
      properties:
        meter:
          type: string
          maxLength: 255
          description: Device identifier, unique within the tenant
        profile:
          type: string
          example: generic
          description: Optional explicit model declaration (catalog key)
        kind:
          type: string
          enum: [consumption, saldo]
          description: >
            `consumption` = incremental delta since the previous report;
            `saldo` = absolute register/balance value.
        value:
          type: number
          minimum: -1000000000
          maximum: 1000000000
        unit:
          type: string
          enum: [kwh, wh, m3, l]
        recorded_at:
          type: string
          format: date-time
          description: >
            Device-side measurement time (ISO 8601). Must not lie more
            than 5 minutes in the future.

    ReportDataResult:
      type: object
      properties:
        accepted: { type: integer, description: newly stored readings }
        duplicates: { type: integer, description: "retried reports, ignored" }
        rejected: { type: integer, description: "items refused per item: provisioning cap or unconfigured meter" }
        results:
          type: array
          items:
            type: object
            properties:
              meter: { type: string }
              kind: { type: string, enum: [consumption, saldo] }
              recorded_at: { type: string, format: date-time }
              status:
                type: string
                enum: [created, duplicate, rejected_provision_limit, rejected_plan_limit, rejected_unconfigured]
                description: >
                  rejected_unconfigured (#44): the meter cannot convert
                  this item yet (pulse report without a configured
                  pulse weight); configure the meter and resend.
                  rejected_provision_limit (#13): too many NEW meters
                  this hour; existing meters are unaffected, retry later.
                  rejected_plan_limit (#32): the workspace is at its
                  plan's meter limit; an owner has to upgrade. Both
                  reject only the creation of a new meter — readings for
                  meters that already exist always ingest.
