@tsplc/debug 0.0.0-stage → 0.2.17
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/dist/format.js +121 -0
- package/dist/format.js.map +11 -0
- package/dist/index.js +1681 -0
- package/dist/index.js.map +24 -0
- package/dist/manifest.js +201 -0
- package/dist/manifest.js.map +10 -0
- package/dist/paths.js +138 -0
- package/dist/paths.js.map +10 -0
- package/package.json +46 -4
- package/src/adapter.ts +59 -0
- package/src/api.ts +182 -0
- package/src/debugger.ts +509 -0
- package/src/hardware.ts +125 -0
- package/src/http.ts +155 -0
- package/src/index.ts +8 -0
- package/src/jobs.ts +115 -0
- package/src/login.ts +284 -0
- package/src/manifest.ts +271 -0
- package/src/opcua.ts +69 -0
- package/src/paths.ts +185 -0
- package/src/telemetry.ts +110 -0
- package/src/timeline.ts +78 -0
- package/src/trace.ts +45 -0
- package/src/ui/bits.ts +41 -0
- package/src/ui/client.ts +48 -0
- package/src/ui/dev.ts +579 -0
- package/src/ui/dom.ts +168 -0
- package/src/ui/fixture.ts +548 -0
- package/src/ui/format.ts +125 -0
- package/src/ui/hardware.ts +368 -0
- package/src/ui/header.ts +179 -0
- package/src/ui/index.ts +20 -0
- package/src/ui/jobs.ts +249 -0
- package/src/ui/main.ts +74 -0
- package/src/ui/overview.ts +123 -0
- package/src/ui/page.ts +178 -0
- package/src/ui/recent.ts +322 -0
- package/src/ui/requests.ts +81 -0
- package/src/ui/store.ts +227 -0
- package/src/ui/tree.ts +156 -0
- package/src/ui/variables.ts +469 -0
- package/src/watch.ts +32 -0
- package/README.md +0 -3
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a build tells the debugger: every TypeScript identity it can show, and the target symbol each one reads.
|
|
5
|
+
* TypeScript paths (`program.diverters.binTrackers[10].bin`) are the identity; `target` strings are opaque to
|
|
6
|
+
* everything except the target's adapter (for CODESYS, IEC symbol paths such as `plc_Program.diverters…`).
|
|
7
|
+
* Written by the compiler to `.tsplc/artifacts/st/debug.json` and shipped with each release.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** A location in the project's own sources: `file` indexes `sources`; lines and columns are 1-based. */
|
|
11
|
+
export const sourceRange = z.object({
|
|
12
|
+
file: z.number().int(),
|
|
13
|
+
line: z.number().int(),
|
|
14
|
+
column: z.number().int(),
|
|
15
|
+
endLine: z.number().int(),
|
|
16
|
+
endColumn: z.number().int(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const json: z.ZodType<unknown> = z.lazy(() =>
|
|
20
|
+
z.union([
|
|
21
|
+
z.string(),
|
|
22
|
+
z.number(),
|
|
23
|
+
z.boolean(),
|
|
24
|
+
z.null(),
|
|
25
|
+
z.array(json),
|
|
26
|
+
z.record(z.string(), json),
|
|
27
|
+
]),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export const debugField = z.object({
|
|
31
|
+
name: z.string(),
|
|
32
|
+
target: z.string(),
|
|
33
|
+
type: z.string(),
|
|
34
|
+
declaration: sourceRange.optional(),
|
|
35
|
+
/** Compiler-made storage, such as an async job's `step`; hidden unless asked for. */
|
|
36
|
+
generated: z.literal(true).optional(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** Keys of `types` are opaque ids; scalars use their IEC name, such as `BOOL`. */
|
|
40
|
+
export const debugType = z.discriminatedUnion("kind", [
|
|
41
|
+
z.object({ kind: z.literal("scalar"), iec: z.string() }),
|
|
42
|
+
z.object({
|
|
43
|
+
kind: z.literal("enum"),
|
|
44
|
+
name: z.string(),
|
|
45
|
+
members: z.record(z.string(), z.number().int()),
|
|
46
|
+
}),
|
|
47
|
+
z.object({
|
|
48
|
+
kind: z.literal("record"),
|
|
49
|
+
name: z.string(),
|
|
50
|
+
declaration: sourceRange.optional(),
|
|
51
|
+
fields: z.array(debugField),
|
|
52
|
+
}),
|
|
53
|
+
z.object({
|
|
54
|
+
kind: z.literal("array"),
|
|
55
|
+
element: z.string(),
|
|
56
|
+
length: z.number().int(),
|
|
57
|
+
/** The first index; TypeScript paths use the target's own indexes, `lower` to `lower + length - 1`. */
|
|
58
|
+
lower: z.number().int(),
|
|
59
|
+
}),
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
export const debugRoot = z.object({
|
|
63
|
+
path: z.string(),
|
|
64
|
+
target: z.string(),
|
|
65
|
+
type: z.string(),
|
|
66
|
+
declaration: sourceRange.optional(),
|
|
67
|
+
/** Readable at runtime through the target's adapter; for CODESYS, all stored state is. */
|
|
68
|
+
readable: z.boolean(),
|
|
69
|
+
generated: z.literal(true).optional(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** A field the compiler folded away: it has no runtime storage, but its value is known. */
|
|
73
|
+
export const debugConstant = z.object({
|
|
74
|
+
path: z.string(),
|
|
75
|
+
type: z.string(),
|
|
76
|
+
value: json,
|
|
77
|
+
declaration: sourceRange.optional(),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const debugIo = z.object({
|
|
81
|
+
path: z.string(),
|
|
82
|
+
target: z.string(),
|
|
83
|
+
direction: z.enum(["input", "output"]),
|
|
84
|
+
type: z.string(),
|
|
85
|
+
device: z.string().optional(),
|
|
86
|
+
channel: z.string().optional(),
|
|
87
|
+
address: z.string().optional(),
|
|
88
|
+
inverted: z.literal(true).optional(),
|
|
89
|
+
shared: z.literal(true).optional(),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export const debugChannel = z.object({
|
|
93
|
+
id: z.string(),
|
|
94
|
+
name: z.string(),
|
|
95
|
+
direction: z.enum(["input", "output"]),
|
|
96
|
+
type: z.string(),
|
|
97
|
+
offsetBits: z.number().int(),
|
|
98
|
+
bits: z.number().int(),
|
|
99
|
+
address: z.string().optional(),
|
|
100
|
+
unit: z.string().optional(),
|
|
101
|
+
description: z.string().optional(),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export const debugDevice = z.object({
|
|
105
|
+
id: z.string(),
|
|
106
|
+
product: z.string().optional(),
|
|
107
|
+
vendor: z.string().optional(),
|
|
108
|
+
productCode: z.string().optional(),
|
|
109
|
+
network: z.object({ address: z.string(), rpiMs: z.number().optional() }).optional(),
|
|
110
|
+
connection: z.string().optional(),
|
|
111
|
+
assemblies: z.array(
|
|
112
|
+
z.object({
|
|
113
|
+
direction: z.enum(["input", "output"]),
|
|
114
|
+
bytes: z.number().int(),
|
|
115
|
+
address: z.string().optional(),
|
|
116
|
+
/** A byte array over the whole assembly, mapped or not. */
|
|
117
|
+
raw: z.string().optional(),
|
|
118
|
+
}),
|
|
119
|
+
),
|
|
120
|
+
/**
|
|
121
|
+
* Target paths of the driver's own state, such as a generated adapter's `eState`; absent means
|
|
122
|
+
* the device's health is unknown.
|
|
123
|
+
*/
|
|
124
|
+
health: z
|
|
125
|
+
.object({
|
|
126
|
+
state: z.string().optional(),
|
|
127
|
+
states: z.record(z.string(), z.number().int()).optional(),
|
|
128
|
+
diagnostic: z.string().optional(),
|
|
129
|
+
message: z.string().optional(),
|
|
130
|
+
})
|
|
131
|
+
.optional(),
|
|
132
|
+
/** Status symbols by label: hand-configured ones, and a generated Modbus server's error. */
|
|
133
|
+
status: z.record(z.string(), z.string()).optional(),
|
|
134
|
+
channels: z.array(debugChannel),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/** A specialized function: one per method per instance, so `reads` resolve to concrete paths. */
|
|
138
|
+
export const debugFunction = z.object({
|
|
139
|
+
id: z.number().int(),
|
|
140
|
+
name: z.string(),
|
|
141
|
+
declaration: sourceRange,
|
|
142
|
+
/** The TypeScript path of `this`, for a method specialized to one instance. */
|
|
143
|
+
instance: z.string().optional(),
|
|
144
|
+
task: z.string().optional(),
|
|
145
|
+
target: z.string(),
|
|
146
|
+
/** Member reads in the body whose runtime path is static in this specialization. */
|
|
147
|
+
reads: z.array(z.object({ range: sourceRange, path: z.string() })),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
export const debugJob = z.object({
|
|
151
|
+
id: z.number().int(),
|
|
152
|
+
name: z.string(),
|
|
153
|
+
declaration: sourceRange,
|
|
154
|
+
instance: z.string().optional(),
|
|
155
|
+
/** The job's state, with fields `step`, `busy`, `done`, `error` and `cancelling`. */
|
|
156
|
+
target: z.string(),
|
|
157
|
+
steps: z.array(
|
|
158
|
+
z.object({
|
|
159
|
+
step: z.number().int(),
|
|
160
|
+
kind: z.enum(["await", "return", "throw", "statement"]),
|
|
161
|
+
range: sourceRange,
|
|
162
|
+
text: z.string(),
|
|
163
|
+
}),
|
|
164
|
+
),
|
|
165
|
+
/**
|
|
166
|
+
* The PLC clock in milliseconds when the job last started: the clock of telemetry records'
|
|
167
|
+
* `time` and a batch's `now`.
|
|
168
|
+
*/
|
|
169
|
+
startedAt: z.string().optional(),
|
|
170
|
+
/**
|
|
171
|
+
* Service calls the job awaits, its nested jobs' included; `sequence` is the request counter sent
|
|
172
|
+
* as `x-tsplc-request`, with `job`'s id when a nested job makes the call.
|
|
173
|
+
*/
|
|
174
|
+
requests: z.array(
|
|
175
|
+
z.object({
|
|
176
|
+
service: z.string(),
|
|
177
|
+
target: z.string(),
|
|
178
|
+
sequence: z.string(),
|
|
179
|
+
job: z.number().int().optional(),
|
|
180
|
+
}),
|
|
181
|
+
),
|
|
182
|
+
/** Made by the compiler, such as a service call's own job; hidden unless asked for. */
|
|
183
|
+
generated: z.literal(true).optional(),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Where `debug.trace` records: an enum state field changing, a decision function returning a
|
|
188
|
+
* different value, or a job starting or finishing. Trace records name their site by `id`.
|
|
189
|
+
*/
|
|
190
|
+
export const debugEventSite = z.object({
|
|
191
|
+
id: z.number().int(),
|
|
192
|
+
kind: z.enum(["state", "decision", "job"]),
|
|
193
|
+
/** For `kind: "job"`: the job, and whether it starts or finishes at this site. */
|
|
194
|
+
job: z.number().int().optional(),
|
|
195
|
+
phase: z.enum(["started", "finished"]).optional(),
|
|
196
|
+
/** The TypeScript path written, or of the job. */
|
|
197
|
+
path: z.string(),
|
|
198
|
+
function: z.number().int().optional(),
|
|
199
|
+
type: z.string(),
|
|
200
|
+
range: sourceRange,
|
|
201
|
+
text: z.string(),
|
|
202
|
+
task: z.string(),
|
|
203
|
+
/**
|
|
204
|
+
* Guard values captured in the same scan, at the write: each `if` condition leaf enclosing it.
|
|
205
|
+
* `stable` is false when the body between the guard and the write can change the leaf.
|
|
206
|
+
*/
|
|
207
|
+
operands: z.array(
|
|
208
|
+
z.object({
|
|
209
|
+
text: z.string(),
|
|
210
|
+
range: sourceRange,
|
|
211
|
+
type: z.string(),
|
|
212
|
+
path: z.string().optional(),
|
|
213
|
+
stable: z.boolean(),
|
|
214
|
+
}),
|
|
215
|
+
),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
/** The statement that last wrote each output, per scan. */
|
|
219
|
+
export const debugWriter = z.object({
|
|
220
|
+
output: z.string(),
|
|
221
|
+
target: z.string(),
|
|
222
|
+
sites: z.array(
|
|
223
|
+
z.object({ id: z.number().int(), range: sourceRange, function: z.number().int() }),
|
|
224
|
+
),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
export const debugTask = z.object({
|
|
228
|
+
name: z.string(),
|
|
229
|
+
intervalMs: z.number(),
|
|
230
|
+
priority: z.number().int(),
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
export const debugManifest = z.object({
|
|
234
|
+
kind: z.literal("tsplc-debug"),
|
|
235
|
+
version: z.literal(1),
|
|
236
|
+
build: z.object({
|
|
237
|
+
id: z.string(),
|
|
238
|
+
/** A variable holding `id`, to tell which build the controller runs. */
|
|
239
|
+
target: z.string(),
|
|
240
|
+
compiler: z.string(),
|
|
241
|
+
}),
|
|
242
|
+
/** Project-relative paths. */
|
|
243
|
+
sources: z.array(z.string()),
|
|
244
|
+
types: z.record(z.string(), debugType),
|
|
245
|
+
roots: z.array(debugRoot),
|
|
246
|
+
constants: z.array(debugConstant),
|
|
247
|
+
io: z.array(debugIo),
|
|
248
|
+
devices: z.array(debugDevice),
|
|
249
|
+
functions: z.array(debugFunction),
|
|
250
|
+
jobs: z.array(debugJob),
|
|
251
|
+
events: z.array(debugEventSite),
|
|
252
|
+
writers: z.array(debugWriter),
|
|
253
|
+
tasks: z.array(debugTask),
|
|
254
|
+
/** Job error codes by number: 1 cancelled, 2 timeout… */
|
|
255
|
+
errors: z.record(z.string(), z.string()),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
export type SourceRange = z.infer<typeof sourceRange>;
|
|
259
|
+
export type DebugType = z.infer<typeof debugType>;
|
|
260
|
+
export type DebugField = z.infer<typeof debugField>;
|
|
261
|
+
export type DebugRoot = z.infer<typeof debugRoot>;
|
|
262
|
+
export type DebugConstant = z.infer<typeof debugConstant>;
|
|
263
|
+
export type DebugIo = z.infer<typeof debugIo>;
|
|
264
|
+
export type DebugDevice = z.infer<typeof debugDevice>;
|
|
265
|
+
export type DebugChannel = z.infer<typeof debugChannel>;
|
|
266
|
+
export type DebugFunction = z.infer<typeof debugFunction>;
|
|
267
|
+
export type DebugJob = z.infer<typeof debugJob>;
|
|
268
|
+
export type DebugEventSite = z.infer<typeof debugEventSite>;
|
|
269
|
+
export type DebugWriter = z.infer<typeof debugWriter>;
|
|
270
|
+
export type DebugTask = z.infer<typeof debugTask>;
|
|
271
|
+
export type DebugManifest = z.infer<typeof debugManifest>;
|
package/src/opcua.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { createMonitor, type MonitorOptions, type Reading } from "@tsplc/opcua";
|
|
3
|
+
import type { ResolvedTsplcConfig } from "@tsplc/config";
|
|
4
|
+
import type { TargetAdapter, TargetRead, TargetSample } from "./adapter.ts";
|
|
5
|
+
|
|
6
|
+
type MonitorConfig = NonNullable<ResolvedTsplcConfig["monitor"]>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The connection the config's `monitor` section describes. Its certificate directory is relative
|
|
10
|
+
* to `root`, and the password comes from `passwordEnv` in `env`.
|
|
11
|
+
*/
|
|
12
|
+
export const opcUaConnection = (
|
|
13
|
+
monitor: MonitorConfig,
|
|
14
|
+
options: { root: string; env: Record<string, string | undefined> },
|
|
15
|
+
): MonitorOptions => {
|
|
16
|
+
const password = monitor.passwordEnv && options.env[monitor.passwordEnv];
|
|
17
|
+
if (monitor.username && !password)
|
|
18
|
+
throw new Error(
|
|
19
|
+
monitor.passwordEnv
|
|
20
|
+
? `Set ${monitor.passwordEnv} to the OPC UA password of ${monitor.username}.`
|
|
21
|
+
: "Configure monitor.passwordEnv for the OPC UA user.",
|
|
22
|
+
);
|
|
23
|
+
return {
|
|
24
|
+
endpoint: monitor.endpoint,
|
|
25
|
+
namespaceUri: monitor.namespaceUri,
|
|
26
|
+
certificateDirectory: resolve(options.root, monitor.certificateDirectory),
|
|
27
|
+
security: monitor.securityPolicy,
|
|
28
|
+
securityMode: monitor.securityMode === "Sign" ? "Sign" : "SignAndEncrypt",
|
|
29
|
+
...(monitor.username && password && { credentials: { username: monitor.username, password } }),
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reads a CODESYS controller over OPC UA, where targets are IEC symbol paths. It reads only
|
|
35
|
+
* what the build publishes, so it suits a local virtual PLC; samples arrive on change.
|
|
36
|
+
*/
|
|
37
|
+
export const createOpcUaAdapter = (options: MonitorOptions): TargetAdapter => {
|
|
38
|
+
const monitor = createMonitor(options);
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const running = monitor.run(controller.signal);
|
|
41
|
+
const sample = ({ value, quality, status }: Reading): TargetSample => ({
|
|
42
|
+
value,
|
|
43
|
+
quality,
|
|
44
|
+
status,
|
|
45
|
+
receivedAt: new Date().toISOString(),
|
|
46
|
+
});
|
|
47
|
+
const names = (targets: readonly TargetRead[]) => targets.map(({ target }) => target);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
method: "OPC UA",
|
|
51
|
+
endpoint: options.endpoint,
|
|
52
|
+
samplingMs: monitor.samplingMs,
|
|
53
|
+
connection: monitor.status,
|
|
54
|
+
onConnection: monitor.onStatus,
|
|
55
|
+
read: async (targets) =>
|
|
56
|
+
new Map(
|
|
57
|
+
Object.entries(await monitor.read(names(targets))).map(([target, reading]) => [
|
|
58
|
+
target,
|
|
59
|
+
sample(reading),
|
|
60
|
+
]),
|
|
61
|
+
),
|
|
62
|
+
subscribe: (targets, listener) =>
|
|
63
|
+
monitor.watch(names(targets), (target, reading) => listener(target, sample(reading))),
|
|
64
|
+
close: async () => {
|
|
65
|
+
controller.abort();
|
|
66
|
+
await running;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
};
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { DebugManifest, DebugType, SourceRange } from "./manifest.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TypeScript paths over a manifest: `program.diverters.binTrackers[10].bin` names a root
|
|
5
|
+
* (`program.diverters`), then record fields and array indexes. Pure, so the browser UI can use it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** A path that reads a target, or a constant the compiler folded. */
|
|
9
|
+
export type ResolvedPath =
|
|
10
|
+
| {
|
|
11
|
+
kind: "target";
|
|
12
|
+
path: string;
|
|
13
|
+
target: string;
|
|
14
|
+
type: string;
|
|
15
|
+
readable: boolean;
|
|
16
|
+
generated?: true;
|
|
17
|
+
declaration?: SourceRange;
|
|
18
|
+
}
|
|
19
|
+
| { kind: "constant"; path: string; type: string; value: unknown; declaration?: SourceRange };
|
|
20
|
+
|
|
21
|
+
/** Roots or constants that share a prefix, such as `program` above `program.diverters`. */
|
|
22
|
+
interface GroupPath {
|
|
23
|
+
kind: "group";
|
|
24
|
+
path: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A child in the instance tree. */
|
|
28
|
+
export type PathNode = { name: string } & (ResolvedPath | GroupPath);
|
|
29
|
+
|
|
30
|
+
const segmentPattern = /[^.[\]]+|\[(?:-?\d+|\*)\]/g;
|
|
31
|
+
|
|
32
|
+
export const pathSegments = (path: string) => path.match(segmentPattern) ?? [];
|
|
33
|
+
|
|
34
|
+
export const joinPath = (prefix: string, segment: string) =>
|
|
35
|
+
segment.startsWith("[") || !prefix ? `${prefix}${segment}` : `${prefix}.${segment}`;
|
|
36
|
+
|
|
37
|
+
/** Scalar type ids are their IEC names, and may have no entry in `types`. */
|
|
38
|
+
export const typeOf = (manifest: DebugManifest, id: string): DebugType =>
|
|
39
|
+
manifest.types[id] ?? { kind: "scalar", iec: id };
|
|
40
|
+
|
|
41
|
+
export const createPaths = (manifest: DebugManifest) => {
|
|
42
|
+
const typeOfId = (id: string) => typeOf(manifest, id);
|
|
43
|
+
// I/O bindings are readable symbols with TypeScript paths of their own.
|
|
44
|
+
const bases = new Map<string, Extract<ResolvedPath, { kind: "target" }>>([
|
|
45
|
+
...manifest.roots.map(
|
|
46
|
+
({ path, ...root }) => [path, { kind: "target" as const, path, ...root }] as const,
|
|
47
|
+
),
|
|
48
|
+
...manifest.io.map(
|
|
49
|
+
({ path, target, type }) =>
|
|
50
|
+
[path, { kind: "target" as const, path, target, type, readable: true }] as const,
|
|
51
|
+
),
|
|
52
|
+
]);
|
|
53
|
+
const constants = new Map(
|
|
54
|
+
manifest.constants.map(({ path, ...constant }) => [
|
|
55
|
+
path,
|
|
56
|
+
{ kind: "constant" as const, path, ...constant },
|
|
57
|
+
]),
|
|
58
|
+
);
|
|
59
|
+
/** Children of the prefixes of every root and constant path, which are groups unless they resolve. */
|
|
60
|
+
const groups = new Map<string, Set<string>>();
|
|
61
|
+
for (const path of [...bases.keys(), ...constants.keys()]) {
|
|
62
|
+
let prefix = "";
|
|
63
|
+
for (const segment of pathSegments(path)) {
|
|
64
|
+
let children = groups.get(prefix);
|
|
65
|
+
if (!children) groups.set(prefix, (children = new Set()));
|
|
66
|
+
children.add(segment);
|
|
67
|
+
prefix = joinPath(prefix, segment);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const resolve = (path: string): ResolvedPath | undefined => {
|
|
72
|
+
const constant = constants.get(path);
|
|
73
|
+
if (constant) return constant;
|
|
74
|
+
const segments = pathSegments(path);
|
|
75
|
+
for (let length = segments.length; length > 0; length--) {
|
|
76
|
+
const base = bases.get(segments.slice(0, length).reduce(joinPath, ""));
|
|
77
|
+
if (!base) continue;
|
|
78
|
+
let resolved: ResolvedPath = base;
|
|
79
|
+
for (const segment of segments.slice(length)) {
|
|
80
|
+
const type = typeOfId(resolved.type);
|
|
81
|
+
if (type.kind === "record") {
|
|
82
|
+
const field = type.fields.find(({ name }) => name === segment);
|
|
83
|
+
if (!field) return;
|
|
84
|
+
resolved = {
|
|
85
|
+
kind: "target",
|
|
86
|
+
path: joinPath(resolved.path, segment),
|
|
87
|
+
target: `${resolved.target}.${field.target}`,
|
|
88
|
+
type: field.type,
|
|
89
|
+
readable: resolved.readable,
|
|
90
|
+
...(field.generated && { generated: true }),
|
|
91
|
+
...(field.declaration && { declaration: field.declaration }),
|
|
92
|
+
};
|
|
93
|
+
} else if (type.kind === "array") {
|
|
94
|
+
const index = /^\[(-?\d+)\]$/.exec(segment)?.[1];
|
|
95
|
+
if (
|
|
96
|
+
index === undefined ||
|
|
97
|
+
Number(index) < type.lower ||
|
|
98
|
+
Number(index) >= type.lower + type.length
|
|
99
|
+
)
|
|
100
|
+
return;
|
|
101
|
+
resolved = {
|
|
102
|
+
kind: "target",
|
|
103
|
+
path: joinPath(resolved.path, segment),
|
|
104
|
+
target: `${resolved.target}[${index}]`,
|
|
105
|
+
type: type.element,
|
|
106
|
+
readable: resolved.readable,
|
|
107
|
+
};
|
|
108
|
+
} else return;
|
|
109
|
+
}
|
|
110
|
+
return resolved;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const typedChildren = (resolved: ResolvedPath | undefined) => {
|
|
115
|
+
const type = resolved?.kind === "target" ? typeOfId(resolved.type) : undefined;
|
|
116
|
+
if (type?.kind === "record") return type.fields.map(({ name }) => name);
|
|
117
|
+
if (type?.kind === "array")
|
|
118
|
+
return Array.from({ length: type.length }, (_, index) => `[${type.lower + index}]`);
|
|
119
|
+
return [];
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** A path's fields or indexes, and the roots and constants below it. */
|
|
123
|
+
const children = (path: string): PathNode[] => {
|
|
124
|
+
const names = [...typedChildren(resolve(path)), ...(groups.get(path) ?? [])];
|
|
125
|
+
return [...new Set(names)].map((name) => {
|
|
126
|
+
const child = joinPath(path, name);
|
|
127
|
+
return { name, ...(resolve(child) ?? { kind: "group", path: child }) };
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const isLeaf = (node: ResolvedPath | GroupPath) =>
|
|
132
|
+
node.kind === "constant" ||
|
|
133
|
+
(node.kind === "target" && ["scalar", "enum"].includes(typeOfId(node.type).kind));
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Leaf paths matching `patterns`, where `*` stands for one segment. A pattern naming a record,
|
|
137
|
+
* array or group stands for all its leaves; wildcards and expansion skip compiler-made fields.
|
|
138
|
+
* Paths that don't resolve are kept, so callers can report them.
|
|
139
|
+
*/
|
|
140
|
+
const expand = (patterns: readonly string[], limit = 2000) => {
|
|
141
|
+
const leaves = new Set<string>();
|
|
142
|
+
const add = (path: string) => {
|
|
143
|
+
leaves.add(path);
|
|
144
|
+
if (leaves.size > limit) throw new RangeError(`More than ${limit} values match`);
|
|
145
|
+
};
|
|
146
|
+
const below = (node: ResolvedPath | GroupPath) => {
|
|
147
|
+
if (isLeaf(node)) return add(node.path);
|
|
148
|
+
for (const child of children(node.path))
|
|
149
|
+
if (!(child.kind === "target" && child.generated)) below(child);
|
|
150
|
+
};
|
|
151
|
+
const match = (prefix: string, rest: readonly string[], wildcard: boolean) => {
|
|
152
|
+
const [head, ...tail] = rest;
|
|
153
|
+
if (head === undefined) {
|
|
154
|
+
const node: ResolvedPath | GroupPath | undefined =
|
|
155
|
+
resolve(prefix) ?? (groups.has(prefix) ? { kind: "group", path: prefix } : undefined);
|
|
156
|
+
if (node) below(node);
|
|
157
|
+
else if (!wildcard) add(prefix);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (head === "*" || head === "[*]") {
|
|
161
|
+
for (const child of children(prefix))
|
|
162
|
+
if (
|
|
163
|
+
!(child.kind === "target" && child.generated) &&
|
|
164
|
+
(head === "*" || child.name.startsWith("["))
|
|
165
|
+
)
|
|
166
|
+
match(child.path, tail, true);
|
|
167
|
+
} else match(joinPath(prefix, head), tail, wildcard);
|
|
168
|
+
};
|
|
169
|
+
for (const pattern of patterns) match("", pathSegments(pattern), false);
|
|
170
|
+
return [...leaves];
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** A value as TypeScript shows it: enum members by name, BOOL as a boolean. */
|
|
174
|
+
const decode = (typeId: string, raw: unknown) => {
|
|
175
|
+
const type = typeOfId(typeId);
|
|
176
|
+
if (type.kind === "enum")
|
|
177
|
+
return Object.entries(type.members).find(([, value]) => value === raw)?.[0] ?? raw;
|
|
178
|
+
if (type.kind === "scalar" && type.iec === "BOOL" && typeof raw === "number") return raw !== 0;
|
|
179
|
+
return raw;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return { typeOf: typeOfId, resolve, children, isLeaf, expand, decode };
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export type Paths = ReturnType<typeof createPaths>;
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { TelemetryBatch, TelemetryRecord, TelemetryReply } from "@tsplc/server/telemetry";
|
|
2
|
+
import { recordedAt } from "@tsplc/server/telemetry-log";
|
|
3
|
+
import type { Session } from "./api.ts";
|
|
4
|
+
import type { DebugManifest } from "./manifest.ts";
|
|
5
|
+
|
|
6
|
+
/** Clock estimates are kept this long, on the server's clock. */
|
|
7
|
+
const clockWindowMs = 60_000;
|
|
8
|
+
|
|
9
|
+
/** A record with the batch it came in, numbered in its task's sequence, at its time on the server's clock. */
|
|
10
|
+
export interface ReceivedRecord {
|
|
11
|
+
build: string;
|
|
12
|
+
task: string;
|
|
13
|
+
sequence: number;
|
|
14
|
+
at: string;
|
|
15
|
+
record: TelemetryRecord;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Follows the app server's telemetry ring (`GET /tsplc/telemetry`): it hands out each new record
|
|
20
|
+
* once, keeps the tasks' scan rates, and estimates the PLC's clock against the server's.
|
|
21
|
+
*/
|
|
22
|
+
export const createTelemetryFollower = (manifest: DebugManifest) => {
|
|
23
|
+
const intervals = new Map(manifest.tasks.map(({ name, intervalMs }) => [name, intervalMs]));
|
|
24
|
+
const tasks = new Map<string, Session["tasks"][number]>(
|
|
25
|
+
manifest.tasks.map(({ name }) => [
|
|
26
|
+
name,
|
|
27
|
+
{ name, scan: null, scansPerSecond: null, dropped: 0 },
|
|
28
|
+
]),
|
|
29
|
+
);
|
|
30
|
+
const rates = new Map<string, { scan: number; now: number }>();
|
|
31
|
+
let estimates: { offsetMs: number; intervalMs: number; at: number }[] = [];
|
|
32
|
+
let newest = 0;
|
|
33
|
+
let build: string | null = null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A batch's `now` was read before it was sent, so `now − receivedAt` falls short of the true
|
|
37
|
+
* offset by its transit time: the largest recent estimate is the closest, and their spread,
|
|
38
|
+
* plus a scan, bounds how far off it may be.
|
|
39
|
+
*/
|
|
40
|
+
const clock = (): Session["clock"] => {
|
|
41
|
+
if (!estimates.length) return null;
|
|
42
|
+
const offsets = estimates.map(({ offsetMs }) => offsetMs);
|
|
43
|
+
const best = estimates.reduce((best, estimate) =>
|
|
44
|
+
estimate.offsetMs > best.offsetMs ? estimate : best,
|
|
45
|
+
);
|
|
46
|
+
return {
|
|
47
|
+
offsetMs: best.offsetMs,
|
|
48
|
+
uncertaintyMs: best.offsetMs - Math.min(...offsets) + best.intervalMs,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const observe = (batch: TelemetryBatch, receivedAt: number) => {
|
|
53
|
+
const rate = rates.get(batch.task);
|
|
54
|
+
// A restarted PLC counts scans and time from zero again.
|
|
55
|
+
const restarted = rate !== undefined && batch.now < rate.now;
|
|
56
|
+
if (restarted) estimates = [];
|
|
57
|
+
estimates = estimates.filter(({ at }) => receivedAt - at < clockWindowMs);
|
|
58
|
+
estimates.push({
|
|
59
|
+
offsetMs: batch.now - receivedAt,
|
|
60
|
+
intervalMs: intervals.get(batch.task) ?? 0,
|
|
61
|
+
at: receivedAt,
|
|
62
|
+
});
|
|
63
|
+
const stats = {
|
|
64
|
+
name: batch.task,
|
|
65
|
+
scansPerSecond: tasks.get(batch.task)?.scansPerSecond ?? null,
|
|
66
|
+
scan: batch.scan,
|
|
67
|
+
dropped: batch.dropped,
|
|
68
|
+
};
|
|
69
|
+
if (!rate || restarted) rates.set(batch.task, { scan: batch.scan, now: batch.now });
|
|
70
|
+
else if (batch.now - rate.now >= 1000) {
|
|
71
|
+
stats.scansPerSecond = ((batch.scan - rate.scan) * 1000) / (batch.now - rate.now);
|
|
72
|
+
rates.set(batch.task, { scan: batch.scan, now: batch.now });
|
|
73
|
+
}
|
|
74
|
+
tasks.set(batch.task, stats);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
/** Where the next pull starts. */
|
|
79
|
+
since: () => newest,
|
|
80
|
+
/** Takes a reply to `GET /tsplc/telemetry?since=<since()>`; a restarted server starts over. */
|
|
81
|
+
accept: (reply: TelemetryReply): ReceivedRecord[] => {
|
|
82
|
+
if (reply.latest < newest) {
|
|
83
|
+
newest = 0;
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
const received: ReceivedRecord[] = [];
|
|
87
|
+
for (const batch of reply.batches) {
|
|
88
|
+
if (batch.seq <= newest) continue;
|
|
89
|
+
newest = batch.seq;
|
|
90
|
+
build = batch.build;
|
|
91
|
+
const receivedAt = new Date(batch.receivedAt);
|
|
92
|
+
observe(batch, receivedAt.getTime());
|
|
93
|
+
batch.records.forEach((record, index) =>
|
|
94
|
+
received.push({
|
|
95
|
+
build: batch.build,
|
|
96
|
+
task: batch.task,
|
|
97
|
+
sequence: batch.first + index,
|
|
98
|
+
at: recordedAt(batch, record, receivedAt).toISOString(),
|
|
99
|
+
record,
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return received;
|
|
104
|
+
},
|
|
105
|
+
/** The build the newest batch came from. */
|
|
106
|
+
build: () => build,
|
|
107
|
+
clock,
|
|
108
|
+
tasks: () => [...tasks.values()].map((task) => ({ ...task })),
|
|
109
|
+
};
|
|
110
|
+
};
|