openapi: 3.1.0
info:
  title: LightBurn / MillMage REST API
  version: 1.0.0
  description: |
    REST API for LightBurn and MillMage. Provides file upload and import,
    read-only project and machine-state queries, cut-setting edits, and
    real-time state streaming via Server-Sent Events.

    ## Authentication

    All endpoints except `POST /api/connect` and `GET /`
    require a Bearer token derived from a shared secret using HMAC-SHA256:

    ```
    token = hex(HMAC-SHA256(secret, floor(unix_time / 60)))
    ```

    The server accepts tokens for the current minute and the previous minute
    to handle clock drift.

    ## Obtaining a Secret

    Obtain a shared secret by pairing a local application:

    - **Local application pairing (`POST /api/connect`)**: For localhost
      applications only. Sends an `application_name` in the request body. The
      application user is shown a consent dialog every time; if approved, a new
      secret is generated and returned. The dialog has a 30-second timeout
      after which the connection is automatically declined. Applications are
      responsible for persisting their own secret. Each call produces a
      distinct secret, even for the same `application_name`.

    ## Multiple instances

    The port is served by one running copy of the application at a time. If
    several copies are open, the one most recently brought to the foreground
    serves it; switching windows moves the listener, and a request made during
    the switch may fail to connect. Retry.

    A secret is recognised by the copy that was running when you paired, and
    may not be recognised by another copy until that one reloads its settings.
    If a previously working secret starts returning `401` on a machine with
    several copies open, pair again rather than assuming it was revoked.

    Neither applies to a single running instance, which serves the port for as
    long as it is open.

servers:
  - url: http://localhost:19520
    description: |
      LightBurn. Port 19520, fixed per product — not user-overridable. Binds
      loopback by default; enable "Allow API Access From Network" in
      Settings > Extensions to accept connections on any interface.
      `POST /api/connect` stays localhost-only either way.
      The API listens while LightBurn is open. With more than one copy of
      LightBurn running, the port is served by the most recently focused
      window — see the Multiple instances note in the description above.
  - url: http://localhost:19521
    description: |
      MillMage. Port 19521, fixed per product — not user-overridable. Binds
      loopback by default; enable "Allow API Access From Network" in
      Settings > Extensions to accept connections on any interface.
      `POST /api/connect` stays localhost-only either way.
      The API listens while MillMage is open. With more than one copy of
      MillMage running, the port is served by the most recently focused
      window — see the Multiple instances note in the description above.

