@tsplc/sim 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/serve.ts ADDED
@@ -0,0 +1,113 @@
1
+ import { z } from "zod";
2
+ import { page } from "./page";
3
+ import type { Simulation } from "./simulation";
4
+
5
+ /** An operator or test action the view offers, such as "Receive bin at Framing01". */
6
+ export interface SimulationAction {
7
+ id: string;
8
+ label: string;
9
+ /** The station, part or load it concerns; the view lists it there. */
10
+ at?: string;
11
+ run: () => Promise<void> | void;
12
+ }
13
+
14
+ export interface ServeSimulationOptions {
15
+ hostname: string;
16
+ port: number;
17
+ title?: string;
18
+ periodMs?: number;
19
+ /** Actions currently available, recomputed for every view refresh. */
20
+ actions?: () => SimulationAction[];
21
+ /** Extra CSS for the page, such as colour overrides. */
22
+ css?: string;
23
+ log?: { warn: (message: string, error: unknown) => void };
24
+ }
25
+
26
+ const controlSchema = z.object({ pause: z.boolean().optional(), step: z.boolean().optional() });
27
+ const deviceActionSchema = z.object({
28
+ device: z.string(),
29
+ action: z.string(),
30
+ argument: z.string().default(""),
31
+ });
32
+
33
+ const loadSchema = z.object({ id: z.string() });
34
+
35
+ const json = (body: unknown, status = 200) =>
36
+ Response.json(body, { status, headers: { "Cache-Control": "no-store" } });
37
+
38
+ /**
39
+ * Runs a simulation in real time and serves its live view: the line drawn from its topology,
40
+ * device inspector and actions, the application's actions, removing loads by hand, and pause/step.
41
+ */
42
+ export const serveSimulation = (
43
+ simulation: Pick<Simulation, "state" | "step" | "act" | "remove" | "run">,
44
+ options: ServeSimulationOptions,
45
+ ) => {
46
+ const controller = new AbortController();
47
+ const periodMs = options.periodMs ?? 50;
48
+ let paused = false;
49
+ const actions = () => options.actions?.() ?? [];
50
+ const attempt = async (run: () => Promise<void> | void) => {
51
+ try {
52
+ await run();
53
+ return json({ ok: true });
54
+ } catch (error) {
55
+ options.log?.warn("Simulation action failed", error);
56
+ return json({ message: error instanceof Error ? error.message : String(error) }, 409);
57
+ }
58
+ };
59
+ const server = Bun.serve({
60
+ hostname: options.hostname,
61
+ port: options.port,
62
+ routes: {
63
+ "/": () =>
64
+ new Response(page({ title: options.title ?? "Simulation", css: options.css ?? "" }), {
65
+ headers: { "Content-Type": "text/html; charset=utf-8" },
66
+ }),
67
+ "/state": () =>
68
+ json({
69
+ ...simulation.state(),
70
+ paused,
71
+ actions: actions().map(({ id, label, at }) => ({ id, label, at })),
72
+ }),
73
+ "/control": {
74
+ POST: async (request) => {
75
+ const command = controlSchema.parse(await request.json());
76
+ if (command.pause !== undefined) paused = command.pause;
77
+ if (command.step) await simulation.step(periodMs);
78
+ return json({ paused });
79
+ },
80
+ },
81
+ "/devices/act": {
82
+ POST: async (request) => {
83
+ const { device, action, argument } = deviceActionSchema.parse(await request.json());
84
+ return attempt(() => simulation.act(device, action, argument));
85
+ },
86
+ },
87
+ "/loads/remove": {
88
+ POST: async (request) => {
89
+ const { id } = loadSchema.parse(await request.json());
90
+ return attempt(() => simulation.remove(id));
91
+ },
92
+ },
93
+ "/actions/:id": {
94
+ POST: (request) => {
95
+ const action = actions().find(({ id }) => id === request.params.id);
96
+ return action
97
+ ? attempt(action.run)
98
+ : json({ message: "Action no longer available" }, 404);
99
+ },
100
+ },
101
+ },
102
+ fetch: () => json({ message: "Not found" }, 404),
103
+ });
104
+ const running = simulation.run(controller.signal, { periodMs, paused: () => paused });
105
+ return {
106
+ url: server.url,
107
+ stop: async () => {
108
+ controller.abort();
109
+ await running;
110
+ await server.stop(true);
111
+ },
112
+ };
113
+ };
@@ -0,0 +1,533 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { type Device, type DeviceView, wirePath } from "./hardware";
3
+ import { drawLine } from "./layout";
4
+ import {
5
+ type CustomPart,
6
+ type Line,
7
+ type Motion,
8
+ type Part,
9
+ type Station,
10
+ type Target,
11
+ targetNames,
12
+ } from "./line";
13
+
14
+ /** Reads and writes PLC variables by dotted path. */
15
+ export interface SimulationIo {
16
+ read: (paths: readonly string[]) => Promise<ReadonlyMap<string, unknown>>;
17
+ write: (values: ReadonlyMap<string, unknown>) => Promise<void>;
18
+ }
19
+
20
+ export interface Load<Data> {
21
+ readonly id: string;
22
+ readonly kind: string;
23
+ readonly length: number;
24
+ readonly width: number;
25
+ /** What a reader reports for this load, such as its barcode; relabelling changes it. */
26
+ code: string;
27
+ data: Data;
28
+ }
29
+
30
+ /** Where a load is: its leading edge along a conveyor, or held at a station. */
31
+ type Place =
32
+ | {
33
+ kind: "conveyor";
34
+ conveyor: string;
35
+ head: number;
36
+ lateral: number;
37
+ /** While its tail still hangs off the conveyor it came from, entered at `at`. */
38
+ from?: { conveyor: string; at: number };
39
+ }
40
+ | { kind: "station"; station: string }
41
+ | { kind: "waiting"; conveyor: string };
42
+
43
+ interface Placed<Data> {
44
+ load: Load<Data>;
45
+ place: Place;
46
+ }
47
+
48
+ interface Segment {
49
+ id: string;
50
+ part: CustomPart;
51
+ start: number;
52
+ end: number;
53
+ }
54
+ interface Point {
55
+ id: string;
56
+ part: Exclude<Part, CustomPart>;
57
+ at: number;
58
+ }
59
+ interface Track {
60
+ id: string;
61
+ draw: string;
62
+ length: number;
63
+ next?: { to: string; at: number };
64
+ segments: Segment[];
65
+ points: Point[];
66
+ }
67
+
68
+ export interface SimulationState {
69
+ timeMs: number;
70
+ error: string | null;
71
+ conveyors: Array<{
72
+ id: string;
73
+ draw: string;
74
+ length: number;
75
+ next?: string;
76
+ parts: Array<{
77
+ id: string;
78
+ kind: Part["kind"];
79
+ label?: string;
80
+ start: number;
81
+ end: number;
82
+ exits?: { left?: string; right?: string };
83
+ station?: string;
84
+ side?: "left" | "right";
85
+ view: DeviceView;
86
+ }>;
87
+ }>;
88
+ stations: Array<{ id: string; label: string; loads: number }>;
89
+ loads: Array<{
90
+ id: string;
91
+ kind: string;
92
+ code: string;
93
+ length: number;
94
+ width: number;
95
+ place: Place;
96
+ }>;
97
+ devices: Array<{
98
+ id: string;
99
+ /** The part it belongs to; null for devices off the conveyors, such as pushbuttons. */
100
+ part: string | null;
101
+ model: string;
102
+ view: DeviceView;
103
+ pins: Record<string, unknown>;
104
+ actions: readonly string[];
105
+ }>;
106
+ }
107
+
108
+ const same = (a: unknown, b: unknown) =>
109
+ Array.isArray(a) && Array.isArray(b)
110
+ ? a.length === b.length && a.every((item, index) => item === b[index])
111
+ : a === b;
112
+
113
+ const resolve = (
114
+ id: string,
115
+ conveyor: Line["conveyors"][string],
116
+ draw: string,
117
+ name: (target: Target) => string,
118
+ ): Track => {
119
+ const segments: Segment[] = [];
120
+ const points: Point[] = [];
121
+ let position = 0;
122
+ conveyor.parts.forEach((part, index) => {
123
+ const partId = part.id ?? `${id}[${index}]`;
124
+ if (part.kind !== "part") {
125
+ points.push({ id: partId, part, at: position + (part.offset ?? 0) });
126
+ return;
127
+ }
128
+ segments.push({ id: partId, part, start: position, end: position + part.length });
129
+ position += part.length;
130
+ });
131
+ return {
132
+ id,
133
+ draw,
134
+ length: position,
135
+ segments,
136
+ points,
137
+ ...(conveyor.next !== undefined && {
138
+ next:
139
+ "to" in conveyor.next
140
+ ? { to: name(conveyor.next.to), at: conveyor.next.at }
141
+ : { to: name(conveyor.next), at: 0 },
142
+ }),
143
+ };
144
+ };
145
+
146
+ const devicesOf = (segment: Segment): Array<[string, Device]> =>
147
+ Object.entries(segment.part.devices).map(([name, device]) => [`${segment.id}.${name}`, device]);
148
+
149
+ const along = (motion: Motion) => ("along" in motion ? Math.max(0, motion.along) : 0);
150
+
151
+ /**
152
+ * Runs a line against a PLC. Each tick reads the PLC outputs the devices are wired to,
153
+ * senses the line, updates every device, writes the changed PLC inputs, then moves loads.
154
+ */
155
+ export const createSimulation = <Data = unknown, Definition extends Line = Line>(
156
+ line: Definition,
157
+ options: { io: SimulationIo },
158
+ ) => {
159
+ type ConveyorName = keyof Definition["conveyors"] & string;
160
+ type StationName = keyof NonNullable<Definition["stations"]> & string;
161
+ const names = targetNames(line);
162
+ const name = (target: Target) => {
163
+ const found = names.get(target);
164
+ if (found === undefined) throw new Error("A part leads outside the line.");
165
+ return found;
166
+ };
167
+ const drawings = drawLine(line);
168
+ const tracks = new Map(
169
+ Object.entries(line.conveyors).map(([id, item]) => [
170
+ id,
171
+ resolve(id, item, drawings.get(item)!, name),
172
+ ]),
173
+ );
174
+ const stations = new Map<string, Station>(Object.entries(line.stations ?? {}));
175
+ const devices = new Map<string, Device>();
176
+ const lineDevices = new Set(Object.keys(line.devices ?? {}));
177
+ for (const [id, device] of Object.entries(line.devices ?? {})) devices.set(id, device);
178
+ for (const track of tracks.values()) {
179
+ for (const segment of track.segments)
180
+ for (const [id, device] of devicesOf(segment)) devices.set(id, device);
181
+ for (const point of track.points)
182
+ if (point.part.kind === "reader") devices.set(point.id, point.part.device);
183
+ }
184
+ const reads = [...new Set([...devices.values()].flatMap((device) => device.reads))];
185
+ const placed = new Map<string, Placed<Data>>();
186
+ const written = new Map<string, unknown>();
187
+ const pendingReads = new Map<string, string[]>();
188
+ let timeMs = 0,
189
+ sequence = 0,
190
+ error: string | null = null;
191
+
192
+ const onConveyor = (conveyor: string) =>
193
+ [...placed.values()]
194
+ .flatMap(({ load, place }) =>
195
+ place.kind === "conveyor" && place.conveyor === conveyor ? [{ load, place }] : [],
196
+ )
197
+ .sort((a, b) => b.place.head - a.place.head);
198
+ /** Loads whose footprint covers part of this conveyor, with their head in its coordinates. */
199
+ const footprints = (track: Track) => [
200
+ ...onConveyor(track.id).map(({ load, place }) => ({ load, head: place.head, place })),
201
+ ...(track.next && tracks.has(track.next.to)
202
+ ? onConveyor(track.next.to).flatMap(({ load, place }) =>
203
+ place.from?.conveyor === track.id && place.head - place.from.at < load.length
204
+ ? [{ load, head: track.length + place.head - place.from.at, place }]
205
+ : [],
206
+ )
207
+ : []),
208
+ ];
209
+ const covers = (head: number, length: number, at: number) => head - length <= at && at <= head;
210
+ const stationLoads = (id: string) =>
211
+ [...placed.values()].filter(({ place }) => place.kind === "station" && place.station === id)
212
+ .length;
213
+
214
+ const sense = () => {
215
+ const writes = new Map<string, unknown>();
216
+ for (const track of tracks.values()) {
217
+ const loads = footprints(track);
218
+ const blocked = (at: number) =>
219
+ loads.some(
220
+ ({ load, head, place }) =>
221
+ covers(head, load.length, at) && Math.abs(place.lateral) < load.width / 2,
222
+ );
223
+ for (const segment of track.segments) {
224
+ const onPart = loads
225
+ .filter(({ load, head }) => head > segment.start && head - load.length < segment.end)
226
+ .map(({ load, head, place }) => ({
227
+ head: head - segment.start,
228
+ tail: head - load.length - segment.start,
229
+ lateral: place.lateral,
230
+ }));
231
+ const sensed = segment.part.sense?.(
232
+ {
233
+ blocked: (at) => blocked(segment.start + at),
234
+ occupied: onPart.length > 0,
235
+ loads: onPart,
236
+ },
237
+ segment.part.devices,
238
+ );
239
+ for (const [wire, value] of sensed ?? []) writes.set(wirePath(wire), value);
240
+ }
241
+ for (const point of track.points) {
242
+ if (point.part.kind === "photoeye")
243
+ writes.set(wirePath(point.part.output), blocked(point.at));
244
+ else if (point.part.kind === "reader") {
245
+ const queue = pendingReads.get(point.id) ?? [];
246
+ point.part.device.world.in.read = queue.shift() ?? null;
247
+ pendingReads.set(point.id, queue);
248
+ }
249
+ }
250
+ }
251
+ for (const [id, item] of stations)
252
+ for (const sensor of item.sensors ?? [])
253
+ writes.set(wirePath(sensor.output), sensor.active({ loads: stationLoads(id) }));
254
+ return writes;
255
+ };
256
+
257
+ /** Puts a load onto a conveyor with its head at `head`, or into a station, if there is room. */
258
+ const enter = (
259
+ target: string,
260
+ load: Load<Data>,
261
+ head: number,
262
+ from?: { conveyor: string; at: number },
263
+ ) => {
264
+ const track = tracks.get(target);
265
+ if (track) {
266
+ const blocked = onConveyor(target).some(
267
+ (other) =>
268
+ other.load !== load &&
269
+ other.place.head > head - load.length &&
270
+ other.place.head - other.load.length < head,
271
+ );
272
+ if (blocked) return false;
273
+ placed.set(load.id, {
274
+ load,
275
+ place: {
276
+ kind: "conveyor",
277
+ conveyor: target,
278
+ head,
279
+ lateral: 0,
280
+ ...(from !== undefined && { from }),
281
+ },
282
+ });
283
+ return true;
284
+ }
285
+ const capacity = stations.get(target)?.capacity;
286
+ if (capacity !== undefined && stationLoads(target) >= capacity) return false;
287
+ placed.set(load.id, { load, place: { kind: "station", station: target } });
288
+ return true;
289
+ };
290
+
291
+ const move = (dtMs: number) => {
292
+ for (const track of tracks.values()) {
293
+ const motions = new Map(
294
+ track.segments.map((segment) => [segment, segment.part.motion(segment.part.devices)]),
295
+ );
296
+ let limit = Number.POSITIVE_INFINITY;
297
+ for (const { load, place } of onConveyor(track.id)) {
298
+ if (place.kind !== "conveyor") continue;
299
+ const tail = place.head - load.length;
300
+ // A part moving loads across, such as lifting belts, carries a load whose middle is on it.
301
+ const middle = place.head - load.length / 2;
302
+ const across = track.segments.find(
303
+ (segment) => segment.start <= middle && middle <= segment.end,
304
+ );
305
+ const motion = across && motions.get(across);
306
+ if (across && motion && "across" in motion) {
307
+ place.lateral += (motion.across * dtMs) / 1000;
308
+ const width = across.part.width ?? 0;
309
+ const exit = across.part.exits?.[place.lateral > 0 ? "right" : "left"];
310
+ // A missing or full exit holds the load at the part's edge.
311
+ if (
312
+ Math.abs(place.lateral) >= width &&
313
+ (exit === undefined || !enter(name(exit), load, load.length))
314
+ )
315
+ place.lateral = Math.sign(place.lateral) * width;
316
+ limit = tail;
317
+ continue;
318
+ }
319
+ if (place.lateral !== 0) {
320
+ limit = tail;
321
+ continue;
322
+ }
323
+ const covered = track.segments.filter(
324
+ ({ start, end }) => start < place.head && end > Math.max(tail, 0),
325
+ );
326
+ const speed = covered.length
327
+ ? Math.min(...covered.map((segment) => along(motions.get(segment) ?? { held: true })))
328
+ : 0;
329
+ let target = Math.min(place.head + (speed * dtMs) / 1000, limit);
330
+ let picked = false;
331
+ for (const point of track.points) {
332
+ if (target < point.at) continue;
333
+ // An operator takes it off here; it waits on the point while the station is full.
334
+ if (point.part.kind === "pickup") {
335
+ if (picked || place.head > point.at) continue;
336
+ picked = enter(name(point.part.station), load, 0);
337
+ if (!picked) target = point.at;
338
+ } else if (point.part.kind === "reader" && place.head < point.at)
339
+ pendingReads.set(point.id, [...(pendingReads.get(point.id) ?? []), load.code]);
340
+ }
341
+ if (picked) continue;
342
+ const next = track.next;
343
+ if (target > track.length && next !== undefined) {
344
+ const entered = enter(next.to, load, next.at + target - track.length, {
345
+ conveyor: track.id,
346
+ at: next.at,
347
+ });
348
+ if (!entered) place.head = track.length;
349
+ } else place.head = Math.min(target, track.length);
350
+ limit = place.head - load.length;
351
+ }
352
+ }
353
+ for (const { load, place } of placed.values())
354
+ if (place.kind === "waiting") enter(place.conveyor, load, load.length);
355
+ };
356
+
357
+ const pointView = (point: Point): DeviceView => {
358
+ switch (point.part.kind) {
359
+ case "reader":
360
+ return point.part.device.view();
361
+ case "photoeye":
362
+ return { status: written.get(wirePath(point.part.output)) ? "busy" : "idle" };
363
+ case "pickup":
364
+ return {
365
+ status: stationLoads(name(point.part.station)) ? "busy" : "idle",
366
+ label: name(point.part.station),
367
+ };
368
+ }
369
+ };
370
+
371
+ const segmentView = (segment: Segment): DeviceView => {
372
+ const views = devicesOf(segment).map(([, device]) => device.view());
373
+ if (!views.length)
374
+ return {
375
+ status: along(segment.part.motion(segment.part.devices)) > 0 ? "running" : "stopped",
376
+ };
377
+ const order = ["fault", "warning", "busy", "running", "stopped", "idle"] as const;
378
+ return views.reduce((worst, view) =>
379
+ order.indexOf(view.status) < order.indexOf(worst.status) ? view : worst,
380
+ );
381
+ };
382
+
383
+ const simulation = {
384
+ /** Advances the line by `dtMs`, exchanging I/O with the PLC once. */
385
+ tick: async (dtMs: number) => {
386
+ const plc = await options.io.read(reads);
387
+ const writes = sense();
388
+ for (const device of devices.values())
389
+ for (const [path, value] of device.update(plc, dtMs)) writes.set(path, value);
390
+ const changed = new Map(
391
+ [...writes].filter(([path, value]) => !same(written.get(path), value)),
392
+ );
393
+ if (changed.size) {
394
+ await options.io.write(changed);
395
+ for (const [path, value] of changed) written.set(path, value);
396
+ }
397
+ move(dtMs);
398
+ timeMs += dtMs;
399
+ },
400
+ /** Ticks in real time until aborted, skipping ticks while `paused()`; failed ticks are reported in `state().error`. */
401
+ run: async (
402
+ signal: AbortSignal,
403
+ options: { periodMs?: number; paused?: () => boolean } = {},
404
+ ) => {
405
+ const periodMs = options.periodMs ?? 50;
406
+ let last = performance.now();
407
+ while (!signal.aborted) {
408
+ const now = performance.now();
409
+ if (!options.paused?.()) await simulation.step(Math.min(now - last, 1000));
410
+ last = now;
411
+ await delay(Math.max(0, periodMs - (performance.now() - now)), undefined, {
412
+ signal,
413
+ }).catch(() => undefined);
414
+ }
415
+ },
416
+ /** One tick that records a failure in `state().error` instead of throwing, then rewrites every input. */
417
+ step: async (dtMs: number) => {
418
+ try {
419
+ await simulation.tick(dtMs);
420
+ error = null;
421
+ } catch (cause) {
422
+ error = cause instanceof Error ? cause.message : String(cause);
423
+ // The PLC may have restarted or missed writes, so send every input again.
424
+ written.clear();
425
+ }
426
+ },
427
+ /** Queues a load at a conveyor's start; it enters once there is room. */
428
+ addLoad: (
429
+ conveyor: ConveyorName,
430
+ load: { kind: keyof Definition["loads"] & string; code: string; data: Data },
431
+ ) => {
432
+ const kind = line.loads[load.kind];
433
+ if (!kind) throw new Error(`Unknown load kind ${load.kind}.`);
434
+ if (!tracks.has(conveyor)) throw new Error(`Unknown conveyor ${conveyor}.`);
435
+ const created: Load<Data> = {
436
+ id: `L${++sequence}`,
437
+ length: kind.length,
438
+ width: kind.width ?? kind.length,
439
+ ...load,
440
+ };
441
+ placed.set(created.id, { load: created, place: { kind: "waiting", conveyor } });
442
+ return created;
443
+ },
444
+ /**
445
+ * Moves a load as an operator would: into a station, onto a conveyor's start once there is
446
+ * room, or onto a conveyor with its tail at `at` mm (failing if that spot is taken).
447
+ */
448
+ place: (id: string, target: ConveyorName | StationName, at?: number) => {
449
+ const entry = placed.get(id);
450
+ if (!entry) throw new Error(`Unknown load ${id}.`);
451
+ if (tracks.has(target) && at === undefined)
452
+ entry.place = { kind: "waiting", conveyor: target };
453
+ else if (!enter(target, entry.load, (at ?? 0) + entry.load.length))
454
+ throw new Error(`No room for ${entry.load.code} at ${target}.`);
455
+ },
456
+ remove: (id: string) => {
457
+ placed.delete(id);
458
+ },
459
+ loads: () => [...placed.values()].map(({ load, place }) => ({ load, place })),
460
+ loadsAt: (station: StationName) =>
461
+ [...placed.values()]
462
+ .filter(({ place }) => place.kind === "station" && place.station === station)
463
+ .map(({ load }) => load),
464
+ act: (device: string, action: string, argument = "") => {
465
+ const target = devices.get(device);
466
+ if (!target) throw new Error(`Unknown device ${device}.`);
467
+ target.act(action, argument);
468
+ },
469
+ state: (): SimulationState => ({
470
+ timeMs,
471
+ error,
472
+ conveyors: [...tracks.values()].map((track) => ({
473
+ id: track.id,
474
+ draw: track.draw,
475
+ length: track.length,
476
+ ...(track.next !== undefined && { next: track.next.to }),
477
+ parts: [
478
+ ...track.segments.map((segment) => ({
479
+ id: segment.id,
480
+ kind: segment.part.kind,
481
+ ...(segment.part.label !== undefined && { label: segment.part.label }),
482
+ start: segment.start,
483
+ end: segment.end,
484
+ ...(segment.part.exits && {
485
+ exits: {
486
+ ...(segment.part.exits.left && { left: name(segment.part.exits.left) }),
487
+ ...(segment.part.exits.right && { right: name(segment.part.exits.right) }),
488
+ },
489
+ }),
490
+ view: segmentView(segment),
491
+ })),
492
+ ...track.points.map((point) => ({
493
+ id: point.id,
494
+ kind: point.part.kind,
495
+ start: point.at,
496
+ end: point.at,
497
+ ...(point.part.kind === "pickup" && {
498
+ station: name(point.part.station),
499
+ side: point.part.side ?? "right",
500
+ }),
501
+ view: pointView(point),
502
+ })),
503
+ ],
504
+ })),
505
+ stations: [...stations].map(([id, item]) => ({
506
+ id,
507
+ label: item.label ?? id,
508
+ loads: stationLoads(id),
509
+ })),
510
+ loads: [...placed.values()].map(({ load, place }) => ({
511
+ id: load.id,
512
+ kind: load.kind,
513
+ code: load.code,
514
+ length: load.length,
515
+ width: load.width,
516
+ place: { ...place },
517
+ })),
518
+ devices: [...devices].map(([id, device]) => ({
519
+ id,
520
+ part: lineDevices.has(id) ? null : id.split(".")[0]!,
521
+ model: device.model,
522
+ view: device.view(),
523
+ pins: device.pins(),
524
+ actions: device.actions,
525
+ })),
526
+ }),
527
+ };
528
+ return simulation;
529
+ };
530
+
531
+ export type Simulation<Data = unknown, Definition extends Line = Line> = ReturnType<
532
+ typeof createSimulation<Data, Definition>
533
+ >;