@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.
- package/CHANGELOG.md +14 -0
- package/build-binary.sh +37 -0
- package/dist/index.js +37 -0
- package/package.json +38 -0
- package/src/__tests__/e2e/binary.e2e.test.ts +394 -0
- package/src/__tests__/preload.ts +44 -0
- package/src/command.ts +66 -0
- package/src/commands/__tests__/run-helpers.test.ts +181 -0
- package/src/commands/list.ts +25 -0
- package/src/commands/remove.ts +16 -0
- package/src/commands/run.ts +518 -0
- package/src/commands/start.ts +309 -0
- package/src/index.ts +45 -0
- package/src/services/__tests__/dev-token.test.ts +156 -0
- package/src/services/dev-token.ts +52 -0
- package/src/services/env.ts +10 -0
- package/src/simulator/__tests__/message-router.test.ts +782 -0
- package/src/simulator/__tests__/twin-cache.test.ts +129 -0
- package/src/simulator/index.ts +200 -0
- package/src/simulator/local-server.ts +184 -0
- package/src/simulator/logger.ts +44 -0
- package/src/simulator/message-router.ts +525 -0
- package/src/simulator/twin-cache.ts +61 -0
- package/src/simulator/types.ts +53 -0
- package/src/utils/__tests__/simulator-config.test.ts +230 -0
- package/src/utils/config-paths.ts +38 -0
- package/src/utils/index.ts +41 -0
- package/src/utils/simulator-config.ts +185 -0
- package/src/utils/tenant-storage.ts +106 -0
- package/tsconfig.json +12 -0
|
@@ -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();
|