openapi: 3.1.0
info:
  title: Flux API
  version: 0.1.0
  description: >
    Backend service for Tesla Powerwall monitoring and automation. Owns Tesla
    Fleet API OAuth, polls energy data on a schedule, stores history in Neon
    Postgres, and runs scheduled automations. This contract is consumed by the
    Flux iOS app and the `flux` Go CLI. Follows Google AIP conventions:
    resource-oriented URLs, standard methods (List/Get/Create/Update/Delete),
    and a standard error envelope. No pagination by default — list methods
    return all matching resources.
  contact:
    name: Flux
servers:
  - url: https://fluxpower.app
    description: Production (custom domain, canonical once live)
  - url: https://fluxpower.vercel.app
    description: Production (Vercel)
  - url: http://localhost:3000
    description: Local development
security:
  - bearerAuth: []
tags:
  - name: sites
  - name: snapshots
  - name: days
  - name: forecast
  - name: automations
  - name: events
  - name: meta
paths:
  /api/health:
    get:
      operationId: getHealth
      tags: [meta]
      summary: Service health
      security: []
      responses:
        "200":
          description: Service healthy (database ok or unconfigured).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"
        "503":
          description: Service degraded (database error).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"

  /api/openapi:
    get:
      operationId: getOpenapi
      tags: [meta]
      summary: This OpenAPI document (YAML)
      security: []
      responses:
        "200":
          description: OpenAPI 3.1 document.
          content:
            application/yaml:
              schema:
                type: string

  /api/sites:
    get:
      operationId: listSites
      tags: [sites]
      summary: List energy sites
      responses:
        "200":
          description: All energy sites.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sites:
                    type: array
                    items:
                      $ref: "#/components/schemas/Site"
                required: [sites]
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/sites/{siteId}:
    get:
      operationId: getSite
      tags: [sites]
      summary: Get an energy site, including its latest live snapshot
      parameters:
        - $ref: "#/components/parameters/SiteId"
      responses:
        "200":
          description: The energy site.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Site"
                  - type: object
                    properties:
                      latestSnapshot:
                        oneOf:
                          - $ref: "#/components/schemas/PowerSnapshot"
                          - type: "null"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateSite
      tags: [sites]
      summary: Update a site's poll interval
      description: >
        Partial update. The only mutable field is `pollIntervalMinutes` — the
        per-site live-poll cadence (5-60, a multiple of 5). Set it to null to
        clear the override and fall back to the server default.
      parameters:
        - $ref: "#/components/parameters/SiteId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SitePatch"
      responses:
        "200":
          description: The updated site.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Site"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/sites/{siteId}/snapshots:
    get:
      operationId: listSnapshots
      tags: [snapshots]
      summary: List power snapshots for a site
      parameters:
        - $ref: "#/components/parameters/SiteId"
        - name: start
          in: query
          description: Inclusive lower bound (RFC3339). Defaults to 24h ago.
          schema:
            type: string
            format: date-time
        - name: end
          in: query
          description: Inclusive upper bound (RFC3339). Defaults to now.
          schema:
            type: string
            format: date-time
        - name: resolution
          in: query
          description: >
            Downsampling bucket. "raw" returns every 5-min sample; other values
            average into fixed buckets.
          schema:
            type: string
            enum: [raw, 15m, 1h, 1d]
            default: raw
      responses:
        "200":
          description: Matching snapshots ordered by ts ascending.
          content:
            application/json:
              schema:
                type: object
                properties:
                  snapshots:
                    type: array
                    items:
                      $ref: "#/components/schemas/PowerSnapshot"
                required: [snapshots]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/sites/{siteId}/days:
    get:
      operationId: listDays
      tags: [days]
      summary: List daily energy rollups for a site
      parameters:
        - $ref: "#/components/parameters/SiteId"
        - name: start
          in: query
          description: Inclusive first day (YYYY-MM-DD). Defaults to 30 days ago.
          schema:
            type: string
            format: date
        - name: end
          in: query
          description: Inclusive last day (YYYY-MM-DD). Defaults to today.
          schema:
            type: string
            format: date
      responses:
        "200":
          description: Matching daily rollups ordered by day ascending.
          content:
            application/json:
              schema:
                type: object
                properties:
                  days:
                    type: array
                    items:
                      $ref: "#/components/schemas/EnergyDay"
                required: [days]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/sites/{siteId}/forecast:
    get:
      operationId: getForecast
      tags: [forecast]
      summary: Solar production forecast vs actuals for a site
      parameters:
        - $ref: "#/components/parameters/SiteId"
        - name: start
          in: query
          description: Inclusive first day (YYYY-MM-DD). Defaults to today.
          schema:
            type: string
            format: date
        - name: end
          in: query
          description: Inclusive last day (YYYY-MM-DD). Defaults to +2 days.
          schema:
            type: string
            format: date
      responses:
        "200":
          description: Forecast entries with actuals when available.
          content:
            application/json:
              schema:
                type: object
                properties:
                  forecasts:
                    type: array
                    items:
                      $ref: "#/components/schemas/SolarForecast"
                required: [forecasts]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/automations:
    get:
      operationId: listAutomations
      tags: [automations]
      summary: List automations
      parameters:
        - name: siteId
          in: query
          description: Filter by site.
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: All automations (optionally filtered by site).
          content:
            application/json:
              schema:
                type: object
                properties:
                  automations:
                    type: array
                    items:
                      $ref: "#/components/schemas/Automation"
                required: [automations]
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createAutomation
      tags: [automations]
      summary: Create an automation
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AutomationCreate"
      responses:
        "201":
          description: The created automation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Automation"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/automations/{automationId}:
    get:
      operationId: getAutomation
      tags: [automations]
      summary: Get an automation
      parameters:
        - $ref: "#/components/parameters/AutomationId"
      responses:
        "200":
          description: The automation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Automation"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateAutomation
      tags: [automations]
      summary: Update an automation (partial)
      description: >
        Partial update. Only fields present in the body are modified, following
        AIP-134 field-mask-by-presence semantics.
      parameters:
        - $ref: "#/components/parameters/AutomationId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AutomationUpdate"
      responses:
        "200":
          description: The updated automation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Automation"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: deleteAutomation
      tags: [automations]
      summary: Delete an automation
      parameters:
        - $ref: "#/components/parameters/AutomationId"
      responses:
        "204":
          description: Deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/automations/{automationId}/runs:
    get:
      operationId: listAutomationRuns
      tags: [automations]
      summary: List execution history for an automation
      parameters:
        - $ref: "#/components/parameters/AutomationId"
      responses:
        "200":
          description: Runs ordered by ts descending.
          content:
            application/json:
              schema:
                type: object
                properties:
                  runs:
                    type: array
                    items:
                      $ref: "#/components/schemas/AutomationRun"
                required: [runs]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/events:
    get:
      operationId: listEvents
      tags: [events]
      summary: List events
      parameters:
        - name: siteId
          in: query
          schema:
            type: string
            format: uuid
        - name: type
          in: query
          schema:
            type: string
        - name: start
          in: query
          schema:
            type: string
            format: date-time
        - name: end
          in: query
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: Matching events ordered by ts descending.
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items:
                      $ref: "#/components/schemas/Event"
                required: [events]
        "401":
          $ref: "#/components/responses/Unauthorized"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Single-user API token supplied via the API_TOKEN env var.
  parameters:
    SiteId:
      name: siteId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    AutomationId:
      name: automationId
      in: path
      required: true
      schema:
        type: string
        format: uuid
  responses:
    Unauthorized:
      description: Missing or invalid bearer token.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    BadRequest:
      description: Invalid request body or parameters.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: integer
            status:
              type: string
              description: Canonical status, e.g. UNAUTHENTICATED, NOT_FOUND.
            message:
              type: string
          required: [code, status, message]
      required: [error]
    Health:
      type: object
      properties:
        status:
          type: string
          enum: [ok, degraded]
        version:
          type: string
        checks:
          type: object
          properties:
            database:
              type: string
              enum: [ok, unconfigured, error]
          required: [database]
        timestamp:
          type: string
          format: date-time
      required: [status, version, checks, timestamp]
    Site:
      type: object
      properties:
        id:
          type: string
          format: uuid
        energySiteId:
          type: string
        siteName:
          type: [string, "null"]
        installDate:
          type: [string, "null"]
          format: date-time
        timezone:
          type: [string, "null"]
        solarPowerKw:
          type: [number, "null"]
          description: Nameplate solar array size (unit kW).
        batteryCapacityWh:
          type: [number, "null"]
          description: >
            Nameplate battery energy capacity (unit Wh), from Tesla
            total_pack_energy (live_status). Null until first observed by the pollers.
        pollIntervalMinutes:
          type: [integer, "null"]
          description: >
            Per-site live-poll cadence in minutes (5-60, a multiple of 5). Null
            falls back to the server default (POLL_INTERVAL_MINUTES, then 5).
        batteryCount:
          type: [integer, "null"]
        solarCount:
          type: [integer, "null"]
        latitude:
          type: [number, "null"]
        longitude:
          type: [number, "null"]
        arrayAzimuth:
          type: [number, "null"]
        arrayTilt:
          type: [number, "null"]
        arrayKwp:
          type: [number, "null"]
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
      required: [id, energySiteId, createdAt, updatedAt]
    SitePatch:
      type: object
      description: Partial update for a site. Only pollIntervalMinutes is mutable.
      properties:
        pollIntervalMinutes:
          type: [integer, "null"]
          minimum: 5
          maximum: 60
          multipleOf: 5
          description: >
            Live-poll cadence in minutes (5-60, multiple of 5). Null clears the
            per-site override.
      required: [pollIntervalMinutes]
      additionalProperties: false
    PowerSnapshot:
      type: object
      properties:
        id:
          type: string
          format: uuid
        siteId:
          type: string
          format: uuid
        ts:
          type: string
          format: date-time
        solarPower:
          type: [number, "null"]
          description: Solar production (unit W).
        batteryPower:
          type: [number, "null"]
          description: Battery power (unit W). Positive = discharging, negative = charging.
        gridPower:
          type: [number, "null"]
          description: Grid power (unit W). Positive = importing, negative = exporting.
        loadPower:
          type: [number, "null"]
          description: Home load (unit W).
        percentageCharged:
          type: [number, "null"]
          description: Battery state of charge, 0-100 (unit percent).
        gridStatus:
          type: [string, "null"]
          enum: [active, inactive, unknown, null]
          description: >
            Normalized grid connection state. Tesla's "Active" maps to active,
            "Inactive"/"Islanded"/off-grid to inactive, anything else to unknown.
            Downsampled buckets report null.
        stormModeActive:
          type: [boolean, "null"]
      required: [id, siteId, ts]
    EnergyDay:
      type: object
      properties:
        id:
          type: string
          format: uuid
        siteId:
          type: string
          format: uuid
        day:
          type: string
          format: date
        solarEnergy:
          type: [number, "null"]
          description: Solar generated (unit Wh).
        homeUsage:
          type: [number, "null"]
          description: Home consumption (unit Wh).
        batteryCharged:
          type: [number, "null"]
          description: Energy charged into the battery (unit Wh).
        batteryDischarged:
          type: [number, "null"]
          description: Energy discharged from the battery (unit Wh).
        gridImported:
          type: [number, "null"]
          description: Energy imported from the grid (unit Wh).
        gridExported:
          type: [number, "null"]
          description: Energy exported to the grid (unit Wh).
        selfPoweredPct:
          type: [number, "null"]
          description: Share of home usage met without the grid, 0-100 (unit percent).
      required: [id, siteId, day]
    SolarForecast:
      type: object
      properties:
        id:
          type: string
          format: uuid
        siteId:
          type: string
          format: uuid
        day:
          type: string
          format: date
        estimatedEnergy:
          type: [number, "null"]
          description: Predicted solar energy for the day (unit Wh).
        actualEnergy:
          type: [number, "null"]
          description: Actual solar generated (from energy_days), when available (unit Wh).
        hourlyWatts:
          type: [object, "null"]
          description: >
            Predicted power by hour of day. Keys are hour-of-day indices "0".."23"
            (site timezone); values are predicted watts (unit W).
          additionalProperties:
            type: number
        fetchedAt:
          type: string
          format: date-time
      required: [id, siteId, day, fetchedAt]
    Automation:
      type: object
      properties:
        id:
          type: string
          format: uuid
        siteId:
          type: string
          format: uuid
        name:
          type: string
        type:
          type: string
          enum: [backup_reserve, operation_mode, notification]
        config:
          $ref: "#/components/schemas/AutomationConfig"
        cronExpr:
          type: string
          description: Standard 5-field cron expression, evaluated in site timezone.
        daysOfWeek:
          type: [array, "null"]
          items:
            type: integer
            minimum: 0
            maximum: 6
        startDate:
          type: [string, "null"]
          format: date
        endDate:
          type: [string, "null"]
          format: date
        timeRange:
          oneOf:
            - $ref: "#/components/schemas/TimeRange"
            - type: "null"
        enabled:
          type: boolean
        lastRunAt:
          type: [string, "null"]
          format: date-time
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
      required: [id, siteId, name, type, config, cronExpr, enabled, createdAt, updatedAt]
    TimeRange:
      type: object
      description: >
        Active time-of-day window in the site timezone. The automation only fires
        when the scheduled tick falls within [from, until].
      properties:
        from:
          type: string
          description: Inclusive window start, "HH:mm" 24-hour.
          pattern: "^([01][0-9]|2[0-3]):[0-5][0-9]$"
        until:
          type: string
          description: Inclusive window end, "HH:mm" 24-hour.
          pattern: "^([01][0-9]|2[0-3]):[0-5][0-9]$"
      required: [from, until]
      additionalProperties: false
    AutomationConfig:
      description: >
        Type-specific settings, validated against `type`. backup_reserve:
        { reservePercent }. operation_mode: { mode } (required). notification:
        { eventTypes } (non-empty array).
      type: object
      properties:
        reservePercent:
          type: integer
          minimum: 0
          maximum: 100
          description: Backup reserve percent, 0-100 (backup_reserve).
        mode:
          type: string
          enum: [self_consumption, autonomous, backup]
          description: Tesla operation mode (operation_mode).
        eventTypes:
          type: array
          minItems: 1
          items:
            type: string
            enum: [grid_outage, battery_full, battery_low, automation_failed]
          description: Event types this notification automation opts into (notification).
      additionalProperties: false
    AutomationCreate:
      type: object
      properties:
        siteId:
          type: string
          format: uuid
          description: Target site. Optional; defaults to the sole site (400 if 0 or >1 exist).
        name:
          type: string
        type:
          type: string
          enum: [backup_reserve, operation_mode, notification]
        config:
          $ref: "#/components/schemas/AutomationConfig"
        cronExpr:
          type: string
        daysOfWeek:
          type: [array, "null"]
          items:
            type: integer
            minimum: 0
            maximum: 6
        startDate:
          type: [string, "null"]
          format: date
        endDate:
          type: [string, "null"]
          format: date
        timeRange:
          oneOf:
            - $ref: "#/components/schemas/TimeRange"
            - type: "null"
        enabled:
          type: boolean
          default: true
      required: [name, type, config, cronExpr]
    AutomationUpdate:
      type: object
      description: >
        Partial update; only present fields are changed. `type` is immutable —
        a body that changes it is rejected with 400.
      properties:
        name:
          type: string
        config:
          $ref: "#/components/schemas/AutomationConfig"
        cronExpr:
          type: string
        daysOfWeek:
          type: [array, "null"]
          items:
            type: integer
            minimum: 0
            maximum: 6
        startDate:
          type: [string, "null"]
          format: date
        endDate:
          type: [string, "null"]
          format: date
        timeRange:
          oneOf:
            - $ref: "#/components/schemas/TimeRange"
            - type: "null"
        enabled:
          type: boolean
    AutomationRun:
      type: object
      properties:
        id:
          type: string
          format: uuid
        automationId:
          type: string
          format: uuid
        ts:
          type: string
          format: date-time
        status:
          type: string
          enum: [success, error, skipped]
        actionTaken:
          type: [string, "null"]
        teslaResult:
          type: [object, "null"]
          additionalProperties: true
        errorMessage:
          type: [string, "null"]
      required: [id, automationId, ts, status]
    Event:
      type: object
      properties:
        id:
          type: string
          format: uuid
        siteId:
          type: string
          format: uuid
        ts:
          type: string
          format: date-time
        type:
          type: string
        severity:
          type: string
          enum: [info, warning, critical]
        message:
          type: string
        payload:
          type: [object, "null"]
          additionalProperties: true
        dispatchStatus:
          type: string
          enum: [pending, sent, failed]
        dispatchedAt:
          type: [string, "null"]
          format: date-time
      required: [id, siteId, ts, type, severity, message, dispatchStatus]