security:
  - bearerAuth: []

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: HMAC-SHA256 time-based token derived from shared secret

  schemas:
    ErrorResponse:
      type: object
      properties:
        error:
          type: string
          example: Missing 'axis' parameter

    Position:
      type: object
      description: |
        Always in mm, regardless of display units. An axis the controller
        doesn't report is omitted.
      properties:
        machine:
          type: object
          properties:
            x: { type: number, example: 100.000 }
            y: { type: number, example: 50.000 }
            z: { type: number, example: -5.000 }
        workpiece:
          type: object
          properties:
            x: { type: number, example: 0.000 }
            y: { type: number, example: 0.000 }
            z: { type: number, example: -5.000 }

    ConnectionStatus:
      type: object
      properties:
        connected:
          type: boolean
          description: >
            Whether a device link is open at this moment. On Ruida and some
            other non-GCode controllers the link is brought up just in time
            and closed again afterwards, so this is `false` whenever no
            transfer is in progress — which does not mean the laser is absent
            or unusable. Not a presence or health check; expect it to toggle
            around activity. How long a link is held open varies by controller
            and by how the device is connected.
        device_name: { type: string, example: Shapeoko 5 Pro }
        supports_z: { type: boolean }
        supports_overrides: { type: boolean }

    JogSettings:
      type: object
      properties:
        xy_distance: { type: number, example: 10.0 }
        z_distance: { type: number, example: 1.0 }
        xy_speed: { type: number, example: 1500.0 }
        z_speed: { type: number, example: 250.0 }
        units:
          type: string
          enum: [mm/s, mm/min, in/s, in/min]
          description: >
            The speed unit. Distances use `distance_units` — do not infer them
            from this field.
        distance_units:
          type: string
          enum: [mm, in]
          description: >
            The distance unit. Reported separately because the application
            supports mixed modes that pair inch distances with metric speeds.

    SavedPosition:
      type: object
      properties:
        index: { type: integer }
        name: { type: string }
        x: { type: number }
        y: { type: number }
        z: { type: number }
        include_z: { type: boolean }
        workpiece: { type: boolean }

    JobState:
      type: object
      properties:
        state:
          type: string
          enum: [idle, running, paused]
          description: >
            Whether the application is running a job on the controller. On
            Ruida controllers this is always `idle` — while the job file is
            being sent and while the machine cuts — because the controller
            runs the job on its own once it has the file, and the application
            does not track it. On Ruida, `idle` does not mean the machine is
            free; `device.laser.controller` on `GET /api/project` identifies
            the controller.
        progress:
          type: number
          minimum: 0
          maximum: 100
          description: >
            Percent complete. Optional — absent when not applicable: while
            `state` is `idle`, and on controllers that cannot report progress.
            Never present on Ruida controllers, whose `state` is always
            `idle`. Treat absence as "unknown", not as zero.

    Overrides:
      type: object
      properties:
        speed_percent: { type: integer, example: 100 }
        power_percent: { type: integer, example: 100 }

    AuxState:
      type: object
      properties:
        spindle_on: { type: boolean }
        spindle_rpm: { type: integer }
        coolant_on: { type: boolean }
        vacuum_on: { type: boolean }

    PollSnapshot:
      type: object
      properties:
        position: { $ref: '#/components/schemas/Position' }
        job: { $ref: '#/components/schemas/JobState' }
        overrides: { $ref: '#/components/schemas/Overrides' }
        aux: { $ref: '#/components/schemas/AuxState' }
        connection: { $ref: '#/components/schemas/ConnectionStatus' }
        settings: { $ref: '#/components/schemas/JogSettings' }

    Error:
      $ref: '#/components/schemas/ErrorResponse'

    Point2:
      type: object
      properties:
        x: { type: number }
        y: { type: number }

    LaserCut:
      type: object
      required: [index, name, kind, layer_index, enabled, priority, profile, params]
      properties:
        index: { type: integer }
        name: { type: string }
        kind: { type: string, enum: [normal, image] }
        layer_index: { type: integer }
        enabled: { type: boolean }
        priority: { type: integer }
        in_use:
          type: boolean
          description: >
            True if any shape in the project uses this cut setting. Counted per
            cut setting across every page, so a normal and an image cut sharing
            a layer are reported separately — which the per-layer count in
            `GET /api/layers` cannot do.
        shape_count:
          type: integer
          description: Number of shapes using this cut setting.
        profile:
          type: string
          enum: [galvo, gantry, unknown]
          description: >
            Which machine the settings should be read as. On `kind: "normal"`
            cuts it therefore says which of `params.galvo` / `params.gantry`
            is present; `kind: "image"` cuts carry a `profile` too but never a
            machine block. Derived from the
            selected device profile, so it is correct with no machine
            physically connected. `unknown` means no device profile has been
            set up yet. Switching the active device changes this, and the block
            that comes with it, for an unchanged project — do not cache the
            response shape.
        global_passes:
          type: integer
          minimum: 1
          description: >
            Galvo only; absent otherwise. Repeats the entire sub-layer stack,
            so the real number of passes is `global_passes` multiplied by the
            per-layer `num_passes`.
        negative: { type: boolean }
        pass_through: { type: boolean }
        enable_cleanup: { type: boolean }
        sort_within_layer: { type: boolean }
        tabs:
          type: object
          description: Tab (bridge) settings for this cut.
          properties:
            enabled: { type: boolean }
            manual: { type: boolean, description: Manual placement rather than automatic. }
            size: { type: number }
            use_spacing: { type: boolean, description: "Use `spacing`; otherwise `count`." }
            count: { type: integer, description: "-1 when unset." }
            count_max: { type: integer, description: "-1 when unset." }
            limit_max: { type: boolean }
            spacing: { type: number }
            skip_inner: { type: boolean }
            cut_power: { type: number }
        mode:
          type: string
          enum: [cut, scan, offset, image, tool]
          description: Only present when `kind == "normal"`.
        params:
          description: Shape follows `kind`.
          anyOf:
            - $ref: '#/components/schemas/LaserCutParams'
            - type: object
              description: |
                Image cut params (when `kind == "image"`), plus the source and
                pulse fields of `LaserCutParams` except `min_power_2`.
              properties:
                speed: { type: number }
                max_power: { type: number }
                dpi: { type: number }
                dither_mode: { type: string }
                cells_per_inch: { type: number, description: Halftone cell size. }
                halftone_angle: { type: number }
                link_dpi_to_interval: { type: boolean }
                max_power_2: { type: number }
                laser1_enabled: { type: boolean }
                laser2_enabled: { type: boolean }
                alt_source: { type: boolean }
                q_pulse_width: { type: number }
                fiber_pulse_width: { type: integer }
        sub_layers:
          type: array
          description: |
            Only present when `kind == "normal"`. Additional sub-layers
            beyond the base layer; the base layer itself is reported in
            `mode` / `params` and is not repeated here. Empty when the
            layer has no sub-layers.
          items:
            type: object
            required: [index, name, enabled, mode, params]
            properties:
              index:
                type: integer
                minimum: 1
                description: Sub-layer position within the cut (the base layer is 0).
              name:
                type: string
                description: User-assigned sub-layer name; may be empty.
              enabled:
                type: boolean
                description: Whether this sub-layer is output.
              mode:
                type: string
                enum: [cut, scan, offset, image, tool]
              params:
                $ref: '#/components/schemas/LaserCutParams'

    LaserCutParams:
      type: object
      description: |
        Vector cut params, shared by a cut's base layer and its sub-layers.

        Exactly one machine-specific block is present — `galvo` or `gantry`,
        matching the entry's `profile`. The other machine's values remain
        stored in the project but are not reported. When `profile` is
        `unknown`, neither block is present.
      properties:
        speed: { type: number }
        min_power: { type: number }
        max_power: { type: number }
        min_power_2: { type: number, description: Min power for laser 2 (dual-source). }
        max_power_2: { type: number, description: Max power for laser 2 (dual-source). }
        default_speed: { type: boolean, description: Use the device default speed. }
        default_power1: { type: boolean }
        default_power2: { type: boolean }
        laser1_enabled: { type: boolean, description: Laser 1 fires on this layer (dual-source). }
        laser2_enabled: { type: boolean, description: Laser 2 fires on this layer (dual-source). }
        alt_source:
          type: boolean
          description: Galvo only — use the alternate (second) laser source.
        num_passes: { type: integer, minimum: 1 }
        z_offset: { type: number }
        z_per_pass: { type: number, description: Z movement applied per pass. }
        u_offset: { type: number, description: For machines with a U axis. }
        angle_per_pass: { type: number, description: Scan angle rotation per pass, in degrees. }
        kerf: { type: number }
        frequency:
          type: integer
          description: Hz. Also the galvo pulse frequency when the device supports it.
        override_frequency:
          type: boolean
          description: >
            Whether `frequency` is applied at all. When false the device
            default is used and the reported `frequency` has no effect — set
            this alongside `frequency` when writing.
        q_pulse_width:
          type: number
          description: MOPA pulse width in ns (when `device.laser.mopa_source` is not 0).
        fiber_pulse_width:
          type: integer
          description: Fiber pulse width in ns, for gantry fiber sources (`laser1_fiber` / `laser2_fiber`).
        bi_directional: { type: boolean }
        cross_hatch: { type: boolean }
        flood_fill: { type: boolean }
        auto_rotate: { type: boolean, description: Continuous rotation between passes. }
        scan_opt:
          type: string
          enum: [individual, by_group, merge_all]
          description: How shapes are grouped when scanning.
        angle: { type: number, description: Scan angle in degrees. }
        interval: { type: number, description: Scan line interval. }
        dot_width: { type: number }
        use_dot_correction: { type: boolean }
        ramp_length: { type: number }
        ramp_outer: { type: boolean }
        perforate: { type: boolean }
        perf_len: { type: number }
        perf_skip: { type: number }
        tabs_enabled: { type: boolean }
        overscan: { type: boolean }
        overscan_percent: { type: number }
        galvo:
          $ref: '#/components/schemas/LaserGalvoParams'
        gantry:
          $ref: '#/components/schemas/LaserGantryParams'

    LaserGalvoParams:
      type: object
      description: >
        Present only when the entry's `profile` is `galvo`. `start_delay` and
        `end_delay` are dot delays on a galvo; the same names mean pauses in
        `LaserGantryParams`.
      properties:
        override_timings:
          type: boolean
          description: Gate for the timing constants below.
        laser_on_tc: { type: integer, description: Laser-on time constant, microseconds. }
        laser_off_tc: { type: integer, description: Laser-off time constant, microseconds. }
        end_tc: { type: integer, description: End time constant, microseconds. }
        polygon_tc: { type: integer, description: Polygon time constant, microseconds. }
        jump_speed: { type: number }
        min_jump_delay: { type: integer, description: Microseconds. }
        max_jump_delay: { type: integer, description: Microseconds. }
        jump_dist_limit: { type: number }
        start_delay: { type: number, description: Dot delay at path start. }
        end_delay: { type: number, description: Dot delay at path end. }
        wobble_enable: { type: boolean }
        wobble_step: { type: number }
        wobble_size: { type: number }

    LaserGantryParams:
      type: object
      description: >
        Present only when the entry's `profile` is `gantry`. `start_delay` and
        `end_delay` are pauses on a gantry; the same names mean dot delays in
        `LaserGalvoParams`.
      properties:
        run_blower: { type: boolean, description: Air assist. }
        auto_blower: { type: boolean }
        blower_speed_override: { type: boolean }
        blower_speed_percent: { type: number }
        cut_through_start: { type: boolean }
        cut_through_end: { type: boolean }
        through_power: { type: number }
        through_power_2: { type: number }
        start_delay: { type: number, description: Pause at path start. }
        end_delay: { type: number, description: Pause at path end. }
        lead_in: { type: boolean }
        lead_out: { type: boolean }
        lead_angle: { type: number, description: Degrees. }
        lead_length: { type: number }
        lead_style: { type: string, enum: [line, arc] }
        enable_ppi: { type: boolean }
        ppi: { type: integer, description: Pulses per inch. }
        dot_mode: { type: boolean }
        dot_time: { type: number }
        dot_spacing: { type: number }
        force_constant_power: { type: boolean }
        dcc_value: { type: number }
        overcut: { type: number }

    LaserCuts:
      type: object
      required: [product, cuts]
      properties:
        product: { type: string, enum: [lightburn] }
        cuts:
          type: array
          items:
            $ref: '#/components/schemas/LaserCut'

    CncCut:
      type: object
      description: |
        One MillMage operation. Operations attach to shapes, not layers, so
        there is no `layer_index`. There is no `priority` either: operations
        run in list order, which `index` already gives.
      required: [index, name, kind, shape_count, enabled, operation, tool]
      properties:
        index:
          type: integer
          description: Position in the project's operation list (use with `/api/cuts/{index}`).
        name: { type: string }
        kind: { type: string, enum: [normal] }
        shape_count:
          type: integer
          description: Number of shapes the operation is assigned to; 0 means unused.
        enabled: { type: boolean }
        operation:
          type: string
          enum:
            - profile
            - pocket
            - rest_pocket
            - drill
            - dogbone_slot
            - chamfer_profile
            - inlay_female
            - inlay_male
            - flute
            - surfacing
            - face
            - bore
            - relief
            - roughing
            - texturing
            - vcarve
            - vcarve_clear
            - image
            - invalid
        tool:
          type: object
          properties:
            name: { type: string }
            diameter: { type: number }
            feed_rate: { type: number }
            plunge_rate: { type: number }
            spindle_rpm: { type: number }
        settings:
          type: object
          description: |
            The operation's stored properties, keyed by schema name; the set
            depends on the operation type (e.g. `passDepth`, `feedRate`,
            `stepOver`, `vacuum`, `coolant`). Distances in mm, speeds in
            mm/s. Enum properties are names, e.g. `cutDirection: "climb"`.
            Lists every stored property, including some the operation
            dialog doesn't show for this type.
          additionalProperties: true
          example: { passDepth: 1.5, feedRate: 25, cutDirection: climb, whichSide: outside, vacuum: false }
        shared_fields:
          type: array
          items: { type: string }
          description: |
            `settings` keys taken from the linked primary operation (e.g.
            `startDepth`, `finalDepth`). Their values are the primary's, and
            they can only be changed on the primary. Empty if not linked.

    CncCuts:
      type: object
      required: [product, cuts]
      properties:
        product: { type: string, enum: [millmage] }
        cuts:
          type: array
          items:
            $ref: '#/components/schemas/CncCut'

  responses:
    Unauthorized:
      description: Missing or invalid Bearer token
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: Unauthorized
    BadRequest:
      description: Invalid or missing parameters
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

