@helioslx/core 0.2.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/CONTRIBUTING.md +83 -0
  3. package/LICENSE +256 -0
  4. package/README.md +159 -0
  5. package/SECURITY.md +67 -0
  6. package/dist/contracts-C4kpAdZq.d.ts +265 -0
  7. package/dist/contracts-C4kpAdZq.d.ts.map +1 -0
  8. package/dist/http.d.ts +643 -0
  9. package/dist/http.d.ts.map +1 -0
  10. package/dist/http.js +670 -0
  11. package/dist/http.js.map +1 -0
  12. package/dist/index.d.ts +40 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +270 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/node.d.ts +114 -0
  17. package/dist/node.d.ts.map +1 -0
  18. package/dist/node.js +334 -0
  19. package/dist/node.js.map +1 -0
  20. package/dist/redis.d.ts +46 -0
  21. package/dist/redis.d.ts.map +1 -0
  22. package/dist/redis.js +174 -0
  23. package/dist/redis.js.map +1 -0
  24. package/dist/source-DB1oq2GT.js +884 -0
  25. package/dist/source-DB1oq2GT.js.map +1 -0
  26. package/dist/source-D_XNWXS9.d.ts +76 -0
  27. package/dist/source-D_XNWXS9.d.ts.map +1 -0
  28. package/dist/testing.d.ts +38 -0
  29. package/dist/testing.d.ts.map +1 -0
  30. package/dist/testing.js +98 -0
  31. package/dist/testing.js.map +1 -0
  32. package/dist/validation-BdsVeyAE.js +151 -0
  33. package/dist/validation-BdsVeyAE.js.map +1 -0
  34. package/examples/embedded-elysia-http.ts +30 -0
  35. package/examples/full-frame-fade.ts +25 -0
  36. package/examples/graceful-shutdown.ts +19 -0
  37. package/examples/quickstart.ts +30 -0
  38. package/examples/redis.ts +30 -0
  39. package/examples/sparse-channels.ts +23 -0
  40. package/examples/viewer-subscription.ts +38 -0
  41. package/examples/viewer-tui.ts +201 -0
  42. package/package.json +108 -0
  43. package/src/contracts.ts +302 -0
  44. package/src/http.ts +1101 -0
  45. package/src/index.ts +61 -0
  46. package/src/memory-store.ts +45 -0
  47. package/src/node.ts +578 -0
  48. package/src/output-engine.ts +778 -0
  49. package/src/redis.ts +258 -0
  50. package/src/source.ts +502 -0
  51. package/src/testing.ts +139 -0
  52. package/src/validation.ts +328 -0
  53. package/src/viewer.ts +368 -0
