ModelingEvolution.RocketWelder.StationManagement 0.3.0

ModelingEvolution.RocketWelder.StationManagement

rw2 as one piece of a station environment (epic-107, docs/epics/epic-107-station-twin/). Built on ModelingEvolution.StationManagement — the foundation that knows containers, networks and waits, and no domain nouns.

This package is a client of rw2: of its container image and of its published REST language. It references no rw2 application project, and no vendor or simulator package.

await using var host = await StationHost.Start("pamet-01");          // one station = one network, fixed IPs
var store = await host.EventStore("rw2-store", reachableFromHost: true);
var rw    = await host.RocketWelder("2.21.34", store, simulatorTrack: true, sinkPorts: [17002]);
//          ↑ always a container; add build: "../rocket-welder2/bin/Debug" to mount the branch under test over /app

var fr20 = await rw.Devices.Ensure(real.Robot("FR20").SimulatedBy(Simulator.Robot, sim));
await rw.Devices.Ensure(real.Track("7th Axis").SimulatedBy(Simulator.Track, sim)
                                              .Keeping(x => x.Travel, x => x.Speed, x => x.Tolerance));
await rw.Cameras.Ensure(real.Camera("41943012").SimulatedBy(Mjpeg.Camera, sim.Preview, sinkPort: 17002, boundTo: fr20));

await rw.Programs.Ensure("Pamet", "AxisThenArm", p => p.AxisMove(500).CapturePoint("A").MoveLinear("A"));
var run = await rw.Programs.Run("AxisThenArm");
await run.ShouldFinishWithin(2.Minutes());

Console.WriteLine(host.Differences.Render());
Rule Where
rw2 is ALWAYS a container; a branch under test is a bin/Debug mounted over /app RocketWelderHosting
A mounted build's machine state never leaks in: plugin path, recordings, FsRoot, repositories are always set RocketWelderHosting.Spec
A sink a human is meant to see is published on the SAME number — rw2 tells the browser to open it directly RocketWelderHosting.Spec, RocketWelder.Sink
Commands are not queries: every Ensure posts, then waits for the READ MODEL RwDevices, RwPipelines
The proof of a camera is a JPEG off its tcpserversink, never rw2's own status RwPipelines.WaitServing
A value rw2 refuses becomes a stated difference, never a silent default Substitutions.Keeping, RwDevices.Ensure
A run is asserted on ProgramExecution-<programId> in the station's own store RunHandle
The MJPEG pipeline graph travels with the PACKAGE; a station says where the stream is CameraPipeline
A station writes its program, not rw2's wire shape — no etag, no JSON, no anchors RwPrograms.Ensure, ProgramBuilder
A station can state a difference only it knows Substitutions.NotCovering
Anything this package does not wrap is reached through rw2's own typed client RocketWelder.Api
rw2 runs with ASPNETCORE_ENVIRONMENT=Development — the customer never does RocketWelderHosting.Spec
A value rw2 cannot be asked about is never reported as kept RwDevices.Unverifiable

On site: rw2 from this machine against a real cell

Station is the real cell's rw2, reached over ssh (BatchMode=yes, sudo -n) and its own REST API. It never commands motion, starts a program or talks to a device: it stops and starts ONE container, app, and the pipelines that ran.

var station  = new Station("pamet-01", "rocketwelder@192.168.58.5");      // work dir /mnt/d/tmp/rw2-station/pamet-01
await station.Claim();                    // refuses while a program is Running; records devices + pipelines; stops app
var snapshot = await station.Snapshot();  // store backup, repositories (no bin/obj), calibration, plugins, manifest ALONE
var store    = await host.EventStore("rw2-store", restoreFrom: snapshot.EventStoreArchive);
var rw       = await host.RocketWelder("2.27.0", store, build, snapshot: snapshot, udp: [2222],
                                       environment: RocketWelderHosting.Production);
