@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/README.md CHANGED
@@ -1,3 +1,7 @@
1
- # Temporary Holding Version
1
+ # @tsplc/sim
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Part of [tsPLC](https://github.com/livingforjesus/tsplc).
4
+
5
+ Plant simulation for tsPLC programs: hardware models (`@tsplc/sim/hardware/*`), line topology with conveyors, zones and stations, loads that move through it, and a live view. A simulation drives the program's IO, so a program runs against its line before the line exists.
6
+
7
+ See [plant simulation](https://github.com/livingforjesus/tsplc/tree/main/docs/guides/plant-simulation.md). MIT licensed.
@@ -0,0 +1,169 @@
1
+ // packages/sim/src/paths.ts
2
+ var pathKey = Symbol("tsplc.path");
3
+ var pathName = (path) => {
4
+ const name = Reflect.get(path, pathKey);
5
+ if (typeof name !== "string" || !name)
6
+ throw new Error("Expected a path from plcPaths().");
7
+ return name;
8
+ };
9
+
10
+ // packages/sim/src/hardware.ts
11
+ var pinIn = (type) => ({ direction: "in", type });
12
+ var pinOut = (type) => ({ direction: "out", type });
13
+ var defaultValue = (type) => type === "bool" ? false : type === "number" ? 0 : [];
14
+ var isScaled = (wire) => ("toPin" in wire);
15
+ var wirePath = (wire) => isScaled(wire) ? pathName(wire.path) : pathName(wire);
16
+ var defineHardware = (model) => (options) => {
17
+ const state = model.setup(options.config);
18
+ const world = model.world();
19
+ const pins = Object.fromEntries(Object.entries(model.pins).map(([name, pin]) => [name, defaultValue(pin.type)]));
20
+ let dtMs = 0;
21
+ const context = () => ({
22
+ pins,
23
+ world,
24
+ config: options.config,
25
+ state,
26
+ dtMs
27
+ });
28
+ const wired = Object.entries(options.wiring).flatMap(([name, wire]) => {
29
+ const pin = model.pins[name];
30
+ if (!pin)
31
+ throw new Error(`${model.name} has no pin ${String(name)}.`);
32
+ const target = wire;
33
+ return target ? [{ name, pin, wire: target, path: wirePath(target) }] : [];
34
+ });
35
+ return {
36
+ model: model.name,
37
+ world,
38
+ reads: wired.filter(({ pin }) => pin.direction === "in").map(({ path }) => path),
39
+ update: (plc, elapsed) => {
40
+ dtMs = elapsed;
41
+ for (const { name, pin, wire, path } of wired) {
42
+ if (pin.direction !== "in" || !plc.has(path))
43
+ continue;
44
+ const value = plc.get(path);
45
+ Reflect.set(pins, name, isScaled(wire) ? wire.toPin(Number(value)) : value);
46
+ }
47
+ model.update(context());
48
+ const writes = new Map;
49
+ for (const { name, pin, wire, path } of wired) {
50
+ if (pin.direction !== "out")
51
+ continue;
52
+ const value = Reflect.get(pins, name);
53
+ writes.set(path, isScaled(wire) ? wire.toPlc(Number(value)) : value);
54
+ }
55
+ return writes;
56
+ },
57
+ view: () => model.view?.(context()) ?? { status: "idle" },
58
+ pins: () => ({ ...pins }),
59
+ actions: Object.keys(model.actions ?? {}),
60
+ act: (action, argument) => {
61
+ const handler = model.actions?.[action];
62
+ if (!handler)
63
+ throw new Error(`${model.name} has no action ${action}.`);
64
+ handler(context(), argument);
65
+ }
66
+ };
67
+ };
68
+
69
+ // packages/sim/src/hardware/cognex.ts
70
+ var pins = {
71
+ TriggerEnable: pinIn("bool"),
72
+ Trigger: pinIn("bool"),
73
+ BufferResultsEnable: pinIn("bool"),
74
+ ResultsAck: pinIn("bool"),
75
+ SoftEvent: pinIn("number"),
76
+ TriggerReady: pinOut("bool"),
77
+ TriggerAck: pinOut("bool"),
78
+ Acquiring: pinOut("bool"),
79
+ MissedAcq: pinOut("bool"),
80
+ DecodeCompleteToggle: pinOut("bool"),
81
+ ResultsBufferOverrun: pinOut("bool"),
82
+ ResultsAvailable: pinOut("bool"),
83
+ GeneralFault: pinOut("bool"),
84
+ SoftEventAck: pinOut("number"),
85
+ TriggerID: pinOut("number"),
86
+ ResultID: pinOut("number"),
87
+ ResultCode: pinOut("number"),
88
+ ResultExtended: pinOut("number"),
89
+ ResultLength: pinOut("number"),
90
+ ResultData: pinOut("bytes")
91
+ };
92
+ var datamanWiring = (record) => Object.fromEntries(Object.entries(pins).map(([name, pin]) => [
93
+ name,
94
+ Reflect.get(pin.direction === "in" ? record.Output : record.Input, name)
95
+ ]));
96
+ var dataman = defineHardware({
97
+ name: "Cognex DataMan",
98
+ pins,
99
+ world: () => ({
100
+ in: { read: null },
101
+ out: {}
102
+ }),
103
+ setup: (config) => ({
104
+ acquisition: null,
105
+ queue: [],
106
+ staged: false,
107
+ reads: 0,
108
+ bufferSize: config.bufferSize ?? 50
109
+ }),
110
+ update: ({ pins: io, world, config, state }) => {
111
+ if (io.ResultData.length !== config.resultSize)
112
+ io.ResultData = Array(config.resultSize).fill(0);
113
+ io.TriggerReady = false;
114
+ io.TriggerAck = false;
115
+ io.Acquiring = false;
116
+ if (!io.BufferResultsEnable)
117
+ state.queue = [];
118
+ if (io.ResultsAck)
119
+ io.ResultsAvailable = false;
120
+ if (state.staged) {
121
+ io.ResultsAvailable = !io.ResultsAck;
122
+ state.staged = false;
123
+ }
124
+ if (state.acquisition) {
125
+ io.DecodeCompleteToggle = !io.DecodeCompleteToggle;
126
+ const room = !io.BufferResultsEnable || state.queue.length + Number(io.ResultsAvailable) < state.bufferSize;
127
+ io.ResultsBufferOverrun = !room;
128
+ if (room)
129
+ state.queue.push(state.acquisition);
130
+ }
131
+ const canPublish = !io.BufferResultsEnable || !io.ResultsAvailable && !io.ResultsAck && !state.staged;
132
+ const result = canPublish ? state.queue.shift() : undefined;
133
+ if (result) {
134
+ io.ResultID = result.id;
135
+ io.ResultCode = result.data.length ? 1 : 0;
136
+ io.ResultExtended = 0;
137
+ io.ResultLength = result.data.length;
138
+ io.ResultData = Array.from({ length: config.resultSize }, (_, i) => result.data[i] ?? 0);
139
+ state.staged = true;
140
+ }
141
+ state.acquisition = world.in.read !== null ? { data: [...new TextEncoder().encode(world.in.read)], id: io.TriggerID } : null;
142
+ if (state.acquisition) {
143
+ state.reads++;
144
+ io.TriggerID = (io.TriggerID + 1) % 65536;
145
+ io.Acquiring = true;
146
+ io.MissedAcq = false;
147
+ }
148
+ },
149
+ view: ({ pins: io, state }) => ({
150
+ status: io.ResultsAvailable ? "busy" : "idle",
151
+ label: io.ResultsAvailable ? "result waiting" : `${state.reads} reads`,
152
+ details: {
153
+ lastResult: new TextDecoder().decode(Uint8Array.from(io.ResultData.slice(0, io.ResultLength))),
154
+ queued: state.queue.length
155
+ }
156
+ }),
157
+ actions: {
158
+ read: ({ world }, code) => {
159
+ world.in.read = code;
160
+ }
161
+ }
162
+ });
163
+ export {
164
+ dataman,
165
+ datamanWiring
166
+ };
167
+
168
+ //# debugId=586C948326CCF0C064756E2164756E21
169
+ //# sourceMappingURL=cognex.js.map
@@ -0,0 +1,12 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/paths.ts", "../src/hardware.ts", "../src/hardware/cognex.ts"],
4
+ "sourcesContent": [
5
+ "declare const valueType: unique symbol;\nconst pathKey = Symbol(\"tsplc.path\");\n\n/** One PLC variable of type `Value`, such as `LineIO.Conveyor_Inbound.Cards[3].Output.MotionAsserted`. */\nexport interface Path<Value> {\n readonly [valueType]?: Value;\n}\n\n/**\n * Typed paths into a PLC's globals: records nest, `.at(index)` selects a record array's element, and\n * scalars and scalar arrays (read and written whole) are `Path`s.\n */\nexport type Paths<T> =\n T extends ReadonlyArray<infer Item>\n ? Item extends object\n ? { readonly at: (index: number) => Paths<Item> }\n : Path<T>\n : T extends object\n ? { readonly [Key in keyof T]: Paths<T[Key]> }\n : Path<T>;\n\nconst join = (segments: readonly string[]) =>\n segments.reduce(\n (path, segment) =>\n /^\\d+$/.test(segment) ? `${path}[${segment}]` : path ? `${path}.${segment}` : segment,\n \"\",\n );\n\n// A function target so that `.at` is callable, and still a path for a record field named `at`.\nconst proxy = (segments: readonly string[]): unknown =>\n new Proxy(() => undefined, {\n get: (_, key) => {\n if (key === pathKey) return join(segments);\n if (typeof key === \"symbol\") return undefined;\n return proxy([...segments, key]);\n },\n apply: (_, __, [index]: [number]) => proxy([...segments.slice(0, -1), String(index)]),\n });\n\n/**\n * Builds typed paths for a generated `PlcGlobals`:\n * `plcPaths<PlcGlobals>().LineIO.Conveyor_Inbound.Cards.at(3).Output.MotionAsserted`.\n */\nexport const plcPaths = <Globals>(): Paths<Globals> => proxy([]) as Paths<Globals>;\n\n/** The dotted variable path, e.g. `LineIO.Conveyor_Inbound.Cards[3].Output.MotionAsserted`. */\nexport const pathName = (path: Path<unknown>): string => {\n const name: unknown = Reflect.get(path, pathKey);\n if (typeof name !== \"string\" || !name) throw new Error(\"Expected a path from plcPaths().\");\n return name;\n};\n",
6
+ "import { type Path, pathName } from \"./paths\";\n\ntype PinType = \"bool\" | \"number\" | \"bytes\";\ntype PinValue<Type extends PinType> = Type extends \"bool\"\n ? boolean\n : Type extends \"number\"\n ? number\n : number[];\n\n/** An electrical terminal, seen from the device: `in` is driven by the PLC, `out` drives a PLC input. */\nexport interface Pin {\n readonly direction: \"in\" | \"out\";\n readonly type: PinType;\n}\nexport const pinIn = <Type extends PinType>(type: Type) => ({ direction: \"in\", type }) as const;\nexport const pinOut = <Type extends PinType>(type: Type) => ({ direction: \"out\", type }) as const;\nexport type Pins = Record<string, Pin>;\n\n/** Converts between a PLC value and a pin value, such as raw analog counts and volts. */\nexport interface Scaled {\n readonly path: Path<number>;\n readonly toPin: (plc: number) => number;\n readonly toPlc: (pin: number) => number;\n}\nexport type Wire<Value> = Path<Value> | (Value extends number ? Scaled : never);\n\n/**\n * A linear analog channel: `analog(path, { plc: [0, 32767], pin: [0, 10] })` wires a raw\n * 0..32767 output to a 0..10 V input.\n */\nexport const analog = (\n path: Path<number>,\n range: { plc: readonly [number, number]; pin: readonly [number, number] },\n): Scaled => {\n const [plcLow, plcHigh] = range.plc,\n [pinLow, pinHigh] = range.pin;\n return {\n path,\n toPin: (plc) => pinLow + ((plc - plcLow) * (pinHigh - pinLow)) / (plcHigh - plcLow),\n toPlc: (pin) => Math.round(plcLow + ((pin - pinLow) * (plcHigh - plcLow)) / (pinHigh - pinLow)),\n };\n};\n\n/** Each pin may be wired to one PLC variable of its type; unwired inputs read as their default. */\nexport type Wiring<Spec extends Pins> = {\n readonly [Name in keyof Spec]?: Wire<PinValue<Spec[Name][\"type\"]>>;\n};\ntype PinValues<Spec extends Pins> = { [Name in keyof Spec]: PinValue<Spec[Name][\"type\"]> };\n\nexport interface DeviceView {\n status: \"running\" | \"stopped\" | \"idle\" | \"busy\" | \"warning\" | \"fault\";\n label?: string;\n details?: Record<string, string | number | boolean>;\n}\n\nexport interface HardwareContext<Spec extends Pins, World extends WorldPorts, Config, State> {\n /** Input pins hold this tick's PLC values; set output pins to drive PLC inputs. */\n readonly pins: PinValues<Spec>;\n /** Physical ports the topology connects: `in` is sensed from the line, `out` acts on it. */\n readonly world: World;\n readonly config: Config;\n readonly state: State;\n readonly dtMs: number;\n}\n\ninterface WorldPorts {\n in: Record<string, unknown>;\n out: Record<string, unknown>;\n}\n\nexport interface HardwareModel<Spec extends Pins, World extends WorldPorts, Config, State> {\n /** Product name, such as \"Intelligrated CS1001\". */\n readonly name: string;\n readonly pins: Spec;\n /** Initial physical port values; they also fix each port's type. */\n readonly world: () => World;\n /** Validates the configuration (switch settings) and creates the device's private state. */\n readonly setup: (config: Config) => State;\n /** Runs once per tick: read input pins and `world.in`, then set output pins and `world.out`. */\n readonly update: (context: HardwareContext<Spec, World, Config, State>) => void;\n readonly view?: (context: HardwareContext<Spec, World, Config, State>) => DeviceView;\n readonly actions?: Record<\n string,\n (context: HardwareContext<Spec, World, Config, State>, argument: string) => void\n >;\n}\n\n/** One physical device on the line: a model, its configuration and its wiring. */\nexport interface Device<World extends WorldPorts = WorldPorts> {\n readonly model: string;\n readonly world: World;\n /** PLC variables this device reads each tick. */\n readonly reads: readonly string[];\n /** Sets input pins from PLC values, runs the model and returns the PLC values it drives. */\n readonly update: (plc: ReadonlyMap<string, unknown>, dtMs: number) => Map<string, unknown>;\n readonly view: () => DeviceView;\n readonly pins: () => Record<string, unknown>;\n readonly actions: readonly string[];\n readonly act: (action: string, argument: string) => void;\n}\n\nconst defaultValue = (type: PinType): PinValue<PinType> =>\n type === \"bool\" ? false : type === \"number\" ? 0 : [];\n\nconst isScaled = (wire: Wire<never> | Scaled | Path<unknown>): wire is Scaled => \"toPin\" in wire;\n/** The PLC variable a wire connects to. */\nexport const wirePath = (wire: Path<unknown> | Scaled) =>\n isScaled(wire) ? pathName(wire.path) : pathName(wire);\n\n/**\n * Defines a hardware model. The result is a factory that instantiates one device from its\n * configuration and wiring: `cs1001({ config: { switches }, wiring: { \"CN2-1\": path } })`.\n */\nexport const defineHardware =\n <Spec extends Pins, World extends WorldPorts, Config, State>(\n model: HardwareModel<Spec, World, Config, State>,\n ) =>\n (options: { config: Config; wiring: Wiring<Spec> }): Device<World> => {\n const state = model.setup(options.config);\n const world = model.world();\n const pins = Object.fromEntries(\n Object.entries(model.pins).map(([name, pin]) => [name, defaultValue(pin.type)]),\n ) as PinValues<Spec>; // Each entry is the default of its declared pin type.\n let dtMs = 0;\n const context = (): HardwareContext<Spec, World, Config, State> => ({\n pins,\n world,\n config: options.config,\n state,\n dtMs,\n });\n const wired = Object.entries(options.wiring).flatMap(([name, wire]) => {\n const pin = model.pins[name];\n if (!pin) throw new Error(`${model.name} has no pin ${String(name)}.`);\n const target: Path<unknown> | Scaled | undefined = wire;\n return target ? [{ name, pin, wire: target, path: wirePath(target) }] : [];\n });\n return {\n model: model.name,\n world,\n reads: wired.filter(({ pin }) => pin.direction === \"in\").map(({ path }) => path),\n update: (plc, elapsed) => {\n dtMs = elapsed;\n for (const { name, pin, wire, path } of wired) {\n if (pin.direction !== \"in\" || !plc.has(path)) continue;\n const value = plc.get(path);\n Reflect.set(pins, name, isScaled(wire) ? wire.toPin(Number(value)) : value);\n }\n model.update(context());\n const writes = new Map<string, unknown>();\n for (const { name, pin, wire, path } of wired) {\n if (pin.direction !== \"out\") continue;\n const value: unknown = Reflect.get(pins, name);\n writes.set(path, isScaled(wire) ? wire.toPlc(Number(value)) : value);\n }\n return writes;\n },\n view: () => model.view?.(context()) ?? { status: \"idle\" },\n pins: () => ({ ...pins }),\n actions: Object.keys(model.actions ?? {}),\n act: (action, argument) => {\n const handler = model.actions?.[action];\n if (!handler) throw new Error(`${model.name} has no action ${action}.`);\n handler(context(), argument);\n },\n };\n };\n",
7
+ "import { defineHardware, pinIn, pinOut, type Wiring } from \"../hardware\";\nimport type { Paths } from \"../paths\";\n\n/** The DataMan EtherNet/IP input (reader → PLC) and output (PLC → reader) assembly fields. */\nexport interface DataManAssembly {\n Input: {\n TriggerReady: boolean;\n TriggerAck: boolean;\n Acquiring: boolean;\n MissedAcq: boolean;\n DecodeCompleteToggle: boolean;\n ResultsBufferOverrun: boolean;\n ResultsAvailable: boolean;\n GeneralFault: boolean;\n SoftEventAck: number;\n TriggerID: number;\n ResultID: number;\n ResultCode: number;\n ResultExtended: number;\n ResultLength: number;\n ResultData: number[];\n };\n Output: {\n TriggerEnable: boolean;\n Trigger: boolean;\n BufferResultsEnable: boolean;\n ResultsAck: boolean;\n SoftEvent: number;\n };\n}\n\nconst pins = {\n TriggerEnable: pinIn(\"bool\"),\n Trigger: pinIn(\"bool\"),\n BufferResultsEnable: pinIn(\"bool\"),\n ResultsAck: pinIn(\"bool\"),\n SoftEvent: pinIn(\"number\"),\n TriggerReady: pinOut(\"bool\"),\n TriggerAck: pinOut(\"bool\"),\n Acquiring: pinOut(\"bool\"),\n MissedAcq: pinOut(\"bool\"),\n DecodeCompleteToggle: pinOut(\"bool\"),\n ResultsBufferOverrun: pinOut(\"bool\"),\n ResultsAvailable: pinOut(\"bool\"),\n GeneralFault: pinOut(\"bool\"),\n SoftEventAck: pinOut(\"number\"),\n TriggerID: pinOut(\"number\"),\n ResultID: pinOut(\"number\"),\n ResultCode: pinOut(\"number\"),\n ResultExtended: pinOut(\"number\"),\n ResultLength: pinOut(\"number\"),\n ResultData: pinOut(\"bytes\"),\n};\n\n/** Wires every assembly field of a PLC record that uses the Cognex field names. */\nexport const datamanWiring = (record: Paths<DataManAssembly>): Wiring<typeof pins> =>\n // Paths are proxies without enumerable keys, so name each field.\n Object.fromEntries(\n Object.entries(pins).map(([name, pin]) => [\n name,\n Reflect.get(pin.direction === \"in\" ? record.Output : record.Input, name),\n ]),\n );\n\nexport interface DataManConfig {\n /** Self (presentation) mode: one acquisition per load passing the reader. */\n trigger: \"self\";\n /** Result data bytes in the input assembly. */\n resultSize: number;\n /** Results the reader buffers before reporting an overrun. */\n bufferSize?: number;\n}\n\ninterface Result {\n data: number[];\n id: number;\n}\n\n/**\n * Cognex DataMan reader on EtherNet/IP, in Self trigger mode with result buffering and the\n * ResultsAvailable/ResultsAck handshake. A load passing the reader is one acquisition; an empty\n * code is a no-read.\n *\n * https://docs.cognex.com/dmst_2541/web/EN/Industrial_Protocols_Manual/Content/Topics/PDF/DIPM/eip-operation.htm\n */\nexport const dataman = defineHardware({\n name: \"Cognex DataMan\",\n pins,\n world: (): { in: { read: string | null }; out: Record<string, never> } => ({\n in: { read: null },\n out: {},\n }),\n setup: (config: DataManConfig) => ({\n acquisition: null as Result | null,\n queue: [] as Result[],\n staged: false,\n reads: 0,\n bufferSize: config.bufferSize ?? 50,\n }),\n update: ({ pins: io, world, config, state }) => {\n // The assembly always carries `resultSize` bytes.\n if (io.ResultData.length !== config.resultSize)\n io.ResultData = Array(config.resultSize).fill(0);\n io.TriggerReady = false;\n io.TriggerAck = false;\n io.Acquiring = false;\n if (!io.BufferResultsEnable) state.queue = [];\n if (io.ResultsAck) io.ResultsAvailable = false;\n // The PLC can scan between separately written fields, so a result's fields go out one\n // tick before ResultsAvailable announces them, as the reader's assembly would arrive whole.\n if (state.staged) {\n io.ResultsAvailable = !io.ResultsAck;\n state.staged = false;\n }\n\n // Last tick's acquisition decodes now.\n if (state.acquisition) {\n io.DecodeCompleteToggle = !io.DecodeCompleteToggle;\n const room =\n !io.BufferResultsEnable ||\n state.queue.length + Number(io.ResultsAvailable) < state.bufferSize;\n io.ResultsBufferOverrun = !room;\n if (room) state.queue.push(state.acquisition);\n }\n\n const canPublish =\n !io.BufferResultsEnable || (!io.ResultsAvailable && !io.ResultsAck && !state.staged);\n const result = canPublish ? state.queue.shift() : undefined;\n if (result) {\n io.ResultID = result.id;\n io.ResultCode = result.data.length ? 1 : 0;\n io.ResultExtended = 0;\n io.ResultLength = result.data.length;\n io.ResultData = Array.from({ length: config.resultSize }, (_, i) => result.data[i] ?? 0);\n state.staged = true;\n }\n\n state.acquisition =\n world.in.read !== null\n ? { data: [...new TextEncoder().encode(world.in.read)], id: io.TriggerID }\n : null;\n if (state.acquisition) {\n state.reads++;\n io.TriggerID = (io.TriggerID + 1) % 65_536;\n io.Acquiring = true;\n io.MissedAcq = false;\n }\n },\n view: ({ pins: io, state }) => ({\n status: io.ResultsAvailable ? \"busy\" : \"idle\",\n label: io.ResultsAvailable ? \"result waiting\" : `${state.reads} reads`,\n details: {\n lastResult: new TextDecoder().decode(\n Uint8Array.from(io.ResultData.slice(0, io.ResultLength)),\n ),\n queued: state.queue.length,\n },\n }),\n actions: {\n /** Presents a code to the reader as if a load passed it; an empty code is a no-read. */\n read: ({ world }, code) => {\n world.in.read = code;\n },\n },\n});\n"
8
+ ],
9
+ "mappings": "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",
10
+ "debugId": "586C948326CCF0C064756E2164756E21",
11
+ "names": []
12
+ }
@@ -0,0 +1,141 @@
1
+ // packages/sim/src/paths.ts
2
+ var pathKey = Symbol("tsplc.path");
3
+ var pathName = (path) => {
4
+ const name = Reflect.get(path, pathKey);
5
+ if (typeof name !== "string" || !name)
6
+ throw new Error("Expected a path from plcPaths().");
7
+ return name;
8
+ };
9
+
10
+ // packages/sim/src/hardware.ts
11
+ var pinIn = (type) => ({ direction: "in", type });
12
+ var pinOut = (type) => ({ direction: "out", type });
13
+ var defaultValue = (type) => type === "bool" ? false : type === "number" ? 0 : [];
14
+ var isScaled = (wire) => ("toPin" in wire);
15
+ var wirePath = (wire) => isScaled(wire) ? pathName(wire.path) : pathName(wire);
16
+ var defineHardware = (model) => (options) => {
17
+ const state = model.setup(options.config);
18
+ const world = model.world();
19
+ const pins = Object.fromEntries(Object.entries(model.pins).map(([name, pin]) => [name, defaultValue(pin.type)]));
20
+ let dtMs = 0;
21
+ const context = () => ({
22
+ pins,
23
+ world,
24
+ config: options.config,
25
+ state,
26
+ dtMs
27
+ });
28
+ const wired = Object.entries(options.wiring).flatMap(([name, wire]) => {
29
+ const pin = model.pins[name];
30
+ if (!pin)
31
+ throw new Error(`${model.name} has no pin ${String(name)}.`);
32
+ const target = wire;
33
+ return target ? [{ name, pin, wire: target, path: wirePath(target) }] : [];
34
+ });
35
+ return {
36
+ model: model.name,
37
+ world,
38
+ reads: wired.filter(({ pin }) => pin.direction === "in").map(({ path }) => path),
39
+ update: (plc, elapsed) => {
40
+ dtMs = elapsed;
41
+ for (const { name, pin, wire, path } of wired) {
42
+ if (pin.direction !== "in" || !plc.has(path))
43
+ continue;
44
+ const value = plc.get(path);
45
+ Reflect.set(pins, name, isScaled(wire) ? wire.toPin(Number(value)) : value);
46
+ }
47
+ model.update(context());
48
+ const writes = new Map;
49
+ for (const { name, pin, wire, path } of wired) {
50
+ if (pin.direction !== "out")
51
+ continue;
52
+ const value = Reflect.get(pins, name);
53
+ writes.set(path, isScaled(wire) ? wire.toPlc(Number(value)) : value);
54
+ }
55
+ return writes;
56
+ },
57
+ view: () => model.view?.(context()) ?? { status: "idle" },
58
+ pins: () => ({ ...pins }),
59
+ actions: Object.keys(model.actions ?? {}),
60
+ act: (action, argument) => {
61
+ const handler = model.actions?.[action];
62
+ if (!handler)
63
+ throw new Error(`${model.name} has no action ${action}.`);
64
+ handler(context(), argument);
65
+ }
66
+ };
67
+ };
68
+
69
+ // packages/sim/src/hardware/generic.ts
70
+ var noWorld = () => ({
71
+ in: {},
72
+ out: {}
73
+ });
74
+ var pushbutton = defineHardware({
75
+ name: "Pushbutton",
76
+ pins: { contact: pinOut("bool") },
77
+ world: noWorld,
78
+ setup: (config) => ({
79
+ pressed: false,
80
+ remainingMs: 0,
81
+ pressMs: config.pressMs ?? 300
82
+ }),
83
+ update: ({ pins, config, state, dtMs }) => {
84
+ if (config.kind === "momentary" && state.pressed) {
85
+ state.remainingMs -= dtMs;
86
+ if (state.remainingMs <= 0)
87
+ state.pressed = false;
88
+ }
89
+ pins.contact = state.pressed;
90
+ },
91
+ view: ({ state }) => ({
92
+ status: state.pressed ? "busy" : "idle",
93
+ label: state.pressed ? "pressed" : "released"
94
+ }),
95
+ actions: {
96
+ press: ({ state }) => {
97
+ state.pressed = true;
98
+ state.remainingMs = state.pressMs;
99
+ },
100
+ release: ({ state }) => {
101
+ state.pressed = false;
102
+ }
103
+ }
104
+ });
105
+ var selector = defineHardware({
106
+ name: "Selector switch",
107
+ pins: { contact: pinOut("bool") },
108
+ world: noWorld,
109
+ setup: (config) => ({ on: config.on }),
110
+ update: ({ pins, state }) => {
111
+ pins.contact = state.on;
112
+ },
113
+ view: ({ state }) => ({ status: state.on ? "running" : "idle", label: state.on ? "on" : "off" }),
114
+ actions: {
115
+ on: ({ state }) => {
116
+ state.on = true;
117
+ },
118
+ off: ({ state }) => {
119
+ state.on = false;
120
+ }
121
+ }
122
+ });
123
+ var lamp = defineHardware({
124
+ name: "Lamp",
125
+ pins: { power: pinIn("bool") },
126
+ world: noWorld,
127
+ setup: (config) => config,
128
+ update: () => {},
129
+ view: ({ pins, state }) => ({
130
+ status: pins.power ? "running" : "idle",
131
+ label: `${state.label ?? "lamp"} ${pins.power ? "on" : "off"}`
132
+ })
133
+ });
134
+ export {
135
+ lamp,
136
+ pushbutton,
137
+ selector
138
+ };
139
+
140
+ //# debugId=E69063BE2BEB4AC064756E2164756E21
141
+ //# sourceMappingURL=generic.js.map
@@ -0,0 +1,12 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/paths.ts", "../src/hardware.ts", "../src/hardware/generic.ts"],
4
+ "sourcesContent": [
5
+ "declare const valueType: unique symbol;\nconst pathKey = Symbol(\"tsplc.path\");\n\n/** One PLC variable of type `Value`, such as `LineIO.Conveyor_Inbound.Cards[3].Output.MotionAsserted`. */\nexport interface Path<Value> {\n readonly [valueType]?: Value;\n}\n\n/**\n * Typed paths into a PLC's globals: records nest, `.at(index)` selects a record array's element, and\n * scalars and scalar arrays (read and written whole) are `Path`s.\n */\nexport type Paths<T> =\n T extends ReadonlyArray<infer Item>\n ? Item extends object\n ? { readonly at: (index: number) => Paths<Item> }\n : Path<T>\n : T extends object\n ? { readonly [Key in keyof T]: Paths<T[Key]> }\n : Path<T>;\n\nconst join = (segments: readonly string[]) =>\n segments.reduce(\n (path, segment) =>\n /^\\d+$/.test(segment) ? `${path}[${segment}]` : path ? `${path}.${segment}` : segment,\n \"\",\n );\n\n// A function target so that `.at` is callable, and still a path for a record field named `at`.\nconst proxy = (segments: readonly string[]): unknown =>\n new Proxy(() => undefined, {\n get: (_, key) => {\n if (key === pathKey) return join(segments);\n if (typeof key === \"symbol\") return undefined;\n return proxy([...segments, key]);\n },\n apply: (_, __, [index]: [number]) => proxy([...segments.slice(0, -1), String(index)]),\n });\n\n/**\n * Builds typed paths for a generated `PlcGlobals`:\n * `plcPaths<PlcGlobals>().LineIO.Conveyor_Inbound.Cards.at(3).Output.MotionAsserted`.\n */\nexport const plcPaths = <Globals>(): Paths<Globals> => proxy([]) as Paths<Globals>;\n\n/** The dotted variable path, e.g. `LineIO.Conveyor_Inbound.Cards[3].Output.MotionAsserted`. */\nexport const pathName = (path: Path<unknown>): string => {\n const name: unknown = Reflect.get(path, pathKey);\n if (typeof name !== \"string\" || !name) throw new Error(\"Expected a path from plcPaths().\");\n return name;\n};\n",
6
+ "import { type Path, pathName } from \"./paths\";\n\ntype PinType = \"bool\" | \"number\" | \"bytes\";\ntype PinValue<Type extends PinType> = Type extends \"bool\"\n ? boolean\n : Type extends \"number\"\n ? number\n : number[];\n\n/** An electrical terminal, seen from the device: `in` is driven by the PLC, `out` drives a PLC input. */\nexport interface Pin {\n readonly direction: \"in\" | \"out\";\n readonly type: PinType;\n}\nexport const pinIn = <Type extends PinType>(type: Type) => ({ direction: \"in\", type }) as const;\nexport const pinOut = <Type extends PinType>(type: Type) => ({ direction: \"out\", type }) as const;\nexport type Pins = Record<string, Pin>;\n\n/** Converts between a PLC value and a pin value, such as raw analog counts and volts. */\nexport interface Scaled {\n readonly path: Path<number>;\n readonly toPin: (plc: number) => number;\n readonly toPlc: (pin: number) => number;\n}\nexport type Wire<Value> = Path<Value> | (Value extends number ? Scaled : never);\n\n/**\n * A linear analog channel: `analog(path, { plc: [0, 32767], pin: [0, 10] })` wires a raw\n * 0..32767 output to a 0..10 V input.\n */\nexport const analog = (\n path: Path<number>,\n range: { plc: readonly [number, number]; pin: readonly [number, number] },\n): Scaled => {\n const [plcLow, plcHigh] = range.plc,\n [pinLow, pinHigh] = range.pin;\n return {\n path,\n toPin: (plc) => pinLow + ((plc - plcLow) * (pinHigh - pinLow)) / (plcHigh - plcLow),\n toPlc: (pin) => Math.round(plcLow + ((pin - pinLow) * (plcHigh - plcLow)) / (pinHigh - pinLow)),\n };\n};\n\n/** Each pin may be wired to one PLC variable of its type; unwired inputs read as their default. */\nexport type Wiring<Spec extends Pins> = {\n readonly [Name in keyof Spec]?: Wire<PinValue<Spec[Name][\"type\"]>>;\n};\ntype PinValues<Spec extends Pins> = { [Name in keyof Spec]: PinValue<Spec[Name][\"type\"]> };\n\nexport interface DeviceView {\n status: \"running\" | \"stopped\" | \"idle\" | \"busy\" | \"warning\" | \"fault\";\n label?: string;\n details?: Record<string, string | number | boolean>;\n}\n\nexport interface HardwareContext<Spec extends Pins, World extends WorldPorts, Config, State> {\n /** Input pins hold this tick's PLC values; set output pins to drive PLC inputs. */\n readonly pins: PinValues<Spec>;\n /** Physical ports the topology connects: `in` is sensed from the line, `out` acts on it. */\n readonly world: World;\n readonly config: Config;\n readonly state: State;\n readonly dtMs: number;\n}\n\ninterface WorldPorts {\n in: Record<string, unknown>;\n out: Record<string, unknown>;\n}\n\nexport interface HardwareModel<Spec extends Pins, World extends WorldPorts, Config, State> {\n /** Product name, such as \"Intelligrated CS1001\". */\n readonly name: string;\n readonly pins: Spec;\n /** Initial physical port values; they also fix each port's type. */\n readonly world: () => World;\n /** Validates the configuration (switch settings) and creates the device's private state. */\n readonly setup: (config: Config) => State;\n /** Runs once per tick: read input pins and `world.in`, then set output pins and `world.out`. */\n readonly update: (context: HardwareContext<Spec, World, Config, State>) => void;\n readonly view?: (context: HardwareContext<Spec, World, Config, State>) => DeviceView;\n readonly actions?: Record<\n string,\n (context: HardwareContext<Spec, World, Config, State>, argument: string) => void\n >;\n}\n\n/** One physical device on the line: a model, its configuration and its wiring. */\nexport interface Device<World extends WorldPorts = WorldPorts> {\n readonly model: string;\n readonly world: World;\n /** PLC variables this device reads each tick. */\n readonly reads: readonly string[];\n /** Sets input pins from PLC values, runs the model and returns the PLC values it drives. */\n readonly update: (plc: ReadonlyMap<string, unknown>, dtMs: number) => Map<string, unknown>;\n readonly view: () => DeviceView;\n readonly pins: () => Record<string, unknown>;\n readonly actions: readonly string[];\n readonly act: (action: string, argument: string) => void;\n}\n\nconst defaultValue = (type: PinType): PinValue<PinType> =>\n type === \"bool\" ? false : type === \"number\" ? 0 : [];\n\nconst isScaled = (wire: Wire<never> | Scaled | Path<unknown>): wire is Scaled => \"toPin\" in wire;\n/** The PLC variable a wire connects to. */\nexport const wirePath = (wire: Path<unknown> | Scaled) =>\n isScaled(wire) ? pathName(wire.path) : pathName(wire);\n\n/**\n * Defines a hardware model. The result is a factory that instantiates one device from its\n * configuration and wiring: `cs1001({ config: { switches }, wiring: { \"CN2-1\": path } })`.\n */\nexport const defineHardware =\n <Spec extends Pins, World extends WorldPorts, Config, State>(\n model: HardwareModel<Spec, World, Config, State>,\n ) =>\n (options: { config: Config; wiring: Wiring<Spec> }): Device<World> => {\n const state = model.setup(options.config);\n const world = model.world();\n const pins = Object.fromEntries(\n Object.entries(model.pins).map(([name, pin]) => [name, defaultValue(pin.type)]),\n ) as PinValues<Spec>; // Each entry is the default of its declared pin type.\n let dtMs = 0;\n const context = (): HardwareContext<Spec, World, Config, State> => ({\n pins,\n world,\n config: options.config,\n state,\n dtMs,\n });\n const wired = Object.entries(options.wiring).flatMap(([name, wire]) => {\n const pin = model.pins[name];\n if (!pin) throw new Error(`${model.name} has no pin ${String(name)}.`);\n const target: Path<unknown> | Scaled | undefined = wire;\n return target ? [{ name, pin, wire: target, path: wirePath(target) }] : [];\n });\n return {\n model: model.name,\n world,\n reads: wired.filter(({ pin }) => pin.direction === \"in\").map(({ path }) => path),\n update: (plc, elapsed) => {\n dtMs = elapsed;\n for (const { name, pin, wire, path } of wired) {\n if (pin.direction !== \"in\" || !plc.has(path)) continue;\n const value = plc.get(path);\n Reflect.set(pins, name, isScaled(wire) ? wire.toPin(Number(value)) : value);\n }\n model.update(context());\n const writes = new Map<string, unknown>();\n for (const { name, pin, wire, path } of wired) {\n if (pin.direction !== \"out\") continue;\n const value: unknown = Reflect.get(pins, name);\n writes.set(path, isScaled(wire) ? wire.toPlc(Number(value)) : value);\n }\n return writes;\n },\n view: () => model.view?.(context()) ?? { status: \"idle\" },\n pins: () => ({ ...pins }),\n actions: Object.keys(model.actions ?? {}),\n act: (action, argument) => {\n const handler = model.actions?.[action];\n if (!handler) throw new Error(`${model.name} has no action ${action}.`);\n handler(context(), argument);\n },\n };\n };\n",
7
+ "import { defineHardware, pinIn, pinOut } from \"../hardware\";\n\nconst noWorld = (): { in: Record<string, never>; out: Record<string, never> } => ({\n in: {},\n out: {},\n});\n\n/**\n * A pushbutton or switch contact wired to a PLC input. Momentary buttons stay pressed for\n * `pressMs` after `press`; maintained ones (such as an e-stop) stay until `release`.\n */\nexport const pushbutton = defineHardware({\n name: \"Pushbutton\",\n pins: { contact: pinOut(\"bool\") },\n world: noWorld,\n setup: (config: { kind: \"momentary\" | \"maintained\"; pressMs?: number }) => ({\n pressed: false,\n remainingMs: 0,\n pressMs: config.pressMs ?? 300,\n }),\n update: ({ pins, config, state, dtMs }) => {\n if (config.kind === \"momentary\" && state.pressed) {\n state.remainingMs -= dtMs;\n if (state.remainingMs <= 0) state.pressed = false;\n }\n pins.contact = state.pressed;\n },\n view: ({ state }) => ({\n status: state.pressed ? \"busy\" : \"idle\",\n label: state.pressed ? \"pressed\" : \"released\",\n }),\n actions: {\n press: ({ state }) => {\n state.pressed = true;\n state.remainingMs = state.pressMs;\n },\n release: ({ state }) => {\n state.pressed = false;\n },\n },\n});\n\n/** A two-position selector switch wired to a PLC input, such as a station's enable switch. */\nexport const selector = defineHardware({\n name: \"Selector switch\",\n pins: { contact: pinOut(\"bool\") },\n world: noWorld,\n setup: (config: { on: boolean }) => ({ on: config.on }),\n update: ({ pins, state }) => {\n pins.contact = state.on;\n },\n view: ({ state }) => ({ status: state.on ? \"running\" : \"idle\", label: state.on ? \"on\" : \"off\" }),\n actions: {\n on: ({ state }) => {\n state.on = true;\n },\n off: ({ state }) => {\n state.on = false;\n },\n },\n});\n\n/** A lamp, beacon or horn driven by a PLC output. */\nexport const lamp = defineHardware({\n name: \"Lamp\",\n pins: { power: pinIn(\"bool\") },\n world: noWorld,\n setup: (config: { label?: string }) => config,\n update: () => {},\n view: ({ pins, state }) => ({\n status: pins.power ? \"running\" : \"idle\",\n label: `${state.label ?? \"lamp\"} ${pins.power ? \"on\" : \"off\"}`,\n }),\n});\n"
8
+ ],
9
+ "mappings": "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",
10
+ "debugId": "E69063BE2BEB4AC064756E2164756E21",
11
+ "names": []
12
+ }
@@ -0,0 +1,158 @@
1
+ // packages/sim/src/paths.ts
2
+ var pathKey = Symbol("tsplc.path");
3
+ var pathName = (path) => {
4
+ const name = Reflect.get(path, pathKey);
5
+ if (typeof name !== "string" || !name)
6
+ throw new Error("Expected a path from plcPaths().");
7
+ return name;
8
+ };
9
+
10
+ // packages/sim/src/hardware.ts
11
+ var pinIn = (type) => ({ direction: "in", type });
12
+ var pinOut = (type) => ({ direction: "out", type });
13
+ var defaultValue = (type) => type === "bool" ? false : type === "number" ? 0 : [];
14
+ var isScaled = (wire) => ("toPin" in wire);
15
+ var wirePath = (wire) => isScaled(wire) ? pathName(wire.path) : pathName(wire);
16
+ var defineHardware = (model) => (options) => {
17
+ const state = model.setup(options.config);
18
+ const world = model.world();
19
+ const pins = Object.fromEntries(Object.entries(model.pins).map(([name, pin]) => [name, defaultValue(pin.type)]));
20
+ let dtMs = 0;
21
+ const context = () => ({
22
+ pins,
23
+ world,
24
+ config: options.config,
25
+ state,
26
+ dtMs
27
+ });
28
+ const wired = Object.entries(options.wiring).flatMap(([name, wire]) => {
29
+ const pin = model.pins[name];
30
+ if (!pin)
31
+ throw new Error(`${model.name} has no pin ${String(name)}.`);
32
+ const target = wire;
33
+ return target ? [{ name, pin, wire: target, path: wirePath(target) }] : [];
34
+ });
35
+ return {
36
+ model: model.name,
37
+ world,
38
+ reads: wired.filter(({ pin }) => pin.direction === "in").map(({ path }) => path),
39
+ update: (plc, elapsed) => {
40
+ dtMs = elapsed;
41
+ for (const { name, pin, wire, path } of wired) {
42
+ if (pin.direction !== "in" || !plc.has(path))
43
+ continue;
44
+ const value = plc.get(path);
45
+ Reflect.set(pins, name, isScaled(wire) ? wire.toPin(Number(value)) : value);
46
+ }
47
+ model.update(context());
48
+ const writes = new Map;
49
+ for (const { name, pin, wire, path } of wired) {
50
+ if (pin.direction !== "out")
51
+ continue;
52
+ const value = Reflect.get(pins, name);
53
+ writes.set(path, isScaled(wire) ? wire.toPlc(Number(value)) : value);
54
+ }
55
+ return writes;
56
+ },
57
+ view: () => model.view?.(context()) ?? { status: "idle" },
58
+ pins: () => ({ ...pins }),
59
+ actions: Object.keys(model.actions ?? {}),
60
+ act: (action, argument) => {
61
+ const handler = model.actions?.[action];
62
+ if (!handler)
63
+ throw new Error(`${model.name} has no action ${action}.`);
64
+ handler(context(), argument);
65
+ }
66
+ };
67
+ };
68
+
69
+ // packages/sim/src/hardware/intelligrated.ts
70
+ var rampMs = (on4, on5) => on4 ? on5 ? 200 : 240 : on5 ? 400 : 600;
71
+ var minimumVolts = 2.55;
72
+ var pins = {
73
+ "CN2-1": pinIn("bool"),
74
+ "CN2-2": pinIn("bool"),
75
+ "CN2-3": pinIn("number"),
76
+ "CN2-4": pinOut("bool"),
77
+ "CN2-5": pinOut("bool"),
78
+ "CN2-6": pinOut("bool"),
79
+ "CN2-7": pinOut("bool")
80
+ };
81
+ var cs1001 = defineHardware({
82
+ name: "Intelligrated CS1001",
83
+ pins,
84
+ world: () => ({ in: { photoeye: false }, out: { motor: 0 } }),
85
+ setup: (config) => {
86
+ const sw = config.switches;
87
+ const unsupported = (setting) => {
88
+ throw new Error(`CS1001: ${setting} is not modelled.`);
89
+ };
90
+ if (sw["SW1-4"] || sw["SW1-5"])
91
+ unsupported("Master/Slave mode (SW1-4/SW1-5)");
92
+ if (sw["SW3-6"])
93
+ unsupported("half-speed control (SW3-6 ON)");
94
+ if (!sw["SW3-3"])
95
+ unsupported("NPN CN2-4..7 outputs (SW3-3 OFF)");
96
+ if (!sw["SW1-1"] && config.internalSpeed === undefined)
97
+ throw new Error("CS1001: SW1-1 OFF (internal speed) needs internalSpeed.");
98
+ if (!sw["SW2-1"] && config.runHoldingMs === undefined)
99
+ throw new Error("CS1001: SW2-1 OFF (INT release) needs runHoldingMs.");
100
+ return { speed: 0, releasingMs: 0, error: false, warning: false };
101
+ },
102
+ update: ({ pins: io, world, config, state, dtMs }) => {
103
+ const sw = config.switches;
104
+ const control = sw["SW2-2"] ? io["CN2-1"] : true;
105
+ let run;
106
+ if (sw["SW2-6"] && control)
107
+ run = true;
108
+ else if (!sw["SW2-6"] && control)
109
+ run = false;
110
+ else if (sw["SW2-1"])
111
+ run = false;
112
+ else {
113
+ state.releasingMs = world.in.photoeye ? state.releasingMs + dtMs : 0;
114
+ run = world.in.photoeye && state.releasingMs < (config.runHoldingMs ?? 0);
115
+ }
116
+ if (state.error)
117
+ run = false;
118
+ const volts = io["CN2-3"];
119
+ const fraction = sw["SW1-1"] ? volts < minimumVolts ? 0 : Math.min(volts, 10) / 10 : config.internalSpeed ?? 0;
120
+ const reverse = sw["SW3-1"] ? io["CN2-2"] : !io["CN2-2"];
121
+ const base = sw["SW3-2"] ? "CCW" : "CW";
122
+ const rotation = reverse ? base === "CW" ? "CCW" : "CW" : base;
123
+ const target = run ? fraction * config.ratedSpeed * (rotation === config.forward ? 1 : -1) : 0;
124
+ const step = config.ratedSpeed * dtMs / rampMs(sw["SW3-4"], sw["SW3-5"]);
125
+ state.speed = Math.abs(target - state.speed) <= step ? target : state.speed + Math.sign(target - state.speed) * step;
126
+ world.out.motor = state.speed;
127
+ io["CN2-4"] = world.in.photoeye;
128
+ io["CN2-5"] = state.error;
129
+ io["CN2-6"] = state.warning;
130
+ io["CN2-7"] = state.speed !== 0;
131
+ },
132
+ view: ({ state, world, config }) => ({
133
+ status: state.error ? "fault" : state.warning || !config.switches["SW2-2"] ? "warning" : state.speed !== 0 ? "running" : "stopped",
134
+ label: state.speed !== 0 ? `${Math.round(Math.abs(state.speed))} mm/s` : "stopped",
135
+ details: {
136
+ photoeye: world.in.photoeye,
137
+ ...!config.switches["SW2-2"] && { note: "SW2-2 NPN: CN2-1 is ignored" }
138
+ }
139
+ }),
140
+ actions: {
141
+ fault: ({ state }) => {
142
+ state.error = true;
143
+ },
144
+ warn: ({ state }) => {
145
+ state.warning = true;
146
+ },
147
+ reset: ({ state }) => {
148
+ state.error = false;
149
+ state.warning = false;
150
+ }
151
+ }
152
+ });
153
+ export {
154
+ cs1001
155
+ };
156
+
157
+ //# debugId=A04F5F96DAE8100264756E2164756E21
158
+ //# sourceMappingURL=intelligrated.js.map
@@ -0,0 +1,12 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/paths.ts", "../src/hardware.ts", "../src/hardware/intelligrated.ts"],
4
+ "sourcesContent": [
5
+ "declare const valueType: unique symbol;\nconst pathKey = Symbol(\"tsplc.path\");\n\n/** One PLC variable of type `Value`, such as `LineIO.Conveyor_Inbound.Cards[3].Output.MotionAsserted`. */\nexport interface Path<Value> {\n readonly [valueType]?: Value;\n}\n\n/**\n * Typed paths into a PLC's globals: records nest, `.at(index)` selects a record array's element, and\n * scalars and scalar arrays (read and written whole) are `Path`s.\n */\nexport type Paths<T> =\n T extends ReadonlyArray<infer Item>\n ? Item extends object\n ? { readonly at: (index: number) => Paths<Item> }\n : Path<T>\n : T extends object\n ? { readonly [Key in keyof T]: Paths<T[Key]> }\n : Path<T>;\n\nconst join = (segments: readonly string[]) =>\n segments.reduce(\n (path, segment) =>\n /^\\d+$/.test(segment) ? `${path}[${segment}]` : path ? `${path}.${segment}` : segment,\n \"\",\n );\n\n// A function target so that `.at` is callable, and still a path for a record field named `at`.\nconst proxy = (segments: readonly string[]): unknown =>\n new Proxy(() => undefined, {\n get: (_, key) => {\n if (key === pathKey) return join(segments);\n if (typeof key === \"symbol\") return undefined;\n return proxy([...segments, key]);\n },\n apply: (_, __, [index]: [number]) => proxy([...segments.slice(0, -1), String(index)]),\n });\n\n/**\n * Builds typed paths for a generated `PlcGlobals`:\n * `plcPaths<PlcGlobals>().LineIO.Conveyor_Inbound.Cards.at(3).Output.MotionAsserted`.\n */\nexport const plcPaths = <Globals>(): Paths<Globals> => proxy([]) as Paths<Globals>;\n\n/** The dotted variable path, e.g. `LineIO.Conveyor_Inbound.Cards[3].Output.MotionAsserted`. */\nexport const pathName = (path: Path<unknown>): string => {\n const name: unknown = Reflect.get(path, pathKey);\n if (typeof name !== \"string\" || !name) throw new Error(\"Expected a path from plcPaths().\");\n return name;\n};\n",
6
+ "import { type Path, pathName } from \"./paths\";\n\ntype PinType = \"bool\" | \"number\" | \"bytes\";\ntype PinValue<Type extends PinType> = Type extends \"bool\"\n ? boolean\n : Type extends \"number\"\n ? number\n : number[];\n\n/** An electrical terminal, seen from the device: `in` is driven by the PLC, `out` drives a PLC input. */\nexport interface Pin {\n readonly direction: \"in\" | \"out\";\n readonly type: PinType;\n}\nexport const pinIn = <Type extends PinType>(type: Type) => ({ direction: \"in\", type }) as const;\nexport const pinOut = <Type extends PinType>(type: Type) => ({ direction: \"out\", type }) as const;\nexport type Pins = Record<string, Pin>;\n\n/** Converts between a PLC value and a pin value, such as raw analog counts and volts. */\nexport interface Scaled {\n readonly path: Path<number>;\n readonly toPin: (plc: number) => number;\n readonly toPlc: (pin: number) => number;\n}\nexport type Wire<Value> = Path<Value> | (Value extends number ? Scaled : never);\n\n/**\n * A linear analog channel: `analog(path, { plc: [0, 32767], pin: [0, 10] })` wires a raw\n * 0..32767 output to a 0..10 V input.\n */\nexport const analog = (\n path: Path<number>,\n range: { plc: readonly [number, number]; pin: readonly [number, number] },\n): Scaled => {\n const [plcLow, plcHigh] = range.plc,\n [pinLow, pinHigh] = range.pin;\n return {\n path,\n toPin: (plc) => pinLow + ((plc - plcLow) * (pinHigh - pinLow)) / (plcHigh - plcLow),\n toPlc: (pin) => Math.round(plcLow + ((pin - pinLow) * (plcHigh - plcLow)) / (pinHigh - pinLow)),\n };\n};\n\n/** Each pin may be wired to one PLC variable of its type; unwired inputs read as their default. */\nexport type Wiring<Spec extends Pins> = {\n readonly [Name in keyof Spec]?: Wire<PinValue<Spec[Name][\"type\"]>>;\n};\ntype PinValues<Spec extends Pins> = { [Name in keyof Spec]: PinValue<Spec[Name][\"type\"]> };\n\nexport interface DeviceView {\n status: \"running\" | \"stopped\" | \"idle\" | \"busy\" | \"warning\" | \"fault\";\n label?: string;\n details?: Record<string, string | number | boolean>;\n}\n\nexport interface HardwareContext<Spec extends Pins, World extends WorldPorts, Config, State> {\n /** Input pins hold this tick's PLC values; set output pins to drive PLC inputs. */\n readonly pins: PinValues<Spec>;\n /** Physical ports the topology connects: `in` is sensed from the line, `out` acts on it. */\n readonly world: World;\n readonly config: Config;\n readonly state: State;\n readonly dtMs: number;\n}\n\ninterface WorldPorts {\n in: Record<string, unknown>;\n out: Record<string, unknown>;\n}\n\nexport interface HardwareModel<Spec extends Pins, World extends WorldPorts, Config, State> {\n /** Product name, such as \"Intelligrated CS1001\". */\n readonly name: string;\n readonly pins: Spec;\n /** Initial physical port values; they also fix each port's type. */\n readonly world: () => World;\n /** Validates the configuration (switch settings) and creates the device's private state. */\n readonly setup: (config: Config) => State;\n /** Runs once per tick: read input pins and `world.in`, then set output pins and `world.out`. */\n readonly update: (context: HardwareContext<Spec, World, Config, State>) => void;\n readonly view?: (context: HardwareContext<Spec, World, Config, State>) => DeviceView;\n readonly actions?: Record<\n string,\n (context: HardwareContext<Spec, World, Config, State>, argument: string) => void\n >;\n}\n\n/** One physical device on the line: a model, its configuration and its wiring. */\nexport interface Device<World extends WorldPorts = WorldPorts> {\n readonly model: string;\n readonly world: World;\n /** PLC variables this device reads each tick. */\n readonly reads: readonly string[];\n /** Sets input pins from PLC values, runs the model and returns the PLC values it drives. */\n readonly update: (plc: ReadonlyMap<string, unknown>, dtMs: number) => Map<string, unknown>;\n readonly view: () => DeviceView;\n readonly pins: () => Record<string, unknown>;\n readonly actions: readonly string[];\n readonly act: (action: string, argument: string) => void;\n}\n\nconst defaultValue = (type: PinType): PinValue<PinType> =>\n type === \"bool\" ? false : type === \"number\" ? 0 : [];\n\nconst isScaled = (wire: Wire<never> | Scaled | Path<unknown>): wire is Scaled => \"toPin\" in wire;\n/** The PLC variable a wire connects to. */\nexport const wirePath = (wire: Path<unknown> | Scaled) =>\n isScaled(wire) ? pathName(wire.path) : pathName(wire);\n\n/**\n * Defines a hardware model. The result is a factory that instantiates one device from its\n * configuration and wiring: `cs1001({ config: { switches }, wiring: { \"CN2-1\": path } })`.\n */\nexport const defineHardware =\n <Spec extends Pins, World extends WorldPorts, Config, State>(\n model: HardwareModel<Spec, World, Config, State>,\n ) =>\n (options: { config: Config; wiring: Wiring<Spec> }): Device<World> => {\n const state = model.setup(options.config);\n const world = model.world();\n const pins = Object.fromEntries(\n Object.entries(model.pins).map(([name, pin]) => [name, defaultValue(pin.type)]),\n ) as PinValues<Spec>; // Each entry is the default of its declared pin type.\n let dtMs = 0;\n const context = (): HardwareContext<Spec, World, Config, State> => ({\n pins,\n world,\n config: options.config,\n state,\n dtMs,\n });\n const wired = Object.entries(options.wiring).flatMap(([name, wire]) => {\n const pin = model.pins[name];\n if (!pin) throw new Error(`${model.name} has no pin ${String(name)}.`);\n const target: Path<unknown> | Scaled | undefined = wire;\n return target ? [{ name, pin, wire: target, path: wirePath(target) }] : [];\n });\n return {\n model: model.name,\n world,\n reads: wired.filter(({ pin }) => pin.direction === \"in\").map(({ path }) => path),\n update: (plc, elapsed) => {\n dtMs = elapsed;\n for (const { name, pin, wire, path } of wired) {\n if (pin.direction !== \"in\" || !plc.has(path)) continue;\n const value = plc.get(path);\n Reflect.set(pins, name, isScaled(wire) ? wire.toPin(Number(value)) : value);\n }\n model.update(context());\n const writes = new Map<string, unknown>();\n for (const { name, pin, wire, path } of wired) {\n if (pin.direction !== \"out\") continue;\n const value: unknown = Reflect.get(pins, name);\n writes.set(path, isScaled(wire) ? wire.toPlc(Number(value)) : value);\n }\n return writes;\n },\n view: () => model.view?.(context()) ?? { status: \"idle\" },\n pins: () => ({ ...pins }),\n actions: Object.keys(model.actions ?? {}),\n act: (action, argument) => {\n const handler = model.actions?.[action];\n if (!handler) throw new Error(`${model.name} has no action ${action}.`);\n handler(context(), argument);\n },\n };\n };\n",
7
+ "import { defineHardware, pinIn, pinOut } from \"../hardware\";\n\ntype Switch =\n | `SW1-${1 | 2 | 3 | 4 | 5 | 6}`\n | `SW2-${1 | 2 | 3 | 4 | 5 | 6}`\n | `SW3-${1 | 2 | 3 | 4 | 5 | 6}`\n | `SW4-${1 | 2 | 3 | 4 | 5 | 6}`;\n/** Switches this model reads; ON is `true`. The rest are \"as fitted\" and may be recorded too. */\ntype ModelledSwitch =\n | \"SW1-1\"\n | \"SW1-4\"\n | \"SW1-5\"\n | \"SW2-1\"\n | \"SW2-2\"\n | \"SW2-6\"\n | \"SW3-1\"\n | \"SW3-2\"\n | \"SW3-3\"\n | \"SW3-4\"\n | \"SW3-5\"\n | \"SW3-6\";\n\nexport interface Cs1001Config {\n /** DIP switches as set on the card (ON = `true`). */\n switches: Record<ModelledSwitch, boolean> & Partial<Record<Switch, boolean>>;\n /** Roller surface speed at full speed (10 V on CN2-3), in mm/s. */\n ratedSpeed: number;\n /** Rotation that moves a load forward (downstream, or right for a transfer belt). */\n forward: \"CW\" | \"CCW\";\n /** SW1-1 OFF only: the SW4-1..4 internal speed as a fraction of `ratedSpeed`. */\n internalSpeed?: number;\n /** SW2-1 OFF (INT) only: how long an unlinked zone runs to release a load by itself. */\n runHoldingMs?: number;\n}\n\n/** SW3-4/SW3-5 acceleration/deceleration times from the WWL manual §3-5 (via docs/hardware.md). */\nconst rampMs = (on4: boolean, on5: boolean) => (on4 ? (on5 ? 200 : 240) : on5 ? 400 : 600);\n\n/** The manual's speed table stops the roller below its first band (2.55–2.95 V). */\nconst minimumVolts = 2.55;\n\nconst pins = {\n /** Run/stop control: forces RUN (SW2-6 ON) or STOP (SW2-6 OFF) while active. */\n \"CN2-1\": pinIn(\"bool\"),\n /** Reverses rotation while active (ZPA mode); SW3-1 selects its polarity. */\n \"CN2-2\": pinIn(\"bool\"),\n /** Speed command, 0–10 V (SW1-1 EXT). Wire with `analog()` from the PLC's raw value. */\n \"CN2-3\": pinIn(\"number\"),\n /** Zone photoeye blocked. */\n \"CN2-4\": pinOut(\"bool\"),\n \"CN2-5\": pinOut(\"bool\"),\n \"CN2-6\": pinOut(\"bool\"),\n /** Motor running (sync), independent of the CN2-1 command. */\n \"CN2-7\": pinOut(\"bool\"),\n};\n\n/**\n * Intelligrated CS1001 MDR control card (Hope/WWL \"DC Brushless Motor Power Roller Intelligent\n * Control Card\") driving one zone, configured by its DIP switches and wired through CN2.\n *\n * Modelled: ZPA mode without neighbour links (CN4/CN5 unwired), CN2-1 force run/stop (SW2-6),\n * EOZ/INT end-of-zone behaviour (SW2-1), CN2-2 direction (SW3-1/SW3-2), CN2-3 external speed\n * (SW1-1), acceleration (SW3-4/SW3-5), and the CN2-4/CN2-5/CN2-6/CN2-7 outputs. Linked ZPA,\n * Master/Slave modes, half-speed control and output polarities other than PNP are not modelled\n * and are rejected, because the source behaviour is not documented here.\n */\nexport const cs1001 = defineHardware({\n name: \"Intelligrated CS1001\",\n pins,\n world: () => ({ in: { photoeye: false }, out: { motor: 0 } }),\n setup: (config: Cs1001Config) => {\n const sw = config.switches;\n const unsupported = (setting: string) => {\n throw new Error(`CS1001: ${setting} is not modelled.`);\n };\n if (sw[\"SW1-4\"] || sw[\"SW1-5\"]) unsupported(\"Master/Slave mode (SW1-4/SW1-5)\");\n if (sw[\"SW3-6\"]) unsupported(\"half-speed control (SW3-6 ON)\");\n if (!sw[\"SW3-3\"]) unsupported(\"NPN CN2-4..7 outputs (SW3-3 OFF)\");\n if (!sw[\"SW1-1\"] && config.internalSpeed === undefined)\n throw new Error(\"CS1001: SW1-1 OFF (internal speed) needs internalSpeed.\");\n if (!sw[\"SW2-1\"] && config.runHoldingMs === undefined)\n throw new Error(\"CS1001: SW2-1 OFF (INT release) needs runHoldingMs.\");\n return { speed: 0, releasingMs: 0, error: false, warning: false };\n },\n update: ({ pins: io, world, config, state, dtMs }) => {\n const sw = config.switches;\n // FIELD-CONFIRMED 2026-10-24: with SW2-2 OFF (NPN) the B&R's sourcing output reads as\n // active, so the card runs whatever the PLC writes.\n const control = sw[\"SW2-2\"] ? io[\"CN2-1\"] : true;\n let run: boolean;\n if (sw[\"SW2-6\"] && control) run = true;\n else if (!sw[\"SW2-6\"] && control) run = false;\n else if (sw[\"SW2-1\"])\n run = false; // EOZ holds its load until CN2-1 runs it.\n else {\n // INT: an unlinked zone with a blocked photoeye releases by itself until its run-holding timer expires.\n state.releasingMs = world.in.photoeye ? state.releasingMs + dtMs : 0;\n run = world.in.photoeye && state.releasingMs < (config.runHoldingMs ?? 0);\n }\n if (state.error) run = false;\n const volts = io[\"CN2-3\"];\n const fraction = sw[\"SW1-1\"]\n ? volts < minimumVolts\n ? 0\n : Math.min(volts, 10) / 10\n : (config.internalSpeed ?? 0);\n // SW3-1 ON: CN2-2 active at 24 V (the PLC's TRUE); OFF (NPN): active at 0 V.\n const reverse = sw[\"SW3-1\"] ? io[\"CN2-2\"] : !io[\"CN2-2\"];\n const base = sw[\"SW3-2\"] ? \"CCW\" : \"CW\";\n const rotation = reverse ? (base === \"CW\" ? \"CCW\" : \"CW\") : base;\n const target = run ? fraction * config.ratedSpeed * (rotation === config.forward ? 1 : -1) : 0;\n const step = (config.ratedSpeed * dtMs) / rampMs(sw[\"SW3-4\"], sw[\"SW3-5\"]);\n state.speed =\n Math.abs(target - state.speed) <= step\n ? target\n : state.speed + Math.sign(target - state.speed) * step;\n world.out.motor = state.speed;\n io[\"CN2-4\"] = world.in.photoeye;\n io[\"CN2-5\"] = state.error;\n io[\"CN2-6\"] = state.warning;\n io[\"CN2-7\"] = state.speed !== 0;\n },\n view: ({ state, world, config }) => ({\n status: state.error\n ? \"fault\"\n : state.warning || !config.switches[\"SW2-2\"]\n ? \"warning\"\n : state.speed !== 0\n ? \"running\"\n : \"stopped\",\n label: state.speed !== 0 ? `${Math.round(Math.abs(state.speed))} mm/s` : \"stopped\",\n details: {\n photoeye: world.in.photoeye,\n ...(!config.switches[\"SW2-2\"] && { note: \"SW2-2 NPN: CN2-1 is ignored\" }),\n },\n }),\n actions: {\n /** Raises CN2-5 and stops the motor, as a stall or jam would. */\n fault: ({ state }) => {\n state.error = true;\n },\n warn: ({ state }) => {\n state.warning = true;\n },\n reset: ({ state }) => {\n state.error = false;\n state.warning = false;\n },\n },\n});\n"
8
+ ],
9
+ "mappings": "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",
10
+ "debugId": "A04F5F96DAE8100264756E2164756E21",
11
+ "names": []
12
+ }