@phystack/device-simulator 6.3.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.
@@ -0,0 +1,129 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { TwinCache } from "../twin-cache";
3
+ import { TwinResponse, TwinTypeEnum } from "../types";
4
+
5
+ function makeTwin(
6
+ id: string,
7
+ type: TwinTypeEnum,
8
+ overrides: Partial<TwinResponse> = {},
9
+ ): TwinResponse {
10
+ return {
11
+ id,
12
+ deviceId: overrides.deviceId ?? `device-${id}`,
13
+ tenantId: overrides.tenantId ?? `tenant-${id}`,
14
+ type,
15
+ properties: overrides.properties ?? { desired: {}, reported: {} },
16
+ descriptors: overrides.descriptors,
17
+ status: overrides.status,
18
+ };
19
+ }
20
+
21
+ describe("TwinCache", () => {
22
+ test("addTwin / getTwin round-trips a twin by id", () => {
23
+ const cache = new TwinCache();
24
+ const twin = makeTwin("screen-1", TwinTypeEnum.Screen);
25
+ cache.addTwin(twin);
26
+ expect(cache.getTwin("screen-1")).toEqual(twin);
27
+ });
28
+
29
+ test("getTwin returns undefined for an unknown id", () => {
30
+ const cache = new TwinCache();
31
+ expect(cache.getTwin("missing")).toBeUndefined();
32
+ });
33
+
34
+ test("addTwin overwrites an existing twin with the same id", () => {
35
+ const cache = new TwinCache();
36
+ cache.addTwin(makeTwin("twin-1", TwinTypeEnum.Screen));
37
+ const replacement = makeTwin("twin-1", TwinTypeEnum.Edge);
38
+ cache.addTwin(replacement);
39
+ expect(cache.getTwin("twin-1")).toEqual(replacement);
40
+ expect(cache.getAllTwins()).toHaveLength(1);
41
+ });
42
+
43
+ test("updateTwin behaves identically to addTwin (set by id)", () => {
44
+ const cache = new TwinCache();
45
+ cache.addTwin(makeTwin("twin-1", TwinTypeEnum.Screen));
46
+ const updated = makeTwin("twin-1", TwinTypeEnum.Screen, {
47
+ properties: { desired: {}, reported: { foo: "bar" } },
48
+ });
49
+ cache.updateTwin(updated);
50
+ expect(cache.getTwin("twin-1")).toEqual(updated);
51
+ });
52
+
53
+ test("populate inserts multiple twins keyed by id", () => {
54
+ const cache = new TwinCache();
55
+ cache.populate([
56
+ makeTwin("a", TwinTypeEnum.Screen),
57
+ makeTwin("b", TwinTypeEnum.Edge),
58
+ ]);
59
+ expect(cache.getAllTwinIds().sort()).toEqual(["a", "b"]);
60
+ });
61
+
62
+ test("removeTwin deletes by id; removing a missing id is a no-op", () => {
63
+ const cache = new TwinCache();
64
+ cache.addTwin(makeTwin("twin-1", TwinTypeEnum.Screen));
65
+ cache.removeTwin("twin-1");
66
+ expect(cache.getTwin("twin-1")).toBeUndefined();
67
+ expect(cache.hasTwin("twin-1")).toBe(false);
68
+ // removing again does not throw
69
+ cache.removeTwin("twin-1");
70
+ expect(cache.getAllTwins()).toHaveLength(0);
71
+ });
72
+
73
+ test("hasTwin reflects presence", () => {
74
+ const cache = new TwinCache();
75
+ expect(cache.hasTwin("twin-1")).toBe(false);
76
+ cache.addTwin(makeTwin("twin-1", TwinTypeEnum.Screen));
77
+ expect(cache.hasTwin("twin-1")).toBe(true);
78
+ });
79
+
80
+ test("getAllTwins / getAllTwinIds list every twin in insertion order", () => {
81
+ const cache = new TwinCache();
82
+ const first = makeTwin("first", TwinTypeEnum.Screen);
83
+ const second = makeTwin("second", TwinTypeEnum.Edge);
84
+ cache.addTwin(first);
85
+ cache.addTwin(second);
86
+ expect(cache.getAllTwins()).toEqual([first, second]);
87
+ expect(cache.getAllTwinIds()).toEqual(["first", "second"]);
88
+ });
89
+
90
+ test("getDeviceTwin returns the first Device-type twin, undefined when none", () => {
91
+ const cache = new TwinCache();
92
+ expect(cache.getDeviceTwin()).toBeUndefined();
93
+ const device = makeTwin("dev-1", TwinTypeEnum.Device);
94
+ cache.addTwin(makeTwin("screen-1", TwinTypeEnum.Screen));
95
+ cache.addTwin(device);
96
+ expect(cache.getDeviceTwin()).toEqual(device);
97
+ });
98
+
99
+ test("getTwinsByType filters by twin type", () => {
100
+ const cache = new TwinCache();
101
+ const screenOne = makeTwin("s1", TwinTypeEnum.Screen);
102
+ const screenTwo = makeTwin("s2", TwinTypeEnum.Screen);
103
+ cache.addTwin(screenOne);
104
+ cache.addTwin(screenTwo);
105
+ cache.addTwin(makeTwin("e1", TwinTypeEnum.Edge));
106
+ expect(cache.getTwinsByType(TwinTypeEnum.Screen)).toEqual([
107
+ screenOne,
108
+ screenTwo,
109
+ ]);
110
+ expect(cache.getTwinsByType(TwinTypeEnum.Peripheral)).toEqual([]);
111
+ });
112
+
113
+ test("getGroupedByType groups twin ids by type", () => {
114
+ const cache = new TwinCache();
115
+ cache.addTwin(makeTwin("dev", TwinTypeEnum.Device));
116
+ cache.addTwin(makeTwin("s1", TwinTypeEnum.Screen));
117
+ cache.addTwin(makeTwin("s2", TwinTypeEnum.Screen));
118
+ cache.addTwin(makeTwin("e1", TwinTypeEnum.Edge));
119
+ expect(cache.getGroupedByType()).toEqual({
120
+ Device: ["dev"],
121
+ Screen: ["s1", "s2"],
122
+ Edge: ["e1"],
123
+ });
124
+ });
125
+
126
+ test("getGroupedByType returns an empty object for an empty cache", () => {
127
+ expect(new TwinCache().getGroupedByType()).toEqual({});
128
+ });
129
+ });
@@ -0,0 +1,200 @@
1
+ /* eslint-disable import/prefer-default-export */
2
+ import crypto from "crypto";
3
+ import chalk from "chalk";
4
+ import { v4 as uuidv4 } from "uuid";
5
+ import { TwinResponse, TwinTypeEnum } from "./types";
6
+ import { TwinCache } from "./twin-cache";
7
+ import { LocalServer, InstanceConnectionEvent } from "./local-server";
8
+ import { MessageRouter } from "./message-router";
9
+ import { simulatorLog } from "./logger";
10
+ import { getDeviceConfig, saveDeviceConfig } from "../utils/simulator-config";
11
+ import { getPackageVersion } from "../utils";
12
+
13
+ /**
14
+ * Generate a deterministic 24-char hex string (MongoDB ObjectID format)
15
+ * from a human-readable name. Same input always produces same output.
16
+ */
17
+ function mockObjectId(name: string): string {
18
+ return crypto.createHash("sha256").update(name).digest("hex").slice(0, 24);
19
+ }
20
+
21
+ export class DeviceSimulator {
22
+ private twinCache: TwinCache;
23
+
24
+ private localServer: LocalServer;
25
+
26
+ private router: MessageRouter | null = null;
27
+
28
+ private port: number;
29
+
30
+ private deviceDisplayName: string;
31
+
32
+ private deviceId: string = "";
33
+
34
+ private tenantId: string = "";
35
+
36
+ constructor(port = 55000, deviceDisplayName = "Local Simulator") {
37
+ this.port = port;
38
+ this.deviceDisplayName = deviceDisplayName;
39
+ this.twinCache = new TwinCache();
40
+ this.localServer = new LocalServer(port);
41
+ }
42
+
43
+ async start(): Promise<void> {
44
+ // 1. Load or create device identity
45
+ // Config storage uses this as directory name. Twins need a 24-char hex ID
46
+ // to pass hub-client validation (e.g. signals zod schemas), so we derive
47
+ // one deterministically via mockObjectId.
48
+ const tenantLabel = "local";
49
+ const tenantObjectId = mockObjectId(tenantLabel);
50
+ this.tenantId = tenantObjectId;
51
+ const existingConfig = await getDeviceConfig(tenantLabel);
52
+ let deviceTwinId: string;
53
+
54
+ if (existingConfig) {
55
+ this.deviceId = existingConfig.deviceId;
56
+ deviceTwinId = existingConfig.deviceTwinId;
57
+ } else {
58
+ this.deviceId = uuidv4();
59
+ deviceTwinId = uuidv4();
60
+ await saveDeviceConfig(tenantLabel, {
61
+ deviceId: this.deviceId,
62
+ tenantId: this.tenantId,
63
+ deviceTwinId,
64
+ });
65
+ }
66
+
67
+ // 2. Create a local Device twin in cache
68
+ const deviceTwin: TwinResponse = {
69
+ id: deviceTwinId,
70
+ deviceId: this.deviceId,
71
+ tenantId: this.tenantId,
72
+ type: TwinTypeEnum.Device,
73
+ properties: {
74
+ desired: {
75
+ displayName: this.deviceDisplayName,
76
+ spaceId: tenantObjectId,
77
+ env: "development",
78
+ deviceSerial: `SIM-${this.deviceId.slice(0, 8)}`,
79
+ accessKey: "simulator-local-key",
80
+ },
81
+ reported: {
82
+ ip: [{ interface: "lo", ipv4: "127.0.0.1", ipv6: "::1" }],
83
+ os: { osVersion: "Simulator" },
84
+ env: { gridEnv: "development" },
85
+ },
86
+ },
87
+ };
88
+ this.twinCache.addTwin(deviceTwin);
89
+
90
+ // 3. Start local server
91
+ await this.localServer.start();
92
+ const io = this.localServer.getIO();
93
+
94
+ this.localServer.on(
95
+ "instanceConnected",
96
+ (event: InstanceConnectionEvent) => {
97
+ const twin = this.twinCache.getTwin(event.twinId);
98
+ const label = twin?.properties?.desired?.appName || "unknown";
99
+ simulatorLog.success(
100
+ `Instance connected: ${label} (${event.twinId}) — ${event.activeCount} active`,
101
+ );
102
+ },
103
+ );
104
+
105
+ this.localServer.on(
106
+ "instanceDisconnected",
107
+ (event: InstanceConnectionEvent) => {
108
+ const twin = this.twinCache.getTwin(event.twinId);
109
+ const label = twin?.properties?.desired?.appName || "unknown";
110
+ simulatorLog.warn(
111
+ `Instance disconnected: ${label} (${event.twinId}) — ${event.activeCount} active`,
112
+ );
113
+ },
114
+ );
115
+
116
+ // 4. Create router (no upstream)
117
+ this.router = new MessageRouter(this.twinCache, io);
118
+ this.localServer.setRouter(this.router);
119
+
120
+ // 5. Print banner
121
+ this.printBanner();
122
+
123
+ if (existingConfig) {
124
+ simulatorLog.dim(`Reusing device identity ${this.deviceId}`);
125
+ }
126
+ simulatorLog.dim(`Apps connect to http://localhost:${this.port}`);
127
+ }
128
+
129
+ async stop(): Promise<void> {
130
+ simulatorLog.dim("Shutting down simulator...");
131
+ await this.localServer.stop();
132
+ simulatorLog.dim("Simulator stopped.");
133
+ }
134
+
135
+ async createTwinForApp(
136
+ type: TwinTypeEnum.Screen | TwinTypeEnum.Edge,
137
+ desiredProperties?: Record<string, any>,
138
+ reuseId?: string,
139
+ ): Promise<TwinResponse> {
140
+ const twin: TwinResponse = {
141
+ id: reuseId || uuidv4(),
142
+ deviceId: this.deviceId,
143
+ tenantId: this.tenantId,
144
+ type,
145
+ properties: {
146
+ desired: desiredProperties || {},
147
+ reported: {},
148
+ },
149
+ };
150
+ this.twinCache.addTwin(twin);
151
+ return twin;
152
+ }
153
+
154
+ createPeripheralTwin(
155
+ instanceId: string,
156
+ name: string,
157
+ hardwareId: string,
158
+ desiredProperties?: Record<string, any>,
159
+ ): TwinResponse {
160
+ const twin: TwinResponse = {
161
+ id: uuidv4(),
162
+ deviceId: this.deviceId,
163
+ tenantId: this.tenantId,
164
+ type: TwinTypeEnum.Peripheral,
165
+ properties: {
166
+ desired: { ...desiredProperties, instanceId, name, hardwareId },
167
+ reported: {},
168
+ },
169
+ descriptors: { instanceId, name, hardwareId },
170
+ };
171
+ this.twinCache.addTwin(twin);
172
+ return twin;
173
+ }
174
+
175
+ verifyTwinExists(twinId: string): boolean {
176
+ return this.twinCache.hasTwin(twinId);
177
+ }
178
+
179
+ getDeviceId(): string {
180
+ return this.deviceId;
181
+ }
182
+
183
+ private printBanner(): void {
184
+ simulatorLog.printBanner([
185
+ "",
186
+ chalk.hex("#00e676")(" ██▀▀▄ █ █ █ █ ▄▀▀▀ ▀▀█▀▀ ▄▀▀█ ▄▀▀▀ █ ▄▀"),
187
+ chalk.hex("#00c853")(" █▄▄▀ █▄▄█ ▀█ ▀▀▄ █ █▄▄█ █ █▀▄ "),
188
+ chalk.hex("#00a846")(" █ █ █ █ ▀▄▄▀ █ █ █ ▀▄▄▀ █ ▀▄"),
189
+ chalk.dim(` Device Simulator v${getPackageVersion()}`),
190
+ chalk.dim("─".repeat(50)),
191
+ ` ${chalk.cyan("Mode:")} ${chalk.yellow("local")}`,
192
+ ` ${chalk.cyan("Port:")} ${this.port}`,
193
+ ` ${chalk.cyan("Device:")} ${this.deviceDisplayName}`,
194
+ ` ${chalk.cyan("Device ID:")} ${chalk.dim(this.deviceId)} ${chalk.yellow("(local)")}`,
195
+ ` ${chalk.cyan("Tenant:")} ${chalk.dim(this.tenantId)} ${chalk.yellow("(local)")}`,
196
+ chalk.dim("─".repeat(50)),
197
+ "",
198
+ ]);
199
+ }
200
+ }
@@ -0,0 +1,184 @@
1
+ /* eslint-disable import/prefer-default-export */
2
+ import { createServer } from "http";
3
+ import { EventEmitter } from "events";
4
+ import { Server as SocketIOServer, Socket } from "socket.io";
5
+ import { MessageRouter } from "./message-router";
6
+ import { EventPayload } from "./types";
7
+
8
+ export interface InstanceConnectionEvent {
9
+ twinId: string;
10
+ activeCount: number;
11
+ }
12
+
13
+ export class LocalServer extends EventEmitter {
14
+ private httpServer: ReturnType<typeof createServer> | null = null;
15
+
16
+ private io: SocketIOServer | null = null;
17
+
18
+ private port: number;
19
+
20
+ private router: MessageRouter | null = null;
21
+
22
+ private connectedClients: Map<string, Socket[]> = new Map();
23
+
24
+ private eventDebounceTimers: Map<string, NodeJS.Timeout> = new Map();
25
+
26
+ constructor(port = 55000) {
27
+ super();
28
+ this.port = port;
29
+ }
30
+
31
+ getIO(): SocketIOServer | null {
32
+ return this.io;
33
+ }
34
+
35
+ setRouter(router: MessageRouter): void {
36
+ this.router = router;
37
+ }
38
+
39
+ start(): Promise<void> {
40
+ this.httpServer = createServer((_req, res) => {
41
+ res.writeHead(200, { "Content-Type": "application/json" });
42
+ res.end(JSON.stringify({ status: "ok", simulator: true }));
43
+ });
44
+
45
+ this.io = new SocketIOServer(this.httpServer, {
46
+ cors: { origin: "*" },
47
+ });
48
+
49
+ this.io.on("connection", (socket: Socket) => {
50
+ this.setupSocketHandlers(socket);
51
+ });
52
+
53
+ return new Promise<void>((resolve, reject) => {
54
+ // Bind to IPv4 loopback — Bun's default .listen(port) picks the IPv6
55
+ // wildcard, which fails on IPv6-disabled Linux hosts. The simulator
56
+ // is a dev-workflow local server so loopback is the right scope.
57
+ this.httpServer!.listen(this.port, "127.0.0.1", () => {
58
+ resolve();
59
+ });
60
+ this.httpServer!.on("error", reject);
61
+ });
62
+ }
63
+
64
+ private setupSocketHandlers(socket: Socket): void {
65
+ const auth = (socket.handshake.auth || {}) as {
66
+ instanceId?: string;
67
+ moduleName?: string;
68
+ };
69
+ const twinId = auth.instanceId || auth.moduleName;
70
+
71
+ // Generic channel for CLI utility calls (no twin ID needed)
72
+ socket.on(
73
+ "simulator",
74
+ async (payload: EventPayload, callback?: Function) => {
75
+ if (this.router) {
76
+ await this.router.handleMessage(socket, "", payload, callback);
77
+ }
78
+ },
79
+ );
80
+
81
+ socket.on("ping", (data: any) => {
82
+ setTimeout(() => {
83
+ socket.emit("pong", { count: (data?.count || 0) + 1 });
84
+ }, 100);
85
+ });
86
+
87
+ // App connections provide a twin ID via auth — set up twin-specific handlers
88
+ if (!twinId) return;
89
+
90
+ if (!this.connectedClients.has(twinId)) {
91
+ this.connectedClients.set(twinId, []);
92
+ }
93
+ this.connectedClients.get(twinId)!.push(socket);
94
+ this.emitDebounced(twinId);
95
+
96
+ socket.join(twinId);
97
+
98
+ socket.on("disconnect", () => {
99
+ const sockets = this.connectedClients.get(twinId);
100
+ if (sockets) {
101
+ const idx = sockets.indexOf(socket);
102
+ if (idx !== -1) sockets.splice(idx, 1);
103
+ if (sockets.length === 0) this.connectedClients.delete(twinId);
104
+ }
105
+ this.emitDebounced(twinId);
106
+ });
107
+
108
+ // Hub-client protocol: client emits on its own twin ID channel
109
+ socket.on(twinId, async (payload: EventPayload, callback?: Function) => {
110
+ if (this.router) {
111
+ await this.router.handleMessage(socket, twinId, payload, callback);
112
+ }
113
+ });
114
+
115
+ socket.on("reconnect", () => {
116
+ socket.join(twinId);
117
+ });
118
+
119
+ socket.on("getDeviceTwin", (callback: Function) => {
120
+ if (this.router) {
121
+ const result = (this.router as any).handleLocalMethod(
122
+ "getDeviceInstance",
123
+ {},
124
+ );
125
+ if (result && typeof result.then === "function") {
126
+ result.then((r: any) => callback(r?.twin || {}));
127
+ } else {
128
+ callback(result?.twin || {});
129
+ }
130
+ }
131
+ });
132
+ }
133
+
134
+ stop(): Promise<void> {
135
+ return new Promise<void>((resolve) => {
136
+ if (this.io) {
137
+ this.io.disconnectSockets(true);
138
+ this.io.close();
139
+ }
140
+ if (this.httpServer) {
141
+ this.httpServer.close(() => resolve());
142
+ } else {
143
+ resolve();
144
+ }
145
+ });
146
+ }
147
+
148
+ getConnectedClients(): string[] {
149
+ return Array.from(this.connectedClients.keys());
150
+ }
151
+
152
+ /**
153
+ * Debounce connect/disconnect events per twin ID.
154
+ *
155
+ * Hub-client's getPhyHubSocket() uses a two-phase connection strategy:
156
+ * 1. PROBE — connects with reconnectionAttempts:1 to discover which URL works
157
+ * (tries localhost:55000, phyos:55500, phyhub.eu.omborigrid.net in order)
158
+ * 2. DISCONNECT — kills the probe socket, extracts the winning URL from socket.io.uri
159
+ * 3. RECONNECT — creates a new socket to the same URL with default (production) settings
160
+ *
161
+ * In simulator mode this is redundant (URL is always localhost:55000) but hub-client
162
+ * doesn't know that — it runs the same code path as production. The result is 3 rapid
163
+ * socket events: connect → disconnect → connect, all within milliseconds.
164
+ *
165
+ * Additionally, Vite HMR can cause the browser to re-initialize the app, triggering
166
+ * the entire probe cycle again.
167
+ *
168
+ * We debounce per twin ID so that after the flurry settles (500ms), we emit a single
169
+ * event reflecting the final state — either instanceConnected or instanceDisconnected.
170
+ */
171
+ private emitDebounced(twinId: string): void {
172
+ const existing = this.eventDebounceTimers.get(twinId);
173
+ if (existing) clearTimeout(existing);
174
+
175
+ const timer = setTimeout(() => {
176
+ this.eventDebounceTimers.delete(twinId);
177
+ const isConnected = this.connectedClients.has(twinId);
178
+ const event = isConnected ? "instanceConnected" : "instanceDisconnected";
179
+ this.emit(event, { twinId, activeCount: this.connectedClients.size });
180
+ }, 500);
181
+
182
+ this.eventDebounceTimers.set(twinId, timer);
183
+ }
184
+ }
@@ -0,0 +1,44 @@
1
+ /* eslint-disable import/prefer-default-export */
2
+ import chalk from "chalk";
3
+
4
+ function timestamp(): string {
5
+ const now = new Date();
6
+ const h = String(now.getHours()).padStart(2, "0");
7
+ const m = String(now.getMinutes()).padStart(2, "0");
8
+ const s = String(now.getSeconds()).padStart(2, "0");
9
+ return chalk.dim(`[${h}:${m}:${s}]`);
10
+ }
11
+
12
+ export class SimulatorLogger {
13
+ log(message: string): void {
14
+ console.log(`${timestamp()} ${message}`);
15
+ }
16
+
17
+ info(message: string): void {
18
+ this.log(message);
19
+ }
20
+
21
+ success(message: string): void {
22
+ this.log(chalk.green(message));
23
+ }
24
+
25
+ warn(message: string): void {
26
+ this.log(chalk.yellow(message));
27
+ }
28
+
29
+ error(message: string): void {
30
+ this.log(chalk.red(message));
31
+ }
32
+
33
+ dim(message: string): void {
34
+ this.log(chalk.dim(message));
35
+ }
36
+
37
+ printBanner(lines: string[]): void {
38
+ for (const line of lines) {
39
+ console.log(line);
40
+ }
41
+ }
42
+ }
43
+
44
+ export const simulatorLog = new SimulatorLogger();