paths:
  /:
    get:
      summary: Placeholder HTML app
      security: []
      responses:
        '200':
          description: HTML page
          content:
            text/html:
              schema:
                type: string

  /api/connect:
    post:
      summary: Request localhost application pairing
      description: |
        Allows a local application to request a shared secret for API access,
        scoped to a specific set of capabilities.

        Only accepts connections from localhost (127.0.0.1 / ::1). The user is
        shown a consent dialog listing the requesting application's name and
        the capabilities it has asked for. If they approve, a new shared
        secret is generated and returned, bound to that capability set for
        its lifetime. If they decline (or the 30-second timeout expires), the
        request is rejected with 403. Each approval produces a distinct
        secret; applications are responsible for persisting their own secret.

        Capabilities are immutable for the life of a token. To change scope,
        the application re-pairs (and the user re-approves a new dialog).

        ### Capability values

        | Wire string | Covers |
        |---|---|
        | `state` | Read-only machine and job state. `/api/status`, `/api/position`, `/api/jog/settings`, `/api/goto/saved`, `/api/units`, `/api/events`, `/api/events/poll`. |
        | `project` | Read-only project data. `/api/project`, `/api/layers`, `/api/cuts`, `/api/cuts/{index}`, `/api/material-library`. |
        | `upload`  | Submit files for import. `/api/file/upload`, `/api/file/open`. |
        | `project_write` | Change project data. `POST /api/cuts/{index}`. |
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [application_name, capabilities]
              properties:
                application_name:
                  type: string
                  description: Display name shown to the user in the consent dialog
                  example: My Automation Tool
                capabilities:
                  type: array
                  minItems: 1
                  description: |
                    Capabilities the token should grant. Must be a non-empty
                    array of known wire strings; unknown entries fail with 400.
                  items:
                    type: string
                    enum: [state, project, upload, project_write]
                  example: [state, project]
      responses:
        '200':
          description: Connection approved, secret returned
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
                  secret:
                    type: string
                    description: Shared secret for deriving Bearer tokens
                    example: aB3dE7fG9hJ1kL5m
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          description: Connection rejected by user, timed out, or not from localhost
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /api/status:
    get:
      summary: Connection status
      description: |
        When no machine is attached, `connected` and the `supports_*` flags are
        `false` and `device_name` still names the selected device profile. The
        endpoint always answers — it does not fail or return an empty body
        because nothing is connected.

        **`connected: false` does not mean the laser is unavailable.** On
        Ruida and some other non-GCode controllers the device link is opened
        just in time and closed again once the transfer finishes, so `false`
        is the normal resting state for a laser that is present, powered on
        and ready to run. It reports only whether LightBurn is actively
        exchanging data with the device at that moment.

        Treat `connected` as "a transfer is in progress", not as a presence or
        health check, and expect it to toggle around jobs and queries. Don't
        surface it to users as a fault or a disconnected machine. How long a
        link is held open varies by controller and by how the device is
        connected — a networked device may report differently from the same
        controller over USB — so don't infer a device's behaviour from this
        field. `device_name` is the reliable way to tell which device profile
        is selected.
      responses:
        '200':
          description: Current connection state and device capabilities
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConnectionStatus'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/position:
    get:
      summary: Current position snapshot
      responses:
        '200':
          description: Machine and workpiece coordinates
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Position'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/jog/settings:
    get:
      summary: Get jog distance and speed settings
      responses:
        '200':
          description: Current jog settings in display units
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JogSettings'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /api/goto/saved:
    get:
      summary: List saved positions
      responses:
        '200':
          description: Array of saved positions from device profile
          content:
            application/json:
              schema:
                type: object
                properties:
                  positions:
                    type: array
                    items:
                      $ref: '#/components/schemas/SavedPosition'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/units:
    get:
      summary: Get current display units
      description: |
        The user's display units, as two independent axes: `units` is the
        speed unit, `distance_units` the distance unit.

        They are separate because the application's control-unit modes include
        mixed combinations — inch distances with metric speeds — that a single
        string cannot express. Read `distance_units` to convert distances;
        do not infer the distance unit from `units`.
      responses:
        '200':
          description: Current units
          content:
            application/json:
              schema:
                type: object
                properties:
                  units:
                    type: string
                    enum: [mm/s, mm/min, in/s, in/min]
                  distance_units:
                    type: string
                    enum: [mm, in]
              example:
                units: mm/min
                distance_units: mm
        '401':
          $ref: '#/components/responses/Unauthorized'
  /api/file/upload:
    post:
      summary: Upload a file to import
      description: |
        Send the file as the raw request body. Include the filename in the
        `X-Filename` header. Returns 202 immediately; the import result is
        reported asynchronously via the `file_imported` SSE event.
      parameters:
        - name: X-Filename
          in: header
          schema:
            type: string
            default: upload.lbrn2
          description: Filename for the uploaded file
        - name: X-Group-Shapes
          in: header
          schema:
            type: string
            enum: ["true", "false"]
            default: "false"
          description: |
            When "true", imported shapes are kept as a group. When "false"
            (default), the application's global grouping preference applies.
        - name: X-Position-X
          in: header
          required: false
          schema: { type: number }
          description: |
            Optional X coordinate (workspace mm, workpiece origin) for the
            anchor point of the imported shapes' bounding box. Must be paired
            with `X-Position-Y`. When omitted, the import lands at the
            current view center (the default "drop point").
        - name: X-Position-Y
          in: header
          required: false
          schema: { type: number }
          description: |
            Optional Y coordinate (workspace mm, workpiece origin). Must be
            paired with `X-Position-X`.
        - name: X-Origin
          in: header
          required: false
          schema:
            type: string
            enum:
              - top-left
              - top-center
              - top-right
              - middle-left
              - center
              - middle-right
              - bottom-left
              - bottom-center
              - bottom-right
            default: center
          description: |
            Which corner of the imported shapes' bounding box should be
            anchored at (`X-Position-X`, `X-Position-Y`). Ignored unless
            both position headers are present. Defaults to `center`.
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        '202':
          description: File received, import in progress
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: accepted }
                  filename: { type: string, example: design.svg }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/file/open:
    post:
      summary: Upload a file and open it as the new project
      description: |
        Replaces the current project with the uploaded file (File → Open
        semantics). The current project is closed first; if it has unsaved
        changes the desktop shows the standard save-changes prompt before
        the open proceeds. Unlike `/api/file/upload`, no shapes are merged
        into the existing project — the project is replaced wholesale.

        Same authorization scope as `/api/file/upload` (`upload` capability).
        No position or grouping headers — irrelevant for the replace-project
        flow. Returns 202 immediately; the result is reported by the
        `file_imported` SSE event.

        **`success` is reliable only for native project files** — `.lbrn` /
        `.lbrn2` on LightBurn, `.mage` on MillMage. Opening a template
        (`.lbt`, `.mmt`) or any other format (SVG, DXF, images, …) reports
        `{"success": false}` with an `error` message even when the file opened
        correctly, so for those formats `false` does not distinguish success
        from failure. To confirm such an open, read `GET /api/project` after
        the event: a successful open leaves `filename` empty and `shape_count`
        counts the opened shapes. The `error` text is translated for display;
        don't match on it. `/api/file/upload` is not affected.
      parameters:
        - name: X-Filename
          in: header
          schema:
            type: string
            default: upload.lbrn2
          description: Filename hint; picks the loader/translator.
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        '202':
          description: File received, open in progress
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: accepted }
                  filename: { type: string, example: project.lbrn2 }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/project:
    get:
      summary: Current project metadata
      description: |
        Top-level read-only snapshot of the loaded project: filename,
        modified flag, shape count, workspace dimensions, and the connected
        device. `workspace` values are always in mm. `units` names the
        user's display units (`distance` is the translated UI label). With
        no project loaded, only `filename`, `modified` and `shape_count`
        are returned.
      responses:
        '200':
          description: Project metadata
          content:
            application/json:
              schema:
                type: object
                properties:
                  filename: { type: string }
                  modified: { type: boolean }
                  shape_count: { type: integer }
                  units:
                    type: object
                    properties:
                      distance: { type: string, example: mm }
                      speed: { type: string, example: mm/s }
                  workspace:
                    type: object
                    properties:
                      machine_size:
                        $ref: '#/components/schemas/Point2'
                      workpiece_size:
                        $ref: '#/components/schemas/Point2'
                      workpiece_offset:
                        $ref: '#/components/schemas/Point2'
                      machine_view: { type: boolean }
                  device:
                    type: object
                    properties:
                      name: { type: string }
                      supports_z: { type: boolean }
                      laser:
                        type: object
                        description: |
                          LightBurn only, and only while a machine profile is
                          loaded. Capabilities that decide which cut
                          source/pulse fields apply.
                          LightBurn doesn't classify CO2 / diode / fiber in
                          general; `controller` is given so clients can infer.
                        properties:
                          controller: { type: string, example: Ruida }
                          galvo: { type: boolean }
                          flying_galvo: { type: boolean }
                          uv: { type: boolean }
                          sources:
                            type: integer
                            description: Laser sources the controller supports (2 = dual-source).
                          sources_enabled:
                            type: integer
                            description: Sources enabled in the device settings.
                          mopa_source:
                            type: integer
                            enum: [0, 1, 2]
                            description: Which source is MOPA (0 = none); enables `q_pulse_width`.
                          laser1_fiber:
                            type: boolean
                            description: Laser 1 is a fiber source (gantry); enables `fiber_pulse_width`.
                          laser2_fiber: { type: boolean }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/layers:
    get:
      summary: All 32 layer definitions
      description: |
        Returns every layer slot (not just the ones in use). Clients that
        want to display only used layers should filter on `in_use == true`.
        Colors are `"#RRGGBB"` hex strings for direct CSS use.
      responses:
        '200':
          description: Layers snapshot
          content:
            application/json:
              schema:
                type: object
                properties:
                  active_index:
                    type: integer
                    description: Index of the currently selected layer, or -1.
                  layers:
                    type: array
                    items:
                      type: object
                      properties:
                        index: { type: integer }
                        name: { type: string }
                        color:
                          type: string
                          example: "#000000"
                          pattern: "^#[0-9a-fA-F]{6}$"
                        type:
                          type: string
                          enum: [normal, tool, none]
                        hidden: { type: boolean }
                        in_use:
                          type: boolean
                          description: True if any shape references this layer.
                        shape_count:
                          type: integer
                          description: Number of shapes referencing this layer.
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/cuts:
    get:
      summary: Cut settings for all layers
      description: |
        Product-specific response: the payload shape differs between
        LightBurn (laser cuts + image cuts) and MillMage (CNC operations).
        Inspect the `product` field to dispatch client-side.

        Image cut settings are included inline in the LightBurn response
        with `kind: "image"`; regular cuts use `kind: "normal"`. Each
        entry's `index` is a dense global 0..N-1 position across both
        kinds in the returned array — use that value with `/api/cuts/{index}`.
        The `layer_index` field is separate from `index` and maps to the
        actual layer slot on the desktop.

        The LightBurn list is fixed-size, whether or not layers are in use:
        30 cut layers, 2 tool layers (`mode: "tool"`), one internal
        virtual-array cut (`layer_index: -1`, not editable), then 30 image
        cuts. Use `/api/layers` `in_use` to find the layers in use.

        On MillMage, `cuts` is the project's operation list in order, and
        `index` is the operation's position in it.

        LightBurn values and the MillMage `tool` block use the user's
        display units; MillMage `settings` use mm and mm/s.

        Each LightBurn entry carries a `profile` of `galvo`, `gantry` or
        `unknown`. On `kind: "normal"` entries that comes with exactly one
        matching machine-specific block inside `params`; `kind: "image"`
        entries carry the `profile` but no block at all.
        The project stores both sets of settings whatever machine is
        selected, so the discriminator is what tells you which block the
        machine actually reads. It comes from the selected device profile and
        is correct with no machine connected. **Switching the active device
        changes the shape for an unchanged project — do not cache it.**

        Use each entry's `in_use` / `shape_count` to find the cut settings
        actually used by the project. Prefer these over joining `/api/layers`
        on `layer_index`: they are counted per cut setting across every page,
        so a normal and an image cut sharing a layer are reported separately,
        which the per-layer count cannot do.
      responses:
        '200':
          description: Cut settings snapshot
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/LaserCuts'
                  - $ref: '#/components/schemas/CncCuts'
                discriminator:
                  propertyName: product
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/cuts/{index}:
    get:
      summary: Single cut setting by global flat index
      description: |
        `index` is the dense 0..N-1 value taken from an entry's `index`
        field in `/api/cuts` (normal cuts first, image cuts after).
      parameters:
        - name: index
          in: path
          required: true
          schema: { type: integer, minimum: 0 }
      responses:
        '200':
          description: Single cut entry (bare, not wrapped)
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/LaserCut'
                  - $ref: '#/components/schemas/CncCut'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Cut index out of range or slot is empty
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    post:
      summary: Update one cut setting (partial)
      description: |
        Changes the cut at the global flat `index` (the same index `GET`
        uses). Only the fields present in the body change. Requires the
        `project_write` capability.

        The whole body is validated first; on any error nothing changes.
        A successful change is one undoable step in the application
        ("Change cut settings") and marks the project modified. Values use
        the same display units `GET` reports. Changes are allowed while a
        job is running; a job already sent to the machine is not affected.

        An out-of-range `index` returns `404` whether or not the editor is
        open — the index is checked first.

        Editable fields (LightBurn):
        - `kind: "normal"` cuts: `name`, `enabled`, `mode`
          (`cut` / `scan` / `offset`), `params` (any `LaserCutParams`
          field), and existing `sub_layers` addressed by `index` (`name`,
          `enabled`, `mode`, `params`). Adding or removing sub-layers is
          not supported.
        - Cut-level fields: `negative`, `pass_through`, `enable_cleanup`,
          `sort_within_layer`, `tabs`, and `global_passes` on galvo.
        - `kind: "image"` cuts: `name`, `enabled`, and `params.speed`,
          `params.max_power`, `params.dither_mode` (one of the strings
          `GET` reports, e.g. `Stucki`), plus the source and pulse fields.
          `dpi` is read-only.
        - Powers (including `*_power_2`) are 0–100; `q_pulse_width` > 0;
          `fiber_pulse_width` a positive integer.

        Machine-specific settings go in `params.galvo` or `params.gantry`,
        and must match the entry's `profile`. Sending the block for the other
        machine is a **400** rather than being ignored, so a request cannot
        quietly fail to configure what you intended. `global_passes` is
        likewise a 400 on a gantry device.

        Set `params.override_frequency` alongside `params.frequency`: with the
        override false the device default applies and the frequency you write
        has no effect.

        Tool layers and internal cuts are rejected with 400.

        Editable fields (MillMage): `name`, `enabled`, and any `settings`
        key the operation reports, with the same JSON type (enums by name).
        `shared_fields` are rejected on linked secondaries. The operation's
        type, tool and shapes can't be changed. The response `index` can
        differ from the request if a linked chain is re-sorted.
      parameters:
        - name: index
          in: path
          required: true
          schema: { type: integer, minimum: 0 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                name: { type: string, minLength: 1 }
                enabled: { type: boolean }
                mode: { type: string, enum: [cut, scan, offset] }
                in_use: { type: boolean, readOnly: true }
                shape_count: { type: integer, readOnly: true }
                profile: { type: string, readOnly: true }
                global_passes:
                  type: integer
                  minimum: 1
                  description: Galvo only; a 400 on a gantry device.
                negative: { type: boolean }
                pass_through: { type: boolean }
                enable_cleanup: { type: boolean }
                sort_within_layer: { type: boolean }
                tabs:
                  type: object
                  description: Partial; only the keys sent are changed.
                params:
                  type: object
                  description: |
                    `LaserCutParams` fields for normal cuts, including at most
                    one of `galvo` / `gantry` matching the entry's `profile`;
                    `speed`, `max_power`, `dither_mode` for image cuts.
                sub_layers:
                  type: array
                  items:
                    type: object
                    required: [index]
                    additionalProperties: false
                    properties:
                      index: { type: integer, minimum: 1 }
                      name: { type: string }
                      enabled: { type: boolean }
                      mode: { type: string, enum: [cut, scan, offset] }
                      params: { $ref: '#/components/schemas/LaserCutParams' }
                settings:
                  type: object
                  description: MillMage only — see `CncCut.settings`.
            example:
              params:
                speed: 150
                max_power: 65
                override_frequency: true
                frequency: 20000
                gantry: { run_blower: true, enable_ppi: true, ppi: 250 }
              tabs: { enabled: true, count: 4 }
              sub_layers:
                - { index: 1, enabled: false }
      responses:
        '200':
          description: Updated cut (same shape as `GET /api/cuts/{index}`)
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/LaserCut'
                  - $ref: '#/components/schemas/CncCut'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Cut index out of range
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: The cut settings / operation editor is open in the application
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /api/overlay:
    get:
      summary: Camera overlay image
      description: |
        The camera overlay currently drawn on the workspace, as a PNG.

        **LightBurn only.** MillMage returns 404.

        Returns only what is already in memory — requesting this never makes
        the application capture a new frame. If no overlay is loaded the
        response is 404 with a JSON error body (note the error content type
        differs from the `image/png` success body).

        Orientation matches the application's **File → Save Background
        Capture** output.

        Use `GET /api/overlay/metadata` first if you want the size, placement
        or revision without transferring the image.
      responses:
        '200':
          description: The overlay image
          content:
            image/png:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No camera overlay image is loaded, or not LightBurn
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /api/overlay/metadata:
    get:
      summary: Camera overlay metadata
      description: |
        Size, workspace placement and a change token for the camera overlay,
        without transferring the image. **LightBurn only.** Same 404 behaviour
        as `GET /api/overlay`, and likewise never triggers a capture.
      responses:
        '200':
          description: Overlay metadata
          content:
            application/json:
              schema:
                type: object
                properties:
                  source:
                    type: string
                    enum: [overhead, head]
                    description: Which camera layer the image came from.
                  width:
                    type: integer
                    description: Image width in pixels.
                  height:
                    type: integer
                    description: Image height in pixels.
                  bounds:
                    type: object
                    description: Placement on the workspace, in mm.
                    properties:
                      x: { type: number }
                      y: { type: number }
                      width: { type: number }
                      height: { type: number }
                  revision:
                    type: string
                    description: >
                      Opaque token that changes whenever the overlay is
                      replaced. Use it to skip re-fetching an unchanged image.
                      Not stable across application restarts — treat a
                      changed value as "refetch", never as a persistent id.
              example:
                source: overhead
                width: 1018
                height: 1074
                bounds: { x: 46.421722, y: 26.932907, width: 162.619812, height: 171.565491 }
                revision: "43284680410162"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No camera overlay image is loaded, or not LightBurn
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /api/material-library:
    get:
      summary: Loaded material / operations library
      description: |
        Returns the currently-loaded material library. Product-specific
        payload: LightBurn returns a single `CutLibrary` (the one selected
        in the library picker) as a flat list of entries; MillMage returns
        the set of loaded operations library files, each as a tree of
        folders and operations.

        Inspect the `product` field to dispatch client-side. On LightBurn,
        if no library is currently selected, `entries` is an empty array.
      responses:
        '200':
          description: Loaded library
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    description: LightBurn (laser) material library.
                    properties:
                      product: { type: string, enum: [lightburn] }
                      name: { type: string }
                      filename: { type: string }
                      entries:
                        type: array
                        items:
                          type: object
                          properties:
                            guid: { type: integer }
                            material: { type: string }
                            description: { type: string }
                            no_thick_title:
                              type: string
                              description: Title shown when `thickness` is -1.
                            thickness:
                              type: number
                              description: Material thickness; -1 for "no thickness" entries.
                            locked: { type: boolean }
                            cut:
                              allOf:
                                - $ref: '#/components/schemas/LaserCut'
                              description: >
                                Same shape as one /api/cuts entry, with one
                                exception: `index` and `layer_index` are both
                                -1. A library entry has no position in the
                                project's cut list, so neither field can be
                                used with /api/cuts/{index}.
                  - type: object
                    description: MillMage (CNC) operations library.
                    properties:
                      product: { type: string, enum: [millmage] }
                      libraries:
                        type: array
                        items:
                          type: object
                          properties:
                            name: { type: string }
                            filename: { type: string }
                            content:
                              type: object
                              description: |
                                The raw operations library file content as
                                stored on disk (operations tree + version
                                metadata).
                discriminator:
                  propertyName: product
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/events:
    get:
      summary: SSE event stream
      description: |
        Server-Sent Events stream for real-time state updates. Connect via
        `fetch()` with an `Authorization` header (the browser `EventSource`
        API does not support custom headers).

        ### Event types

        | Event | Frequency | `data` |
        |-------|-----------|--------|
        | `position` | ~2 Hz | `Position` |
        | `job` | ~2 Hz | `JobState` |
        | `settings` | On change | `JogSettings` |
        | `overrides` | On change | `Overrides` |
        | `aux` | On change | `AuxState` |
        | `connection` | On change | `ConnectionStatus` |
        | `file_imported` | After `/api/file/upload` or `/api/file/open` | `{"success": bool, "error"?: string}` |

        The stream opens with a `:connected` comment, and a `:keepalive`
        comment is sent every 30 seconds.

        "On change" events are not re-sent to a new subscriber, so call
        `/api/events/poll` once after connecting to get the current state.
      responses:
        '200':
          description: SSE event stream
          content:
            text/event-stream:
              schema:
                type: string
        '401':
          description: Missing or invalid Bearer token (empty body)

  /api/events/poll:
    get:
      summary: Polling fallback
      description: |
        Returns the latest state snapshot as a single JSON object. Use this
        if SSE is unavailable.
      responses:
        '200':
          description: Complete state snapshot
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PollSnapshot'
        '401':
          $ref: '#/components/responses/Unauthorized'