@@ -0,0 +1,25 @@
1
+ import { createSacnSource, SLOT_COUNT } from "@helioslx/core";
2
+ import { RecordingTransport } from "@helioslx/core/testing";
3
+
4
+ const source = createSacnSource({
5
+ transport: new RecordingTransport(),
6
+ ownsTransport: true,
7
+ });
8
+
9
+ try {
10
+ const frame = new Uint8Array(SLOT_COUNT);
11
+ frame[0] = 255;
12
+ frame[1] = 128;
13
+ frame[2] = 64;
14
+
15
+ const snapshot = await source.universe(1).write(frame, {
16
+ durationMs: 2_000,
17
+ });
18
+
19
+ console.info(
20
+ `Scheduled fade for universe ${snapshot.universe} with ${snapshot.activeTransitions} active transitions.`,
21
+ );
22
+ // write schedules the fade; it does not wait for the duration to elapse.
23
+ } finally {
24
+ await source.close();
25
+ }
@@ -0,0 +1,19 @@
1
+ import { createSacnSource } from "@helioslx/core";
2
+ import { RecordingTransport } from "@helioslx/core/testing";
3
+
4
+ const source = createSacnSource({
5
+ transport: new RecordingTransport(),
6
+ ownsTransport: true,
7
+ onStop: () => {
8
+ console.info("Scheduler paused.");
9
+ },
10
+ onClose: () => {
11
+ console.info("Source closed.");
12
+ },
13
+ });
14
+
15
+ await source.universe(1).setChannels({ 1: 255 });
16
+
17
+ // stop() pauses; close() is final teardown (also used by Node process handlers).
18
+ await source.stop();
19
+ await source.close();
@@ -0,0 +1,30 @@
1
+ import { createSacnSource } from "@helioslx/core/node";
2
+
3
+ const iface = process.env.SACN_INTERFACE;
4
+ const source = createSacnSource({
5
+ name: "Helios quickstart",
6
+ transportOptions: {
7
+ // Use the local IPv4 address on your lighting network.
8
+ ...(iface === undefined ? {} : { iface }),
9
+ },
10
+ });
11
+
12
+ const universe = source.universe(1, {
13
+ priority: 100,
14
+ sourceName: "demo",
15
+ });
16
+
17
+ try {
18
+ await universe.fadeChannels(
19
+ {
20
+ 1: 255,
21
+ 2: 128,
22
+ },
23
+ { durationMs: 10_000 },
24
+ );
25
+
26
+ console.info("Output is running. Press Ctrl-C to stop.");
27
+ } catch (error) {
28
+ await source.close();
29
+ throw error;
30
+ }
@@ -0,0 +1,30 @@
1
+ import { createSacnSource } from "@helioslx/core";
2
+ import { RedisOutputStore } from "@helioslx/core/redis";
3
+ import { NodeSacnTransport } from "@helioslx/core/node";
4
+
5
+ const store = new RedisOutputStore({
6
+ // Uses REDIS_URL when present; otherwise defaults to local Redis.
7
+ ...(process.env.REDIS_URL === undefined
8
+ ? {}
9
+ : { url: process.env.REDIS_URL }),
10
+ });
11
+
12
+ const source = createSacnSource({
13
+ transport: new NodeSacnTransport({
14
+ sourceName: "Helios Redis example",
15
+ ...(process.env.SACN_INTERFACE === undefined
16
+ ? {}
17
+ : { iface: process.env.SACN_INTERFACE }),
18
+ }),
19
+ store,
20
+ ownsTransport: true,
21
+ ownsStore: true,
22
+ });
23
+
24
+ try {
25
+ await source.universe(1).fadeChannels({ 1: 255 }, { durationMs: 500 });
26
+ } finally {
27
+ // The source closes the store; this store owns its internally-created
28
+ // Redis client by default.
29
+ await source.close();
30
+ }
@@ -0,0 +1,23 @@
1
+ import { createSacnSource } from "@helioslx/core";
2
+ import { RecordingTransport } from "@helioslx/core/testing";
3
+
4
+ const source = createSacnSource({
5
+ transport: new RecordingTransport(),
6
+ ownsTransport: true,
7
+ });
8
+
9
+ const universe = source.universe(1);
10
+
11
+ try {
12
+ await universe.transition([
13
+ { channel: 1, value: 255, durationMs: 1_000 },
14
+ { channel: 10, value: 64, durationMs: 2_000 },
15
+ { channel: 512, value: 128, durationMs: 3_000 },
16
+ ]);
17
+
18
+ await universe.fadeChannels({ 1: 0 }, { durationMs: 250 });
19
+
20
+ console.info(await universe.get());
21
+ } finally {
22
+ await source.close();
23
+ }
@@ -0,0 +1,38 @@
1
+ import { ViewerService } from "@helioslx/core";
2
+ import { NodeSacnReceiver } from "@helioslx/core/node";
3
+
4
+ const viewer = new ViewerService({
5
+ receiver: new NodeSacnReceiver(),
6
+ ownsReceiver: true,
7
+ streamCapacity: 16,
8
+ });
9
+
10
+ await viewer.start();
11
+ await viewer.setSelectedUniverses([1, 2]);
12
+
13
+ const unsubscribe = viewer.subscribe((packet) => {
14
+ console.info(
15
+ `Universe ${packet.universe} channel 1=${packet.values[0]} ` +
16
+ `from ${packet.source.sourceName ?? packet.source.cid}`,
17
+ );
18
+ });
19
+
20
+ // Async streams are bounded and coalesce queued packets by universe.
21
+ const stream = viewer.packets(8);
22
+ const consume = (async (): Promise<void> => {
23
+ for await (const packet of stream) {
24
+ console.info(`Stream received universe ${packet.universe}`);
25
+ }
26
+ })();
27
+
28
+ const close = async (): Promise<void> => {
29
+ unsubscribe();
30
+ stream.close();
31
+ await consume;
32
+ await viewer.close();
33
+ };
34
+
35
+ process.once("SIGINT", () => void close());
36
+ process.once("SIGTERM", () => void close());
37
+
38
+ console.info("Watching universes 1 and 2. Press Ctrl-C to stop.");
@@ -0,0 +1,201 @@
1
+ import {
2
+ MAX_UNIVERSE,
3
+ MIN_UNIVERSE,
4
+ SLOT_COUNT,
5
+ ViewerService,
6
+ type ViewerPacket,
7
+ } from "@helioslx/core";
8
+ import { NodeSacnReceiver } from "@helioslx/core/node";
9
+
10
+ const COLUMNS = 32;
11
+ const ROWS = SLOT_COUNT / COLUMNS;
12
+ const ESC = "\x1b";
13
+ const HIDE_CURSOR = `${ESC}[?25l`;
14
+ const SHOW_CURSOR = `${ESC}[?25h`;
15
+ const CLEAR_HOME = `${ESC}[2J${ESC}[H`;
16
+ const RESET = `${ESC}[0m`;
17
+
18
+ const clampUniverse = (value: number): number =>
19
+ Math.min(MAX_UNIVERSE, Math.max(MIN_UNIVERSE, value));
20
+
21
+ const parseUniverse = (raw: string): number => {
22
+ const value = Number(raw);
23
+ if (!Number.isInteger(value) || value < MIN_UNIVERSE || value > MAX_UNIVERSE) {
24
+ throw new Error(
25
+ `Universe must be an integer between ${MIN_UNIVERSE} and ${MAX_UNIVERSE}.`,
26
+ );
27
+ }
28
+ return value;
29
+ };
30
+
31
+ const pad = (value: number, width: number): string =>
32
+ String(value).padStart(width, "0");
33
+
34
+ const intensityStyle = (value: number): string => {
35
+ if (value <= 0) return `${ESC}[2m`;
36
+ if (value < 64) return `${ESC}[90m`;
37
+ if (value < 128) return `${ESC}[37m`;
38
+ if (value < 192) return `${ESC}[97m`;
39
+ return `${ESC}[1;97m`;
40
+ };
41
+
42
+ const formatAge = (receivedAt: number, now: number): string => {
43
+ const ageMs = Math.max(0, now - receivedAt);
44
+ if (ageMs < 1_000) return `${ageMs}ms`;
45
+ if (ageMs < 60_000) return `${(ageMs / 1_000).toFixed(1)}s`;
46
+ return `${Math.floor(ageMs / 60_000)}m`;
47
+ };
48
+
49
+ const iface = process.env.SACN_INTERFACE;
50
+ const startUniverse = parseUniverse(
51
+ process.argv[2] ?? process.env.SACN_UNIVERSE ?? "1",
52
+ );
53
+
54
+ const viewer = new ViewerService({
55
+ receiver: new NodeSacnReceiver({
56
+ ...(iface === undefined ? {} : { iface }),
57
+ }),
58
+ ownsReceiver: true,
59
+ });
60
+
61
+ const latest = new Map<number, ViewerPacket>();
62
+ let universe = startUniverse;
63
+ let closed = false;
64
+ let redrawPending = false;
65
+ let selecting: Promise<void> = Promise.resolve();
66
+ let unsubscribe: () => void = () => undefined;
67
+
68
+ const render = (): void => {
69
+ if (closed || !process.stdout.isTTY) return;
70
+
71
+ const now = Date.now();
72
+ const packet = latest.get(universe);
73
+ const telemetry = viewer.getTelemetry();
74
+ const lines: string[] = [];
75
+
76
+ lines.push(`Helios viewer universe ${universe}`);
77
+ if (packet) {
78
+ const source = packet.source.sourceName ?? packet.source.cid;
79
+ lines.push(
80
+ `source ${source} pri ${packet.source.priority} seq ${packet.source.sequence} age ${formatAge(packet.receivedAt, now)} packets ${telemetry.packetsReceived}`,
81
+ );
82
+ } else {
83
+ lines.push(`waiting for packets… packets ${telemetry.packetsReceived}`);
84
+ }
85
+ lines.push("");
86
+
87
+ const values = packet?.values;
88
+ for (let row = 0; row < ROWS; row += 1) {
89
+ const startChannel = row * COLUMNS + 1;
90
+ const cells: string[] = [`${pad(startChannel, 3)}`];
91
+ for (let column = 0; column < COLUMNS; column += 1) {
92
+ const index = row * COLUMNS + column;
93
+ const value = values?.[index] ?? 0;
94
+ cells.push(`${intensityStyle(value)}${pad(value, 3)}${RESET}`);
95
+ }
96
+ lines.push(cells.join(" "));
97
+ }
98
+
99
+ lines.push("");
100
+ lines.push("←/→ or h/l universe ±1 [/] ±10 q quit");
101
+
102
+ process.stdout.write(`${CLEAR_HOME}${HIDE_CURSOR}${lines.join("\n")}\n`);
103
+ };
104
+
105
+ const scheduleRedraw = (): void => {
106
+ if (redrawPending || closed) return;
107
+ redrawPending = true;
108
+ setImmediate(() => {
109
+ redrawPending = false;
110
+ render();
111
+ });
112
+ };
113
+
114
+ const selectUniverse = (next: number): void => {
115
+ const clamped = clampUniverse(next);
116
+ if (clamped === universe) return;
117
+ universe = clamped;
118
+ scheduleRedraw();
119
+ selecting = selecting
120
+ .then(async () => {
121
+ if (closed) return;
122
+ await viewer.setSelectedUniverses([universe]);
123
+ })
124
+ .catch((error: unknown) => {
125
+ if (!closed) {
126
+ process.stderr.write(
127
+ `Failed to select universe ${universe}: ${String(error)}\n`,
128
+ );
129
+ }
130
+ });
131
+ };
132
+
133
+ function restoreTerminal(): void {
134
+ if (process.stdin.isTTY) {
135
+ process.stdin.setRawMode(false);
136
+ process.stdin.pause();
137
+ }
138
+ process.stdin.off("data", onKey);
139
+ process.stdout.write(SHOW_CURSOR);
140
+ }
141
+
142
+ async function close(): Promise<void> {
143
+ if (closed) return;
144
+ closed = true;
145
+ unsubscribe();
146
+ process.off("SIGINT", onSignal);
147
+ process.off("SIGTERM", onSignal);
148
+ process.stdout.off("resize", scheduleRedraw);
149
+ restoreTerminal();
150
+ await selecting;
151
+ await viewer.close();
152
+ }
153
+
154
+ function onSignal(): void {
155
+ void close();
156
+ }
157
+
158
+ function onKey(chunk: Buffer): void {
159
+ const key = chunk.toString("utf8");
160
+ if (key === "q" || key === "\u0003") {
161
+ void close();
162
+ return;
163
+ }
164
+ if (key === "\u001b[D" || key === "h") {
165
+ selectUniverse(universe - 1);
166
+ return;
167
+ }
168
+ if (key === "\u001b[C" || key === "l") {
169
+ selectUniverse(universe + 1);
170
+ return;
171
+ }
172
+ if (key === "[") {
173
+ selectUniverse(universe - 10);
174
+ return;
175
+ }
176
+ if (key === "]") {
177
+ selectUniverse(universe + 10);
178
+ }
179
+ }
180
+ await viewer.start();
181
+ await viewer.setSelectedUniverses([universe]);
182
+
183
+ unsubscribe = viewer.subscribe((packet) => {
184
+ latest.set(packet.universe, packet);
185
+ if (packet.universe === universe) scheduleRedraw();
186
+ });
187
+
188
+ process.once("SIGINT", onSignal);
189
+ process.once("SIGTERM", onSignal);
190
+ process.stdout.on("resize", scheduleRedraw);
191
+
192
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
193
+ console.error("viewer-tui requires an interactive TTY.");
194
+ await close();
195
+ process.exitCode = 1;
196
+ } else {
197
+ process.stdin.setRawMode(true);
198
+ process.stdin.resume();
199
+ process.stdin.on("data", onKey);
200
+ render();
201
+ }
package/package.json ADDED
@@ -0,0 +1,108 @@
1
+ {
2
+ "name": "@helioslx/core",
3
+ "version": "0.2.0",
4
+ "description": "TypeScript toolkit for sending and observing sACN (E1.31) from Node.js",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "files": [
8
+ "dist",
9
+ "src",
10
+ "examples",
11
+ "README.md",
12
+ "LICENSE",
13
+ "CHANGELOG.md",
14
+ "CONTRIBUTING.md",
15
+ "SECURITY.md"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ },
22
+ "./node": {
23
+ "types": "./dist/node.d.ts",
24
+ "import": "./dist/node.js"
25
+ },
26
+ "./redis": {
27
+ "types": "./dist/redis.d.ts",
28
+ "import": "./dist/redis.js"
29
+ },
30
+ "./http": {
31
+ "types": "./dist/http.d.ts",
32
+ "import": "./dist/http.js"
33
+ },
34
+ "./testing": {
35
+ "types": "./dist/testing.d.ts",
36
+ "import": "./dist/testing.js"
37
+ }
38
+ },
39
+ "main": "./dist/index.js",
40
+ "types": "./dist/index.d.ts",
41
+ "engines": {
42
+ "node": ">=22"
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/helioslx/core.git"
47
+ },
48
+ "bugs": {
49
+ "url": "https://github.com/helioslx/core/issues"
50
+ },
51
+ "homepage": "https://github.com/helioslx/docs#readme",
52
+ "keywords": [
53
+ "sacn",
54
+ "e1.31",
55
+ "dmx",
56
+ "lighting",
57
+ "udp"
58
+ ],
59
+ "scripts": {
60
+ "build": "tsdown",
61
+ "check": "npm run typecheck && npm test && npm run build && npm run test:examples",
62
+ "test:examples": "tsc --ignoreConfig --noEmit --strict --exactOptionalPropertyTypes --noUncheckedIndexedAccess --skipLibCheck --target ES2023 --module NodeNext --moduleResolution NodeNext --types node examples/*.ts",
63
+ "typecheck": "tsc --noEmit",
64
+ "test": "vitest run",
65
+ "test:coverage": "vitest run --coverage",
66
+ "prepublishOnly": "npm run check"
67
+ },
68
+ "devDependencies": {
69
+ "@elysiajs/node": "^1.4.5",
70
+ "@elysiajs/openapi": "^1.4.15",
71
+ "@types/node": "latest",
72
+ "@vitest/coverage-v8": "^4.1.10",
73
+ "elysia": "^1.4.29",
74
+ "redis": "^6.1.0",
75
+ "sacn": "^4.6.2",
76
+ "tsdown": "latest",
77
+ "typescript": "latest",
78
+ "vitest": "latest"
79
+ },
80
+ "peerDependencies": {
81
+ "@elysiajs/node": "^1.4.5",
82
+ "@elysiajs/openapi": "^1.4.15",
83
+ "elysia": "^1.4.29",
84
+ "redis": "^6.1.0",
85
+ "sacn": "^4.6.2"
86
+ },
87
+ "peerDependenciesMeta": {
88
+ "@elysiajs/node": {
89
+ "optional": true
90
+ },
91
+ "@elysiajs/openapi": {
92
+ "optional": true
93
+ },
94
+ "elysia": {
95
+ "optional": true
96
+ },
97
+ "redis": {
98
+ "optional": true
99
+ },
100
+ "sacn": {
101
+ "optional": true
102
+ }
103
+ },
104
+ "publishConfig": {
105
+ "access": "public"
106
+ },
107
+ "license": "Apache-2.0"
108
+ }