await rw.Devices.Verify(real);            // the restored roster, matched — never Ensure'd
// …
await station.Release();                  // app → HTTP 200 → read model caught up → pipelines → cameras Connected
Rule Where
A claim record is never overwritten: while the station is down a second claim returns it; while it serves, a claim refuses — "run --release first" Station.Claim
A claim refuses only while a program is Running (owner ruling 2026-09-25: no motion or axis guards), and when the station cannot list its pipelines (a release could not restart them) Station.Claim
The claim record is written BEFORE the station is stopped, and removed only when a release verified all of it ClaimRecord, Station.Release
Released = serving AND the devices read model caught up AND every recorded pipeline Running AND every camera Connected again; one pipeline restart if a camera lags Station.Release
The station's appsettings.runtime.json never travels; its Plugins array does, as a file mounted over rw2's own PluginManifest, RocketWelderHosting.Spec
Manifest and plugins on disk: both or neither — a mismatch kills the devices read model silently PluginManifest.Mismatches
A snapshot lands where rw2 READS it (RepositoryStoragePaths, CalibrationPath, plugins directory) RocketWelderHosting.Spec
Megmeet class-1 answers on UDP 2222: published on the same number udp:

Why Development, when the point is to run what the customer runs

seed-handeye — rw2's only endpoint that binds a camera's intrinsics and mount — is served only when rw2 runs in the Development environment (CamerasEndpoints.cs:98 returns 404 otherwise, RW-3). A twin that could not carry the station's calibration would not be a twin, so the host always sets it, and this is the one place a twin knowingly differs from a customer's cell. It is also the switch that changed the linear-track default on 2026-09-17, which is why simulatorTrack: is an explicit parameter rather than an inherited default.

From a snapshot: the station's data, simulators for its devices

A twin away from the cell runs rw2 on the station's restored store — its programs, repositories, calibration and plugins — and puts a simulator in the place of every device, under the device's own name. Programs bind devices by role and name, so the station's programs stay bindable.

var snapshot = Rw2Snapshot.Open(new AbsolutePath("/mnt/d/tmp/rw2-station/pamet-01"));
var store    = await host.EventStore("rw2-store", restoreFrom: snapshot.EventStoreArchive, reachableFromHost: true);
var rw       = await host.RocketWelder("2.29.7", store, build, simulatorTrack: true, snapshot: snapshot);

await rw.Devices.Replace(real.Robot("FR20").SimulatedBy(Simulator.Robot, at));   // DELETE → gone → Ensure
await rw.Cameras.Replace(real.Camera("41943012").SimulatedBy(Mjpeg.Camera, url, sinkPort, boundTo: fr20));

foreach (var p in snapshot.Programs ?? [])                  // what the STATION had (programs.json), and whether it opened there
    Console.WriteLine(await rw.Programs.OpensHere(p.Name));  // "Prosta: opens" · "SeamWeldTest: does not open — 422 …"
Rule Where
Replace finds the station's device by NAME, any type; a device already of the substitute's type is left alone, so a re-run converges RwDevices.Replace
The substitute is posted only after rw2's read model no longer lists the removed device RwDevices.Evict
A refused removal throws rw2's own words (status + body); nothing is put back, nothing is torn down RwDevices.Evict
One Differences line per replaced device: the station's FairinoTrack replaced by SimulatorTrack (in By) RwDevices.Replacing
A program that does not open is REPORTED with rw2's answer, never thrown RwPrograms.OpensHere
Snapshot() records programs.json — from the station while it serves, else from the claim (on site the snapshot is taken after the stop). A snapshot without it (package ≤ 0.2.1) still opens; Programs is null Station.Snapshot, ClaimRecord.Programs, Rw2Snapshot.Programs

How it matches the station

Real() · EmulatedBy(endpoint) · SimulatedBy(deviceType, endpoint) · .Keeping(x => …) produce both the rw2 registration and a Match, so a twin always says how alike it is:

FR20      simulated  Simulator                not: the Fairino driver
7th Axis  simulated  SimulatorTrack           not: the FairinoTrack driver, travel, speed, tolerance (rw2 refused: 400 …)
Megmeet   simulated  SimulatorWeldingMachine  not: the MegmeetWeldingMachine_ArtsenPlusPro driver
Plasma    simulated  SimulatorWeldingMachine  not: the FairinoDoWeldingMachine driver
41943012  simulated  MjpegCamera              calibration kept · not: the Camera driver

