openapi: 3.1.0
info:
  title: Oppsal Orientering — Tidtaking API
  version: 0.1.0
  summary: Backend for Råtass / Oppsal orienteering timing.
  description: |
    Central API server backing the kiosk display, admin UI and remote
    readers. One process per organisation; SQLite (today) under the hood.

    ### How to use this from an AI / agent

    1. **Read the active event** with `GET /status`. Most of the system
       acts on the *currently active* event — there is at most one active
       event at a time (set via `POST /api/events/{id}/activate`).
    2. **Push card reads** as a remote reader by `POST /api/scan` with a
       Bearer station token. The server runs the same dedup, partial-read
       and course-detection logic the local kiosk uses.
    3. **Subscribe to live updates** by opening a WebSocket on `/ws`.
       Events emitted: `scanner_status`, `card_reading_start`, `result`,
       `card_read_error`, `event_activated`, `event_stopped`,
       `registration_mode`, `start_card_read`. They have no acks; clients
       reconnect on disconnect.
    4. **Read members** via `GET /api/athletes`. Filter by `ratass=1`,
       `ungdom=1`, `noEventor=1`, `search=...`, or `club=...`.
    5. Mutating operations on the events / members tables require a
       valid admin session (cookie-based PIN today). Read endpoints are
       open within the network boundary.

    ### Identifiers

    - `eventId` — integer, primary key in `events`.
    - `athleteId` — integer, primary key in `members`.
    - `tag` — EMIT card number (matches either the printed number
      `members.emit_card` or the chip tag `members.emit_tag`).
    - `stationId` — string, opaque label assigned to a kiosk by the
      `STATION_TOKENS` env var on the server.

servers:
  - url: https://o.fjoven.com
    description: Production
  - url: http://localhost:3000
    description: Local dev (kiosk laptop)
  - url: http://localhost:3010
    description: Local dev (server-mode)

