Commit Graph

5 Commits

Author SHA1 Message Date
raphael 65db38d9f2 100%/B: S9F3/F5/F11 emission + Router fallback
tests / build-and-test (push) Failing after 31s
Closes the S9 stream.  Every documented protocol-error condition is now
auto-emitted by Connection (with the assist of one Router predicate),
without involving the application.

Router (include/secsgem/gem/router.hpp)
  Adds two predicates: has_handler(stream, function) and
  has_handler_for_stream(stream).  Lets the wrapping message handler
  decide whether an unhandled message is "unknown stream" (S9F3) or
  "unknown function in a known stream" (S9F5).

Connection (include/secsgem/hsms/{connection.hpp, connection.cpp})
  - emit_s9() goes public so the message_handler can call it.
  - New current_header() accessor returns the HSMS header of the
    primary currently being dispatched.  Non-null only inside the
    on_message_ call; cleared on the way out.
  - handle_data sets current_header_ before invoking on_message_.
  - on_length on oversized frame: synthesizes a 10-byte MHEAD whose
    first 4 bytes are the offending length prefix, emits S9F11, and
    sets close_after_flush so the S9F11 goes out before the socket
    closes.

Server (apps/secs_server.cpp)
  The conn->set_message_handler lambda now wraps router.dispatch.  For
  any inbound primary without a registered handler, it captures the
  MHEAD via current_header() and emits either S9F3 (stream unknown) or
  S9F5 (function unknown).  The wrapper still returns the Router's
  reply (SxF0 for primaries with W) so transactional semantics are
  preserved.

COMPLIANCE.md
  Error Messages row flips from 🟡 to .  S9F3/F5/F11 rows in the
  coverage matrix flip from 🟡 to .  Each row in the matrix now
  states its trigger condition explicitly.  Drops the
  "Finish S9 wiring" bullet from the "what would 100% take" list.

