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