.Keeping(x => x.Travel, …) is not decoration. rw2's SimulatorTrack driver clamps every commanded position client-side into its own TravelMinMm/TravelMaxMm (SimulatorTrackConfig.cs:31-34 and Clamp at :83), whose defaults are ±1000 mm. A Pamet script that sets the rail's travel on the simulator but not on the rw2 device cannot command a 5000 mm move at all — it is silently clamped. Set the same travel on both.

calibration kept is a claim about the CELL's lens. The camera's SimulatedBy puts it in Kept whenever the description carries a Calibration and a robot to bind it to — which states that this twin runs the station's own optics. A script that seeds the SIMULATOR's ground-truth calibration instead must leave the description's Calibration null, or the twin claims a fidelity it does not have; record what it really runs with .NotCovering("the station's lens — the simulator's own optics are used").

Two values are demoted rather than claimed:

  • The travel, speed, tolerance (rw2 refused: …) line is RW-1: POST /api/peripheral-devices validates against the registered property types rather than the device type's own schema, so SimulatorTrack's declared TravelMinMm, MaxSpeedMmPerSecond and ToleranceMm are answered with 400. The device is registered without them and the twin says so. When RW-1 lands, those three move to kept with no change to a station script.

Only rw2's OWN device types live here (Simulator, SimulatorTrack, SimulatorWeldingMachine, MjpegCamera). A vendor's device type is a string in the station's description — this package never references a vendor package.

Writing a station script

A station script is usually a file-based C# app (dotnet run up.cs). Those disable reflection-based JSON, and every call in this package would fail with "Reflection-based serialization has been disabled for this application" — so a script needs one line above its #:package directives:

#:property JsonSerializerIsReflectionEnabledByDefault=true

Test

dotnet build src/StationManagement.sln
dotnet test  src/StationManagement.sln --filter "Category!=Docker"   # pure logic
dotnet test  src/StationManagement.sln --filter "Category=Docker"    # a real station

This package has its own solution, src/StationManagement.sln, and is deliberately NOT in src/RocketWelder.sln: rw2's CI and its docker build must never restore from the internal feed over a package rw2 does not ship.

The Docker suite brings a whole twin up on a developer machine — it needs the rw2 image (20 GB) and modelingevolution/simulator:local, so it never runs in CI.

Authoring a program headlessly

Our own test programs, written as the program reads. Etags, JsonObjects and block anchors stay inside the package:

await rw.Programs.Ensure("Pamet", "AxisThenArm", p => p
    .AxisMove(500)              // the external axis, mm — omit the axis name where the cell leaves no choice
    .CapturePoint("A")          // teaches the pose the cell is STANDING IN, then declares it
    .MoveLinear("A"));          // a straight-line arm move; travel defaults to "20%"

var run = await rw.Programs.Run("AxisThenArm");
await run.ShouldFinishWithin(2.Minutes());

Ensure creates the repository, the program and its blocks and compiles it, and is idempotent — a station script run twice converges instead of authoring the program twice. Anything beyond these three steps is rw2's own rw.Api.Programs (block editing, arcs, corners, splines); this package does not re-wrap it.

Worth knowing before writing station tests:

  • CapturePoint reads the live cell, and refuses if anything moved, is not Standstill, is faulted, unhomed or slow to answer (400; a 422 means there is no repository directory to teach into). It also records every registered axis in the point, and at run time a move to it is refused unless each axis is still at its taught value — so park the axis where the run will have it, then capture. That ordering is what makes AxisMove(500) … CapturePoint("A") … MoveLinear("A") work: the point is taught at 500 mm, and the run drives the axis there before the arm moves.
  • Blocks bind devices by INTERFACE, never by name — the generated program resolves IRobot / ILinearTrack, so a device registered only under a name is unreachable.
  • A non-dry run of a program with an enabled Pause is refused (400): nobody is at a run screen to resume it.
  • AxisMove needs rw2 2.21.35 (RW-10 — an older rw2 refuses that one step with 400 naming the block type), and so does .Keeping(x => x.Travel, …) carrying the station's axis values (RW-1).
  • An out-of-travel target behaves differently in a twin than on the cell. rw2's SimulatedTrack driver CLAMPS a commanded position into its own travel (SimulatorTrackConfig.Clamp, ±1000 mm by default) where a real Fairino REJECTS it with OutOfRange — so never write a twin test that expects a refusal there; it will pass on the cell and fail in the twin, or worse, the reverse.