Verified
  - Tests: 78 cases / 454 assertions still pass (no behavioural change
    on the happy path; new emission paths fire only on protocol errors
    that the demo doesn't induce).
  - Build clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 01:57:35 +02:00
raphael 547fd2116b Close COMPLIANCE.md gap: S9 error stream
tests / build-and-test (push) Failing after 43s
Adds S9F1, F3, F5, F7, F9, F11, F13 to the message catalog and wires
the two emission paths that the Connection layer can drive without help
from the Router or the application: S9F7 on a body-decode failure and
S9F9 on a T3 transaction-timer timeout.

Catalog (data/messages.yaml -> generated messages.hpp)

  All six MHEAD-carrying messages (F1/F3/F5/F7/F9/F11) use the same
  shape — a single <B 10> body with the offending 10-byte HSMS header.
  S9F13 (conversation timeout) carries <L,2 <A MEXP> <A EDID>>.

Connection-side emissions (src/hsms/connection.cpp)

  emit_s9(function, mhead)   New private helper.  Builds a 9/function/W=0
                              data message whose body is <B 10> with the
                              MHEAD bytes, allocates a fresh sys_bytes,
                              and queues it onto the write path.  No
                              reply is tracked.

  S9F7 on body decode        handle_data wraps Message::from_body in a
                              try/catch.  Previously any decode error
                              closed the connection; now it emits S9F7
                              with the offending header and continues
                              reading.  Reply-side decode failure also
                              emits S9F7 and surfaces the new
                              Error::IllegalData to the waiting
                              ReplyHandler (rather than making the
                              caller wait out T3).

  S9F9 on T3 timeout         The send_request T3 callback rebuilds the
                              original outgoing MHEAD from
                              (device_id, expected_stream,
                              expected_function-1, sys, W=1) and emits
                              S9F9 before invoking the callback with
                              Error::Timeout (unchanged).

What's intentionally not yet wired (logged in COMPLIANCE.md)

  - S9F3 / S9F5 — "unknown stream / function".  These need to live in
    the Router's fallback path, which would require either the Router
    knowing about a Connection-shaped sender or the Connection's
    message wrapper learning which streams the Router has handlers
    for.  Deferred — today the fallback returns SxF0 only.
  - S9F11 — "Data Too Long".  Currently we close on oversized frames;
    we'd need to also build a synthetic 10-byte MHEAD substitute (the
    real header isn't yet available at the point of detection) and
    flush it through close_after_flush.

Tests + docs

  tests/test_messages.cpp  Round-trip every S9F* using a representative
                           10-byte MHEAD literal; check S9F13 carries
                           MEXP + EDID.  +2 cases / +37 assertions.

  COMPLIANCE.md            Error Messages row moved from "no S9 stream"
                           to a detailed status describing what's
                           emitted vs catalog-only.  Coverage matrix
                           expanded per-message (F1/F7/F9/F13 ;
                           F3/F5/F11 🟡 catalog-only).

Build/demo unaffected: 75 cases / 420 assertions pass; the happy-path
demo never trips a decode error or T3, so the S9 path isn't exercised
end-to-end (but unit tests prove the wire shape).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 19:14:41 +02:00
raphael 41accc3263 #2 Tighten reply correlation: match (sys_bytes, stream, function) exactly
The previous heuristic ("function % 2 == 0 && pending_requests_.count(sys)")
worked in practice but was wrong in principle — SECS-II doesn't enforce
function parity, and a peer protocol violation (replying with the wrong
SxFy) would have been silently treated as a primary message.

Now PendingRequest carries the expected reply stream + function (computed
from request.stream / request.function+1 per SECS-II convention) at
send_request time.  handle_data matches on all three:

  it->second.expected_stream == h.stream() &&
  it->second.expected_function == h.function()

If sys_bytes matches but stream/function doesn't, the Connection logs
a diagnostic ("!! unexpected SxFy for pending sys=N (expected ...)")
and treats the message as a primary so the application handler can
still respond.  The pending request stays open until T3.

No behaviour change on the happy path; the demo and all 69 tests still
pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 09:53:37 +02:00
raphael b871cd9da2 Table/YAML-driven refactor (Layer 1 start)
Move equipment capabilities and the E30 control state machine out of C++
code and into YAML data files; introduce a Router for SECS dispatch;
consolidate small files.

Behavioural changes: none.  Demo identical (15 SxFy transactions +
3 equipment-initiated primaries), 67 test cases / 384 assertions still
all green.  Structural changes only.

Why
---

The previous server.cpp held the equipment data dictionary (3 SVIDs,
2 ECIDs, 3 CEIDs, 2 alarms, 2 recipes, 4 host commands) as imperative
C++ in a 50-line `populate()` function, and routed inbound messages
through a 150-line if-ladder.  Adding a new SVID required a recompile.
Adding a new state transition required editing two switch statements
(`operator_*` and `on_host_request_*`).  The control state machine's
behavioural rules were spread across imperative code in two methods.

This is exactly what implementation_plan.md calls out as the wrong
shape: behavioural rules should live in versioned data, and every
runtime/test/analyzer should read from that data rather than re-encode
it.  This commit starts that move.

What's new
----------

data/equipment.yaml
  Equipment data dictionary.  Declarative SVIDs / ECIDs / CEIDs /
  alarms / recipes / host commands.  Host commands carry their HCACK
  ack code plus optional `emit_ceid` and `set_alarm` side-effects.
  Adding a new SVID or command is a YAML edit, no recompile.

data/control_state.yaml
  The E30 §6.2 control state transition table as data.  Each row is
  (from, on) -> (to [, then] [, ack]).  `then` chains an auto-advance
  through the transient AttemptOnline state.  The previous
  imperative switch is gone.

include/secsgem/config/loader.hpp + src/config/loader.cpp
  yaml-cpp-backed loader.  `load_control_state(path)` returns a
  ControlTransitionTable + initial state; `load_equipment(path, model)`
  populates the EquipmentDataModel and returns the device descriptor
  (id, MDLN, SOFTREV, optional auto-emit CEID).  Surfaces config
  errors with file path + field name via ConfigError.

include/secsgem/gem/router.hpp  (header-only)
  Small (stream, function) -> handler map.  Server registers all
  handlers once at startup, then the Connection's message handler is
  just `router.dispatch(msg)`.  Unhandled primaries with W set get
  SxF0 by default.  Replaces the if-ladder in secs_server.cpp.

include/secsgem/gem/control_state.hpp + .cpp
  ControlTransitionTable is the new pure data type.  ControlStateMachine
  is now a thin engine over the table: `fire(event)` looks up the row,
  optionally transitions, optionally chains a `then` transition, returns
  the ack code.  Behaviour rules no longer live in C++ switches.
  The default in-code table matches data/control_state.yaml row for row;
  tests rely on it so they don't need the YAML file.

include/secsgem/gem/data_model.hpp + .cpp
  `register_command(rcmd, CommandSpec)` replaces the function-handler
  signature.  CommandSpec = (HostCmdAck, optional emit_ceid, optional
  set_alarm).  `dispatch_command` returns a CommandResult so the server
  can fire the side-effects after S2F42 is sent.

apps/secs_server.cpp
  No populate(), no if-ladder.  Loads equipment.yaml + control_state.yaml
  at startup (clean error on bad config), wires the Router once,
  delegates dispatch.  Sm change handler reads emit_on_control_change
  from the YAML.  Welcome S10F3 removed for parity with config (a future
  YAML rule could re-introduce it declaratively).

tests/test_loader.cpp  (new)
  Verifies the YAML loader produces the same shape as the in-code
  default table, and that equipment.yaml populates every section
  (SVIDs/ECIDs/CEIDs/alarms/recipes/commands).  SECSGEM_DATA_DIR
  CMake define points at ${CMAKE_SOURCE_DIR}/data so tests don't
  depend on cwd.

CMakeLists.txt, Dockerfile
  find_package(yaml-cpp) and link.  libyaml-cpp-dev added to the
  Ubuntu base image (yaml-cpp 0.8 ships the modern target name).

File consolidation
------------------

Five small files removed; their content lives in fewer headers:

  - secs2/item.cpp        -> inline in secs2/item.hpp
  - secs2/message.cpp     -> inline in secs2/message.hpp
  - hsms/types.hpp        -> merged into hsms/header.hpp
  - hsms/frame.hpp        -> merged into hsms/header.hpp
  - hsms/frame.cpp        -> merged into hsms/header.cpp

hsms/header.hpp is now "the HSMS wire format" in one place: SType + status
enums + Timers + Header + Frame + constants.  All includers updated.

Net effect
----------

Before: equipment data dictionary lived in 50 lines of imperative
populate() inside secs_server.cpp; dispatch in a 20-branch if-ladder.

After: equipment data dictionary lives in 47 lines of YAML; dispatch
is a Router built once.  Adding a new capability is now a YAML edit
in the common case.

Test count up to 67 cases / 384 assertions (+4 cases / +106 assertions)
covering the loader and the new table-driven SM paths.

What's NOT changed
------------------

The per-SxFy reply construction still lives in C++ (each message has a
unique body shape).  Moving those into YAML/JSON message-shape
definitions is the next refactor step but requires a generic typed
encoder/decoder driven by shape descriptors; out of scope here.

Spooling, the S9 error stream, S1F19/F20, and the other gaps in
COMPLIANCE.md remain unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 08:57:38 +02:00
raphael 96b02f8b50 Initial commit: C++20 SECS-II / HSMS / GEM client + server
A fully containerised SECS/GEM toolchain. Single docker compose project,
no host build tools. 63 unit-test cases / 278 assertions, two demo
executables, end-to-end two-container demo exercising every implemented
capability.

Architecture (bottom-up):

  secs2/   E5 SECS-II codec
    Item        variant over L/A/B/BOOLEAN/I1-8/U1-8/F4/F8
    encode/decode  big-endian, 1/2/3-byte length encoding
    Message     SxFy + W-bit + optional root item
    to_sml      human-readable text rendering

  hsms/    E37 HSMS transport (TCP)
    Header      10-byte header + SType enum (Data/Select/Deselect/
                Linktest/Reject/Separate)
    Frame       4-byte length prefix + payload encode/decode
    Connection  async Asio TCP, NOT-SELECTED -> SELECTED state machine,
                T3/T5/T6/T7/T8 timers, system-bytes reply correlation,
                graceful close-after-flush separation

  endpoint  active Client (connect with T5 retry) and passive Server
            (accept loop) wrappers over Connection

  gem/     E30 GEM logic
    ControlStateMachine  5-state E30 control model with operator
                         actions, host requests, SEMI-mandated ack
                         codes (OnlineAck, OfflineAck, CommAck), and
                         a state-change handler
    EquipmentDataModel   in-memory dictionary: SVIDs, DVIDs, ECIDs
                         (with EAC), CEIDs, report defs, CEID->report
                         links, enabled-events set, alarm table
                         (ALCD, enabled, active), process programs,
                         host command registry, clock (16-char
                         YYYYMMDDhhmmsscc with offset)
    messages.hpp         builders + parsers for every SxFy below

GEM message coverage (full list):

  S1F1/F2    Are You There / On Line Data
  S1F3/F4    Selected Equipment Status Request / Data
  S1F11/F12  Status Variable Namelist Request / Data
  S1F13/F14  Establish Communications (+ CommAck)
  S1F15/F16  Request OFFLINE (+ OfflineAck)
  S1F17/F18  Request ONLINE (+ OnlineAck)
  S2F13/F14  Equipment Constant Request / Data
  S2F15/F16  EC Send + EquipmentAck (Accept/UnknownEcid/Busy/OutOfRange)
  S2F17/F18  Date and Time Request / Data
  S2F29/F30  Equipment Constant Namelist Request / Data
  S2F31/F32  Date and Time Set Request / TimeAck
  S2F33/F34  Define Report + DefineReportAck (5 enum values)
  S2F35/F36  Link Event Report + LinkEventAck
  S2F37/F38  Enable / Disable Event Report + EnableEventAck
  S2F41/F42  Host Command + HostCmdAck (7 values) + per-param CPACKs
  S5F1/F2    Alarm Report Send + AlarmAck (ALCD bit-7 set/cleared
             + lower-7 category)
  S5F3/F4    Enable/Disable Alarm Send + AlarmAck
  S5F5/F6    List Alarms Request / Data (active alarms tagged in ALCD)
  S6F11/F12  Event Report Send (equipment-initiated CEID emission
             with full report data) + EventReportAck
  S7F3/F4    Process Program Send + ProcessProgramAck (7 values)
  S7F5/F6    Process Program Request / Data
  S7F19/F20  Current EPPD List Request / Data
  S10F1/F2   Terminal Display Single (host->equipment) + TerminalAck
  S10F3/F4   Terminal Display Single (equipment->host)

Demo apps:

  apps/secs_server.cpp   passive equipment. Populates the data model
                         with 3 SVIDs (ControlState, Clock,
                         EventsEnabled), 2 ECIDs, 3 CEIDs
                         (ControlStateChanged, AlarmSetEvent,
                         ProcessStarted), 2 alarms (Chiller Temp High
                         cat 4, Door Open cat 1), 2 recipes
                         (RECIPE-A, RECIPE-B), and 4 host commands
                         (START, STOP, PAUSE, FAULT). Emits S6F11 on
                         every control state transition + on START;
                         emits S5F1 + the AlarmSetEvent CEID on FAULT.
                         Pushes an S10F3 welcome message when the host
                         comes online.

  apps/secs_client.cpp   active host. Walks 17 steps: Establish ->
                         Online -> S1F11 SVID namelist -> S1F3 read ->
                         S2F29 EC namelist -> S2F13 read ->
                         S2F17 clock -> S2F33/S2F35/S2F37 dynamic
                         event subscription -> S2F41 START
                         (-> receives S6F11) -> S5F5 alarm list ->
                         S5F3 enable alarm 1 -> S2F41 FAULT
                         (-> receives S5F1 + S6F11) -> S7F19/S7F5
                         recipe list + body -> S10F1 terminal ->
                         S1F15 Offline -> Separate. Handles inbound
                         S6F11, S5F1, S10F3 primaries.

Testing:

  tests/test_secs2.cpp         codec round-trip for every format,
                               byte-layout assertions for known values,
                               truncation/trailing-byte rejection,
                               nested list round-trip, SML rendering
  tests/test_hsms.cpp          header byte layout, data + control
                               header round-trip, full frame round-
                               trip with length prefix, short-payload
                               rejection
  tests/test_control_state.cpp every (state, event) pair in the E30
                               control state machine, including
                               AlreadyOnline / NotAccept rejections
                               and idempotent offline-while-offline
  tests/test_data_model.cpp    SVID/ECID/Alarm/Recipe CRUD, clock
                               format + parse, host command registry,
                               full event-report pipeline (define ->
                               link -> enable -> compose) with
                               every error path (InvalidVid,
                               UnknownCeid, UnknownRptid), alarm
                               set/clear with ALCD bit-7 semantics
  tests/test_messages.cpp      round-trip + byte-layout for every
                               builder/parser pair, including S6F11
                               event reports with mixed item types

Toolchain:

  Dockerfile          Ubuntu 24.04, g++-13, CMake, Ninja, libasio-dev
  docker-compose.yml  builder / tests / server / client services,
                      source bind-mounted, build artifacts in a
                      named volume so the host tree stays clean
  CMakeLists.txt      C++20, -Wall -Wextra -Wpedantic, standalone
                      Asio (ASIO_STANDALONE), doctest via FetchContent

Documentation:

  README.md           architecture, quick start, demo log
  COMPLIANCE.md       honest per-capability E5/E30/E37 audit with
                      spec section refs. Calls out what's implemented,
                      what's partial (Reject.req, Alarms missing F7/F8,
                      EC range validation, PP without verify, terminal
                      single-line only), and what's intentionally not
                      yet implemented (spooling, S9 error stream,
                      Documentation S1F19/F20+F21/F22, limits monitoring,
                      trace data collection, multi-block, material
                      movement). Does NOT claim "100% GEM-compliant" and
                      lists the work required to honestly make that claim.

This is Layer 0 + the start of Layer 1 from implementation_plan.md.
The transition-table-driven "spec-as-data" architecture (Layer 1
proper) is not yet implemented; the current code uses imperative
state machines that are structurally ready to be refactored onto
tables.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 00:21:10 +02:00