components:
  securitySchemes:
    StationToken:
      type: http
      scheme: bearer
      description: |
        Bearer token issued to a kiosk reader. Configured server-side via
        the `STATION_TOKENS` env var: `"stationId=token,stationId2=token2"`.
        The matched `stationId` is attached to every accepted scan.
    AdminPin:
      type: apiKey
      in: cookie
      name: connect.sid
      description: |
        Session cookie set by `POST /api/auth` with a valid admin PIN.
        Required for write operations on events/members/courses.

  schemas:

    Punch:
      type: object
      required: [code, total_seconds_raw]
      properties:
        index:
          type: integer
          description: 1-based index in the punch list
        code:
          type: integer
          description: 'Control code 0..255. Codes 250-253 are reader/system codes.'
        total_seconds_raw:
          type: integer
          description: Seconds since the EMIT card was activated.
        time_hms:
          type: string
          example: "00:01:23"
        split_seconds:
          type: integer

    Frame:
      type: object
      description: A decoded card read produced by a scanner.
      required: [tag, punches]
      properties:
        tag:
          type: string
          description: EMIT card number as a string.
        device_type:
          type: string
          enum: [EPT, MTR, ESCAN]
        punches:
          type: array
          items:
            $ref: "#/components/schemas/Punch"
        error:
          type: string
          description: 'Optional decoder hint, e.g. "wrong_dialect".'

    Member:
      type: object
      properties:
        id:                       { type: integer }
        first_name:               { type: string }
        last_name:                { type: string }
        birth_date:               { type: string, format: date, nullable: true }
        birth_year:               { type: integer, nullable: true }
        sex:                      { type: string, enum: [M, F], nullable: true }
        club_name:                { type: string, nullable: true }
        emit_card:
          type: string
          nullable: true
          description: Printed EMIT card number.
        emit_tag:
          type: string
          nullable: true
          description: EMIT TAG card identifier.
        email:                    { type: string, nullable: true, format: email }
        phone:                    { type: string, nullable: true }
        street_address:           { type: string, nullable: true }
        postal_code:              { type: string, nullable: true }
        city:                     { type: string, nullable: true }
        is_ratass:                { type: integer, enum: [0, 1] }
        is_ungdom:                { type: integer, enum: [0, 1] }
        ratass_group:             { type: string, nullable: true }
        eventor_person_id:        { type: integer, nullable: true }
        eventor_organisation_id:  { type: integer, nullable: true }
        is_active:                { type: integer, enum: [0, 1] }
        notes:                    { type: string, nullable: true }

    Course:
      type: object
      properties:
        id:                  { type: integer }
        event_id:            { type: integer }
        name:                { type: string }
        description:         { type: string, nullable: true }
        required_controls:
          type: string
          description: 'JSON array of control codes, e.g. "[31,32,33]".'
        finish_line_codes:   { type: string }
        distance_km:         { type: number, nullable: true }
        climb_m:             { type: integer, nullable: true }
        color:               { type: string, example: "#22c55e" }
        free_order:          { type: integer, enum: [0, 1] }

    Event:
      type: object
      properties:
        id:                  { type: integer }
        name:                { type: string }
        date:                { type: string, format: date, nullable: true }
        organizer:           { type: string, nullable: true }
        description:         { type: string, nullable: true }
        type:                { type: string, enum: [training, race] }
        status:              { type: string, enum: [created, active, stopped] }
        config:
          type: string
          description: JSON object - type-specific config.
        system_codes:
          type: string
          description: 'JSON array. Default "[250,251,252,253]".'
        finish_line_codes:
          type: string
          description: JSON array of finish-line post codes.
        start_line_codes:
          type: string
          description: JSON array of start-line post codes.

    Result:
      type: object
      properties:
        id:                  { type: integer }
        event_id:            { type: integer }
        athlete_id:          { type: integer, nullable: true }
        course_id:           { type: integer, nullable: true }
        detected_course_id:  { type: integer, nullable: true }
        emit_card:           { type: string }
        read_time:           { type: string, format: date-time }
        time_seconds:        { type: integer, nullable: true }
        codes:
          type: string
          description: JSON array of all punched codes.
        punches:
          type: string
          description: JSON array of full punch objects.
        course_validation:
          type: string
          nullable: true
          description: 'JSON object {isValid, missing, extra, matched}.'
        status:              { type: string, enum: [OK, MP, DSQ, DNF, OT, DNS, DISK] }
        points:              { type: integer }

    Station:
      type: object
      properties:
        stationId:     { type: string }
        role:          { type: string, enum: [start, finish, checkpoint] }
        scannerType:   { type: string, enum: [EPT, MTR, ESCAN], nullable: true }
        source:        { type: string, enum: [local, remote] }
        online:        { type: boolean }
        lastSeen:
          type: integer
          description: Epoch ms of the last scan or heartbeat.
        since:
          type: integer
          description: Epoch ms when the current online streak started.
        offlineReason: { type: string, nullable: true }
        desiredConfig:
          type: object
          description: Settings the server wants the reader to apply (delivered in the next /api/scan or /api/heartbeat response).
          properties:
            dialect: { type: string, enum: [auto, ept, mtr] }

    Error:
      type: object
      properties:
        status:  { type: string, example: error }
        message: { type: string }