Which rw2 it works against

Minimum: rw2 2.21.18, and 2.21.35 for authoring an AxisMove over REST (RW-10). Devices.Replace needs DELETE /api/devices/{id}, first released in 2.21.20 (git tag --contains 2345ed20). An axis that carries the station's travel, speed and tolerance (RW-1) is in no release yet: 2.29.7 answers 400 "Invalid value '0' for property 'TravelMinMm'" and its driver clamps the rail at 1000 mm (measured 2026-09-28, docs/epics/epic-107-station-twin/snapshot-dev-log.md); feature/epic-107-rest-honesty carries it. Everything else works from 2.21.18. That is the first release carrying the headless POST /api/repositories/{repoId}/programs this package creates its own programs with (git tag --contains 3a2d6395). It also needs PATCH /api/pipeline/{id}/autostart, POST /api/pipeline/import, and — for Cameras.Ensure to bind a calibration — POST /api/cameras/calibration/seed-handeye, which rw2 serves only in the Development environment (the host sets ASPNETCORE_ENVIRONMENT=Development for exactly that reason).

Proven against 2.21.34, the version the integration suite runs; Replace and the program probe against 2.29.7, with and without the rest-honesty build mounted (ReplaceOnRw2Tests). Anything between 2.21.18 and that is untested.

Published

Internal feed only (nuget.modelingevolution.com) — never nuget.org.

A release tag station-management-v1.2.3 → .github/workflows/publish-station-management-nuget.yml
A feature branch X.Y.Z-preview.<sha> → .github/workflows/preview-station-management-nuget.yml

The package carries its own version, not rw2's image version: its surface moves when rw2's REST language moves, which is much rarer than an image build. What ties it to rw2 is the minimum version stated above.

No packages depend on ModelingEvolution.RocketWelder.StationManagement.

Version Downloads Last updated
0.3.1-preview.f72598b12 4 10/11/2026
0.3.1-preview.becb30244 43 10/07/2026
0.3.1-preview.ab52494fc 0 10/07/2026
0.3.1-preview.aa0b72dc8 0 10/11/2026
0.3.1-preview.7933f6e4b 0 10/07/2026
0.3.1-preview.52c3c4d2a 22 10/07/2026
0.3.1-preview.388e4a9ab 46 10/11/2026
0.3.1-preview.332633ffd 0 10/07/2026
0.3.0 6 09/28/2026
0.2.2-preview.f8d5e697f 0 09/28/2026
0.2.2-preview.f13421609 0 09/28/2026
0.2.2-preview.c7d3e39d3 0 09/28/2026
0.2.1 2 09/25/2026
0.2.1-preview.8952a5eb 0 09/25/2026
0.2.1-preview.7da5ea94 0 09/25/2026
0.2.1-preview.4ada13b8 0 09/25/2026
0.2.1-preview.66486488 0 09/25/2026
0.2.0 1 09/25/2026
0.1.0-preview.e705254e 0 09/25/2026
0.1.0-preview.e32fdfa2 2 09/18/2026
0.1.0-preview.bcbf4393 0 09/25/2026
0.1.0-preview.ae609c75 1 09/18/2026
0.1.0-preview.887555e 0 09/18/2026
0.1.0-preview.81feef9 0 09/18/2026
0.1.0-preview.7fcc18f4 0 09/18/2026
0.1.0-preview.5f287869 1 09/18/2026
0.1.0-preview.2c875b27 1 09/18/2026
0.1.0-preview.7093278 0 09/18/2026