paths:

  ##################
  # Status / live  #
  ##################
  /status:
    get:
      tags: [status]
      summary: Active event + registration mode
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  activeRace:        { $ref: "#/components/schemas/Event", nullable: true }
                  registrationMode:  { type: boolean }

  /event/{id}:
    get:
      tags: [status]
      summary: Full event detail with parsed courses
      description: Convenience endpoint used by the kiosk display.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer }
      responses:
        '200':
          description: ok

  /history/{tag}:
    get:
      tags: [status]
      summary: Recent results for a card
      parameters:
        - in: path
          name: tag
          required: true
          schema: { type: string }
        - in: query
          name: limit
          schema: { type: integer, default: 10 }
      responses:
        '200': { description: ok }

  /recent-results:
    get:
      tags: [status]
      summary: Latest results in the active event
      parameters:
        - in: query
          name: limit
          schema: { type: integer, default: 10 }
      responses:
        '200': { description: ok }

  ##############################
  # Reader → server card flow  #
  ##############################
  /api/scan:
    post:
      tags: [reader]
      summary: Push a decoded card frame
      description: |
        Server runs the same `processCardRead()` as the local scanner:
        validation → dedup → partial-read detection → MTR-truncation
        check → athlete lookup → course detection → time calculation →
        DB INSERT → WebSocket broadcast.

        On the wire the body looks like:

        ```json
        {
          "stationId":   "kiosk-skullerud-finish",
          "stationRole": "finish",
          "frame": {
            "tag": "243984",
            "device_type": "EPT",
            "punches": [
              { "code": 77,  "total_seconds_raw": 3 },
              { "code": 70,  "total_seconds_raw": 12 },
              { "code": 250, "total_seconds_raw": 220 }
            ]
          }
        }
        ```
      security: [ { StationToken: [] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [frame]
              properties:
                stationId:   { type: string }
                stationRole: { type: string, enum: [start, finish, checkpoint] }
                frame:       { $ref: "#/components/schemas/Frame" }
      responses:
        '200':
          description: Frame accepted (results are broadcast on /ws)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:    { type: string, example: ok }
                  stationId: { type: string }
        '400': { description: Missing frame.tag, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        '401': { description: Invalid station token, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }

  /api/heartbeat:
    post:
      tags: [reader]
      summary: Reader liveness ping
      description: Marks a station online without producing a result. Send every ~30 s.
      security: [ { StationToken: [] } ]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                stationRole: { type: string, enum: [start, finish, checkpoint] }
                scannerType: { type: string, enum: [EPT, MTR, ESCAN] }
      responses:
        '200': { description: ok }

  /api/stations:
    get:
      tags: [reader]
      summary: Online + recently-offline readers
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:   { type: string, example: ok }
                  stations:
                    type: array
                    items: { $ref: "#/components/schemas/Station" }

  /api/stations/{stationId}/config:
    put:
      tags: [reader]
      summary: Set per-station configuration (e.g. dialect)
      description: |
        Persists the `desiredConfig` for a station. The next /api/scan or
        /api/heartbeat response from that reader contains the new config,
        and the reader applies it live via `setDialect()` — no
        reconnect needed. For the local kiosk's own scanner
        (`stationId="local-finish"`), the active scanner instance is
        also updated immediately.

        **Open by design.** Recovery scenarios at a kiosk (a kid swaps
        the reader for the wrong type) need to be fixable on the spot
        without finding an admin PIN. The blast radius is bounded — only
        `dialect` can be set, and a wrong choice yields no decode rather
        than corrupted data.
      parameters: [ { in: path, name: stationId, required: true, schema: { type: string } } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                dialect: { type: string, enum: [auto, ept, mtr] }
      responses:
        '200': { description: ok }
        '400': { description: invalid dialect }

  ###############
  # Auth        #
  ###############
  /api/auth:
    post:
      tags: [auth]
      summary: Validate admin PIN
      description: Issues a signed-cookie session used for admin operations.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [pin]
              properties:
                pin: { type: string }
      responses:
        '200': { description: ok }
        '401': { description: invalid PIN }
    get:
      tags: [auth]
      summary: Inspect current session
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
                  admin:  { type: boolean }

  /api/auth/logout:
    post:
      tags: [auth]
      summary: Clear the admin session
      responses:
        '200': { description: ok }

  /api/admin/tokens:
    get:
      tags: [auth]
      summary: List API tokens
      description: |
        Bearer tokens issued for remote integrations (kiosk readers,
        dashboards, ...). Only the prefix and metadata are returned —
        the raw secret is shown once at create time.
      security: [ { AdminPin: [] } ]
      responses:
        '200':
          description: ok
        '401': { description: not logged in }
    post:
      tags: [auth]
      summary: Create a new API token
      security: [ { AdminPin: [] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, scopes]
              properties:
                name:      { type: string, example: "Kiosk Skullerud finish" }
                scopes:
                  type: array
                  items: { type: string, enum: [scan, read, admin] }
                stationId: { type: string, description: 'Required for tokens with the "scan" scope.' }
                notes:     { type: string }
      responses:
        '200':
          description: Returns the raw token in the body — only chance to see it
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:    { type: string, example: ok }
                  token:     { type: string, example: "o_abc...32-bytes...xyz" }
                  id:        { type: integer }
                  name:      { type: string }
                  prefix:    { type: string }
                  scopes:    { type: array, items: { type: string } }
                  stationId: { type: string, nullable: true }

  /api/admin/tokens/{id}:
    delete:
      tags: [auth]
      summary: Revoke a token
      description: Soft-revoke. The row is kept for audit but stops authenticating.
      security: [ { AdminPin: [] } ]
      parameters: [ { in: path, name: id, required: true, schema: { type: integer } } ]
      responses:
        '200': { description: ok }
        '404': { description: not found or already revoked }

  ###############
  # Events      #
  ###############
  /api/events:
    get:
      tags: [events]
      summary: List events
      parameters:
        - in: query
          name: type
          schema: { type: string, enum: [training, race] }
        - in: query
          name: status
          schema: { type: string, enum: [created, active, stopped] }
      responses:
        '200': { description: ok }
    post:
      tags: [events]
      summary: Create event
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, type]
              properties:
                name:             { type: string }
                date:             { type: string, format: date }
                organizer:        { type: string }
                description:      { type: string }
                type:             { type: string, enum: [training, race] }
                config:           { type: object }
                systemCodes:      { type: array, items: { type: integer } }
                finishLineCodes:  { type: array, items: { type: integer } }
                startLineCodes:   { type: array, items: { type: integer } }
      responses:
        '200': { description: ok }

  /api/events/{id}:
    get:
      tags: [events]
      summary: Get event with courses + classes + participant count
      parameters: [ { in: path, name: id, required: true, schema: { type: integer } } ]
      responses:
        '200': { description: ok }
    put:
      tags: [events]
      summary: Update event
      parameters: [ { in: path, name: id, required: true, schema: { type: integer } } ]
      responses:
        '200': { description: ok }

  /api/events/{id}/activate:
    post:
      tags: [events]
      summary: Activate the event
      description: Stops any other active event first. Only one active at a time.
      parameters: [ { in: path, name: id, required: true, schema: { type: integer } } ]
      responses:
        '200': { description: ok }

  /api/events/{id}/stop:
    post:
      tags: [events]
      summary: Stop the event
      parameters: [ { in: path, name: id, required: true, schema: { type: integer } } ]
      responses:
        '200': { description: ok }

  /api/events/{eventId}/settings:
    get:
      tags: [events]
      summary: Get event settings (codes + config)
      parameters: [ { in: path, name: eventId, required: true, schema: { type: integer } } ]
      responses:
        '200': { description: ok }
    put:
      tags: [events]
      summary: Update settings
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                systemCodes:      { type: array, items: { type: integer } }
                finishLineCodes:  { type: array, items: { type: integer } }
                startLineCodes:   { type: array, items: { type: integer } }
                config:           { type: object }
      parameters: [ { in: path, name: eventId, required: true, schema: { type: integer } } ]
      responses:
        '200': { description: ok }

  /api/events/{eventId}/swap-control:
    post:
      tags: [events]
      summary: Swap a control code event-wide
      description: |
        Replaces `fromCode` with `toCode` in every course's
        `required_controls` *and* in every stored result's `codes` /
        `punches` arrays so prior validation still matches.
      parameters: [ { in: path, name: eventId, required: true, schema: { type: integer } } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fromCode, toCode]
              properties:
                fromCode: { type: integer }
                toCode:   { type: integer }
      responses:
        '200': { description: ok }

  /api/events/{eventId}/courses:
    get:
      tags: [events]
      summary: List courses for the event
      parameters: [ { in: path, name: eventId, required: true, schema: { type: integer } } ]
      responses:
        '200': { description: ok }
    post:
      tags: [events]
      summary: Create a course
      parameters: [ { in: path, name: eventId, required: true, schema: { type: integer } } ]
      responses:
        '200': { description: ok }

  ###############
  # Members     #
  ###############
  /api/athletes:
    get:
      tags: [members]
      summary: List members (paginated)
      parameters:
        - in: query
          name: search
          schema: { type: string }
          description: Matches first_name / last_name / emit_card / emit_tag.
        - in: query
          name: ratass
          schema: { type: integer, enum: [0, 1] }
        - in: query
          name: ungdom
          schema: { type: integer, enum: [0, 1] }
        - in: query
          name: noEventor
          schema: { type: integer, enum: [1] }
          description: When 1, filter to members without an Eventor user.
        - in: query
          name: club
          schema: { type: string }
        - in: query
          name: page
          schema: { type: integer, default: 1 }
        - in: query
          name: limit
          schema: { type: integer, default: 50 }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:    { type: string, example: ok }
                  athletes:  { type: array, items: { $ref: "#/components/schemas/Member" } }
                  total:     { type: integer }
                  page:      { type: integer }
                  pageSize:  { type: integer }
                  totalPages: { type: integer }
    post:
      tags: [members]
      summary: Create a member
      responses: { '200': { description: ok } }

  /api/athletes/{id}:
    get:
      tags: [members]
      summary: Get a single member
      parameters: [ { in: path, name: id, required: true, schema: { type: integer } } ]
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:  { type: string, example: ok }
                  athlete: { $ref: "#/components/schemas/Member" }
    put:
      tags: [members]
      summary: Update a member
      parameters: [ { in: path, name: id, required: true, schema: { type: integer } } ]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                firstName:     { type: string }
                lastName:      { type: string }
                birthDate:     { type: string, format: date }
                birthYear:     { type: integer }
                sex:           { type: string, enum: [M, F] }
                clubName:      { type: string }
                emitCard:      { type: string }
                emitTag:       { type: string }
                email:         { type: string, format: email }
                phone:         { type: string }
                streetAddress: { type: string }
                postalCode:    { type: string }
                city:          { type: string }
                notes:         { type: string }
                isActive:      { type: integer, enum: [0, 1] }
      responses:
        '200': { description: ok }
    delete:
      tags: [members]
      summary: Delete a member
      parameters: [ { in: path, name: id, required: true, schema: { type: integer } } ]
      responses:
        '200': { description: ok }

  /api/athletes/{id}/relations:
    get:
      tags: [members]
      summary: Parents and children for a member
      parameters: [ { in: path, name: id, required: true, schema: { type: integer } } ]
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:   { type: string, example: ok }
                  parents:  { type: array, items: { $ref: "#/components/schemas/Member" } }
                  children: { type: array, items: { $ref: "#/components/schemas/Member" } }

  /api/athletes/by-card/{cardNumber}:
    get:
      tags: [members]
      summary: Look up a member by EMIT card or tag
      parameters: [ { in: path, name: cardNumber, required: true, schema: { type: string } } ]
      responses:
        '200': { description: ok }
        '404': { description: not found }

  /api/athletes/merge:
    post:
      tags: [members]
      summary: Merge two member rows
      description: |
        Moves all FK references (`results`, `event_participants`,
        `emit_card_history`, `member_relations`) from the loser to the
        survivor, copies non-null fields onto the survivor where empty,
        then deletes the loser. If exactly one of the two rows has
        `eventor_person_id`, that row becomes the survivor regardless of
        the supplied `survivorId`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [survivorId, loserId]
              properties:
                survivorId: { type: integer }
                loserId:    { type: integer }
      responses:
        '200': { description: ok }

  /api/athletes/import-spond:
    post:
      tags: [members]
      summary: Import members + parents + addresses from a Spond Excel
      description: |
        Multipart upload of the Spond `Råtasser ... -members.xlsx` export.
        Reads the **For import** sheet (children, parents 1–4, address
        columns L/M/N). Auto-classifies kids born ≥ currentYear-12 as
        Råtass; older ones as Ungdom.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        '200': { description: ok }

  /api/athletes/clubs:
    get:
      tags: [members]
      summary: Club list with member counts
      responses: { '200': { description: ok } }

  ##################
  # Scanner config #
  ##################
  /api/scanner/dialect:
    get:
      tags: [scanner]
      summary: Current reader dialect (auto/EPT/MTR)
      responses: { '200': { description: ok } }
    post:
      tags: [scanner]
      summary: Set reader dialect (live, no port disconnect)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [dialect]
              properties:
                dialect: { type: string, enum: [auto, ept, mtr] }
      responses: { '200': { description: ok } }

  /api/scanner/status:
    get:
      tags: [scanner]
      summary: Local scanner connection state
      responses: { '200': { description: ok } }

  /api/scanner/reconnect:
    post:
      tags: [scanner]
      summary: Force the local scanner to reopen its serial port
      responses: { '200': { description: ok } }

  /api/scanner/ports:
    get:
      tags: [scanner]
      summary: List available USB serial ports
      responses: { '200': { description: ok } }

  /api/scanner/start-port:
    post:
      tags: [scanner]
      summary: Configure which port is used by the start scanner
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                port: { type: string, nullable: true }
      responses: { '200': { description: ok } }

  /api/registration/mode:
    post:
      tags: [scanner]
      summary: Toggle registration mode on the kiosk
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                enabled: { type: boolean }
      responses: { '200': { description: ok } }

  /api/season:
    get:
      tags: [reports]
      summary: Season overview for Råtass members
      parameters:
        - in: query
          name: group
          schema: { type: string }
          description: Optional ratass_group filter (e.g. "2014 (12 år)").
      responses: { '200': { description: ok } }

# ---------------------------------------------------------------------------
# WebSocket — informational. OpenAPI doesn't formally model WS; documented
# here for clients that pair `GET /api/openapi.yaml` with the spec below.
# ---------------------------------------------------------------------------
x-websocket:
  url: /ws
  description: |
    Connect with `wss://<host>/ws` (or `ws://` over plain HTTP). No
    handshake — the server pushes JSON-encoded events on a single text
    channel.
  events:
    scanner_status:
      description: Per-station online/offline state. Includes a `stations` array.
      example: |
        { "type": "scanner_status", "connected": true, "scannerType": "EPT",
          "startScanner": { "connected": false },
          "stations": [
            { "stationId": "kiosk-skullerud-finish", "role": "finish",
              "scannerType": "EPT", "source": "remote",
              "online": true, "lastSeen": 1714740000000 }
          ] }
    card_reading_start:
      description: Sent when the scanner begins receiving bytes for a card. Use to show a spinner.
      example: |
        { "type": "card_reading_start" }
    result:
      description: Final result for a card read.
      example: |
        { "type": "result", "tag": "243984",
          "athlete": { "id": 528, "first_name": "Balder Omsted", "last_name": "Aukrust" },
          "timeSeconds": 679, "codes": [144, 167, 154, 148, 99, 82, 71],
          "status": "OK", "duplicate": false, "eventType": "training" }
    card_read_error:
      description: Bad / partial / wrong-dialect card read. UI shows a re-read prompt.
      example: |
        { "type": "card_read_error", "tag": "243984", "reason": "wrong_dialect",
          "currentDialect": "MTR", "suggestedDialect": "EPT",
          "message": "Leseren er satt til MTR, men data ser ut som EPT. Bytt lesertype og les på nytt." }
    event_activated:
      example: |
        { "type": "event_activated", "event": { "id": 7, "name": "Skjellumtoppen rakett" } }
    event_stopped:
      example: |
        { "type": "event_stopped", "eventId": 7 }
    registration_mode:
      example: |
        { "type": "registration_mode", "enabled": true }
    start_card_read:
      description: Emitted by a dedicated start reader (separate USB port) when a runner punches in at start.
      example: |
        { "type": "start_card_read", "tag": "243984", "athlete": { "id": 528, "first_name": "Balder", "last_name": "Aukrust" } }
