vda5050 gmr api server project init
This commit is contained in:
commit
37e997d4c6
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
deno.lock
|
||||
*.exe
|
||||
agv_api
|
||||
simulator
|
||||
release
|
||||
device_registry.json
|
||||
debug.log
|
212
agv_main.ts
Normal file
212
agv_main.ts
Normal file
@ -0,0 +1,212 @@
|
||||
// main.ts
|
||||
|
||||
/// <reference lib="deno.unstable" />
|
||||
|
||||
// 导入 UUID 库(请根据实际情况调整URL)
|
||||
import { v4 as uuidv4 } from "npm:uuid";
|
||||
import { setupAgvWorker, initAgvWorker } from "./agv_manager.ts";
|
||||
import { reconnectAllWorker, setupMasterWorker, updateDeviceListFromConfig } from "./master_manager.ts";
|
||||
import { globalEventManager } from "./event_manager.ts";
|
||||
|
||||
// Load config and mappings from JSON files at runtime
|
||||
const configText = await Deno.readTextFile("./config.json");
|
||||
const config = JSON.parse(configText);
|
||||
const mappingFile = config.mappingFile || "mapping.json";
|
||||
const mappingsText = await Deno.readTextFile(`./${mappingFile}`);
|
||||
const mappings = JSON.parse(mappingsText);
|
||||
|
||||
// Generate a unique instance ID from manufacturer and UUID
|
||||
const instanceId = uuidv4();
|
||||
|
||||
// 添加全局事件监听器
|
||||
globalEventManager.addEventListener("reconnect-all", (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
console.log("🎯 主线程收到全局重连事件:", customEvent.detail);
|
||||
handleReconnectAll();
|
||||
});
|
||||
|
||||
globalEventManager.addEventListener("grpc-connection-lost", (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
console.log("🎯 主线程收到 gRPC 连接丢失事件:", customEvent.detail);
|
||||
// 可以添加特殊的 gRPC 重连逻辑
|
||||
});
|
||||
|
||||
globalEventManager.addEventListener("device-status-update", (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
console.log("🎯 主线程收到设备状态更新事件:", customEvent.detail.data);
|
||||
});
|
||||
|
||||
// 重连处理函数
|
||||
function handleReconnectAll() {
|
||||
console.log("agv_main: 执行全局重连");
|
||||
reconnectAllWorker(kv, webWorker, masterWorker, agvWorker, config, mappings, instanceId);
|
||||
}
|
||||
|
||||
// 初始化 downWorker 并支持自身重启
|
||||
let downWorker: Worker;
|
||||
function startDownWorker() {
|
||||
console.log("agv_main: 启动 downWorker");
|
||||
downWorker = new Worker(
|
||||
new URL("./vda5050_transformer_worker.ts", import.meta.url).href,
|
||||
{ type: "module" }
|
||||
);
|
||||
|
||||
// 注册到全局事件管理器
|
||||
globalEventManager.registerWorker("downWorker", downWorker);
|
||||
|
||||
// 初始化 downWorker
|
||||
downWorker.postMessage({
|
||||
type: "init",
|
||||
mqtt: config.mqtt,
|
||||
mappings,
|
||||
instanceId
|
||||
});
|
||||
|
||||
// 监听重启请求
|
||||
downWorker.onmessage = (event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
// 只处理非全局事件相关的消息
|
||||
if (message.type === "reconnect-all" && message.type !== "dispatchGlobalEvent") {
|
||||
console.log("agv_main: 收到 downWorker reconnect-all,重启所有 Workers");
|
||||
handleReconnectAll();
|
||||
} else if (message.type === "reconnect-down") {
|
||||
console.log("agv_main: 收到 downWorker reconnect-down,重启 downWorker");
|
||||
downWorker.postMessage({ type: "stop" });
|
||||
downWorker.terminate();
|
||||
startDownWorker();
|
||||
}
|
||||
};
|
||||
}
|
||||
startDownWorker();
|
||||
|
||||
// 打开 Deno KV 数据库(Deno KV 在最新版本中为内置特性)
|
||||
const kv = await Deno.openKv();
|
||||
|
||||
// // 启动 Web Worker 用于可视化界面
|
||||
const webWorker = new Worker(
|
||||
new URL("./web_worker.ts", import.meta.url).href,
|
||||
{ type: "module" }
|
||||
);
|
||||
console.log("Web Worker 已启动");
|
||||
const masterWorker = new Worker(
|
||||
new URL("./vda_worker.ts", import.meta.url).href,
|
||||
{ type: "module" }
|
||||
);
|
||||
const agvWorker = new Worker(
|
||||
new URL("./agv_worker.ts", import.meta.url).href,
|
||||
{ type: "module" }
|
||||
);
|
||||
|
||||
// 注册所有 Worker 到全局事件管理器
|
||||
globalEventManager.registerWorker("webWorker", webWorker);
|
||||
globalEventManager.registerWorker("masterWorker", masterWorker);
|
||||
globalEventManager.registerWorker("agvWorker", agvWorker);
|
||||
|
||||
reconnectAllWorker(kv, webWorker, masterWorker, agvWorker, config, mappings, instanceId);
|
||||
|
||||
// 监听配置文件变化,动态更新设备列表
|
||||
const watcher = Deno.watchFs("./devices.json");
|
||||
(async () => {
|
||||
console.log("开始监控配置文件变化...");
|
||||
for await (const event of watcher) {
|
||||
if (event.paths.some((p) => p.endsWith("devices.json"))) {
|
||||
console.log("检测到设备配置文件变化,更新设备列表...");
|
||||
await updateDeviceListFromConfig(masterWorker, config, instanceId);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// 处理 Ctrl+C(SIGINT)退出
|
||||
Deno.addSignalListener("SIGINT", () => {
|
||||
console.log("接收到 Ctrl+C,主程序通知 Worker 退出...");
|
||||
|
||||
// 向所有 Worker 发送 shutdown 消息
|
||||
agvWorker.postMessage("shutdown");
|
||||
masterWorker.postMessage({ type: "shutdown" });
|
||||
webWorker.postMessage({ type: "shutdown" });
|
||||
// 通知 downWorker 关闭
|
||||
downWorker.postMessage({ type: "shutdown" });
|
||||
|
||||
// 延时后终止 Worker 并退出主程序
|
||||
setTimeout(() => {
|
||||
agvWorker.terminate();
|
||||
masterWorker.terminate();
|
||||
webWorker.terminate();
|
||||
downWorker.terminate();
|
||||
console.log("所有 Worker 已终止,程序退出");
|
||||
Deno.exit(0);
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// Web Worker 错误处理
|
||||
webWorker.onerror = (error) => {
|
||||
console.error("Web Worker 执行错误:", error);
|
||||
};
|
||||
|
||||
// 发送导航订单的函数(示例订单)
|
||||
function sendNavigationOrder() {
|
||||
// 使用 UUID v4 生成订单号
|
||||
const orderId = uuidv4();
|
||||
console.log(`创建新的导航订单 ${orderId}`);
|
||||
|
||||
masterWorker.postMessage({
|
||||
type: "sendOrder",
|
||||
orderId: orderId,
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "start",
|
||||
nodePosition: {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
mapId: "warehouse",
|
||||
theta: 0,
|
||||
},
|
||||
actions: [],
|
||||
},
|
||||
{
|
||||
nodeId: "waypoint1",
|
||||
nodePosition: {
|
||||
x: 5.0,
|
||||
y: 0.0,
|
||||
mapId: "warehouse",
|
||||
theta: 0,
|
||||
},
|
||||
actions: [],
|
||||
},
|
||||
{
|
||||
nodeId: "destination",
|
||||
nodePosition: {
|
||||
x: 10.0,
|
||||
y: 5.0,
|
||||
mapId: "warehouse",
|
||||
theta: 0,
|
||||
},
|
||||
actions: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 取消订单的函数
|
||||
function cancelOrder(orderId: string) {
|
||||
console.log(`取消订单 ${orderId}`);
|
||||
masterWorker.postMessage({
|
||||
type: "cancelOrder",
|
||||
orderId: orderId,
|
||||
});
|
||||
}
|
||||
|
||||
// 发送自定义命令设置给 Worker 的函数
|
||||
function sendCustomCommand(cmd: string) {
|
||||
console.log(`发送自定义命令设置: ${cmd}`);
|
||||
masterWorker.postMessage({
|
||||
type: "setCommand",
|
||||
command: cmd,
|
||||
});
|
||||
}
|
||||
|
||||
// CLI moved to cli.ts
|
||||
import { startCli } from "./cli.ts";
|
||||
startCli(masterWorker, agvWorker, sendNavigationOrder, cancelOrder, sendCustomCommand, instanceId);
|
||||
|
||||
console.log("VDA 5050 控制程序已启动,输入命令或按 Ctrl+C 退出程序");
|
104
agv_manager.ts
Normal file
104
agv_manager.ts
Normal file
@ -0,0 +1,104 @@
|
||||
// agv_manager.ts
|
||||
// Module to setup and handle the AGV simulation Worker
|
||||
|
||||
import { reconnectAllWorker } from './master_manager.ts';
|
||||
|
||||
/**
|
||||
* Sends the device list to the AGV worker.
|
||||
*/
|
||||
export async function updateAgvDeviceListFromConfig(worker: Worker) {
|
||||
try {
|
||||
const text = await Deno.readTextFile("./devices.json");
|
||||
const devices = JSON.parse(text);
|
||||
if (Array.isArray(devices)) {
|
||||
worker.postMessage({ type: "deviceList", data: devices });
|
||||
} else {
|
||||
console.error("agv 配置文件格式错误,要求为数组格式");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("agv 读取设备配置文件失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function initAgvWorker(agvWorker: Worker, config: any){
|
||||
// Send initial init to the AGV worker
|
||||
agvWorker.postMessage({ type: "init", data: { serverUrl: config.serverUrl } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the AGV Worker, wiring message handlers and sending initial init.
|
||||
* @param kv Deno KV instance for storing/retrieving AGV state
|
||||
* @param serverUrl The gRPC server URL to send to the AGV worker
|
||||
* @param masterWorker The master VDA 5050 Worker to forward AGV events to
|
||||
* @returns The initialized AGV Worker
|
||||
*/
|
||||
export function setupAgvWorker(kv: Deno.Kv, config: any, masterWorker: Worker, agvWorker: Worker, web_worker: Worker, mappings: any, instanceId: string): Worker {
|
||||
|
||||
console.log("AGV 模拟器 Worker 已启动");
|
||||
|
||||
agvWorker.onmessage = async (event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
switch (message.type) {
|
||||
case "inited":
|
||||
console.log("agv inited");
|
||||
await updateAgvDeviceListFromConfig(agvWorker);
|
||||
break;
|
||||
case "requestKVDataOnline": {
|
||||
const deviceKey = message.key;
|
||||
const entry = await kv.get([deviceKey]);
|
||||
const deviceStatus = entry.value || null;
|
||||
agvWorker.postMessage({
|
||||
type: "requestKVDataOnline",
|
||||
requestId: message.requestId,
|
||||
data: deviceStatus,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "requestKVData": {
|
||||
const deviceKey = message.key;
|
||||
const entry = await kv.get([deviceKey]);
|
||||
const deviceStatus = entry.value || null;
|
||||
agvWorker.postMessage({
|
||||
type: "requestKVData",
|
||||
requestId: message.requestId,
|
||||
data: deviceStatus,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "orderForwarded":
|
||||
masterWorker.postMessage({
|
||||
type: "orderForwarded",
|
||||
data: { agvId: message.agvId, order: message.data },
|
||||
});
|
||||
break;
|
||||
case "instantActionsForwarded":
|
||||
masterWorker.postMessage({
|
||||
type: "instantActionsForwarded",
|
||||
data: { agvId: message.agvId, actions: message.data },
|
||||
});
|
||||
break;
|
||||
case "devicesStatus":
|
||||
// console.log("收到 Worker devicesStatus 消息,准备更新设备状态",message);
|
||||
break;
|
||||
case "reconnect-all": {
|
||||
console.log("agv 收到 Worker reconnect 消息,准备重连");
|
||||
|
||||
agvWorker.postMessage({
|
||||
type: "shutdown"
|
||||
});
|
||||
setTimeout( () => {
|
||||
agvWorker.terminate();
|
||||
setTimeout( () => {
|
||||
reconnectAllWorker(kv, web_worker, masterWorker, agvWorker, config, mappings, instanceId);
|
||||
}, 2000);
|
||||
}, 2000);
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.warn("未知消息类型 from agvWorker:", message);
|
||||
}
|
||||
};
|
||||
|
||||
return agvWorker;
|
||||
}
|
932
agv_worker.ts
Normal file
932
agv_worker.ts
Normal file
@ -0,0 +1,932 @@
|
||||
// agv_worker.ts
|
||||
|
||||
// 捕获未处理的 promise,避免 worker 因 grpc 异常退出
|
||||
globalThis.addEventListener("unhandledrejection", (event) => {
|
||||
console.error("Unhandled promise rejection:", event.reason);
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// Deno + npm 包
|
||||
import * as grpc from "npm:@grpc/grpc-js@1.12.1";
|
||||
import * as protoLoader from "npm:@grpc/proto-loader";
|
||||
import { delay } from "https://deno.land/std/async/delay.ts";
|
||||
import { createWorkerEventHelper } from "./worker_event_helper.ts";
|
||||
|
||||
// 创建事件助手
|
||||
const eventHelper = createWorkerEventHelper("agvWorker");
|
||||
|
||||
// Proto 加载
|
||||
const PROTO_PATH = "./proto/vda5050.proto";
|
||||
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
|
||||
keepCase: true,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
});
|
||||
const proto = grpc.loadPackageDefinition(packageDefinition) as any;
|
||||
const vda5050 = proto.vda5050 as any;
|
||||
|
||||
// 运行时参数 & 设备列表
|
||||
let SERVER_URL: string;
|
||||
let devices: Device[] = [];
|
||||
|
||||
// 全局 gRPC 流 & AGV 实例映射
|
||||
let stream: grpc.ClientDuplexStream<any, any> | null = null;
|
||||
const agvMap = new Map<string, AgvSimulator>();
|
||||
|
||||
// gRPC 连接状态标记
|
||||
let isGrpcConnected = false;
|
||||
|
||||
// 全局自增 headerId
|
||||
let globalHeaderId = 1;
|
||||
|
||||
// 设备接口
|
||||
interface Device {
|
||||
serialNumber: string;
|
||||
manufacturer: string;
|
||||
}
|
||||
|
||||
// 新增 AGV 模拟器状态类型定义
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
theta: number;
|
||||
}
|
||||
|
||||
interface NodeState {
|
||||
nodeId: string;
|
||||
sequenceId: number;
|
||||
nodeDescription: string;
|
||||
nodePosition: Position;
|
||||
released: boolean;
|
||||
}
|
||||
|
||||
interface EdgeState {
|
||||
edgeId: string;
|
||||
sequenceId: number;
|
||||
edgeDescription: string;
|
||||
released: boolean;
|
||||
trajectory: Position[];
|
||||
}
|
||||
|
||||
interface AgvPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
theta: number;
|
||||
mapId: string;
|
||||
mapDescription: string;
|
||||
positionInitialized: boolean;
|
||||
deviationRange: number;
|
||||
localizationScore: number;
|
||||
}
|
||||
|
||||
interface Velocity {
|
||||
vx: number;
|
||||
vy: number;
|
||||
omega: number;
|
||||
}
|
||||
|
||||
interface BatteryState {
|
||||
batteryCharge: number;
|
||||
charging: boolean;
|
||||
batteryVoltage?: number;
|
||||
batteryHealth?: number;
|
||||
reach?: number;
|
||||
}
|
||||
|
||||
interface ErrorState {
|
||||
errorType: string;
|
||||
errorDescription: string;
|
||||
errorLevel: string;
|
||||
}
|
||||
|
||||
interface SafetyState {
|
||||
eStop: boolean;
|
||||
fieldViolation: boolean;
|
||||
}
|
||||
|
||||
interface ActionState {
|
||||
actionId: string;
|
||||
actionType: string;
|
||||
actionDescription: string;
|
||||
actionStatus: string;
|
||||
resultDescription: string;
|
||||
}
|
||||
|
||||
interface AgvState {
|
||||
headerId: number;
|
||||
timestamp: string;
|
||||
version: string;
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
orderId: string;
|
||||
orderUpdateId: number;
|
||||
lastNodeId: string;
|
||||
lastNodeSequenceId: number;
|
||||
driving: boolean;
|
||||
waitingForInteractionZoneRelease: boolean;
|
||||
paused: boolean;
|
||||
forkState?: string;
|
||||
newBaseRequest: boolean;
|
||||
distanceSinceLastNode: number;
|
||||
operatingMode: string;
|
||||
nodeStates: NodeState[];
|
||||
edgeStates: EdgeState[];
|
||||
agvPosition: AgvPosition;
|
||||
velocity: Velocity;
|
||||
loads: any[];
|
||||
batteryState: BatteryState;
|
||||
errors: ErrorState[];
|
||||
information: any[];
|
||||
safetyState: SafetyState;
|
||||
actionStates: ActionState[];
|
||||
}
|
||||
|
||||
// 建立/重建 gRPC 流
|
||||
function initGrpcStream() {
|
||||
try {
|
||||
if (stream) {
|
||||
console.log("关闭旧 gRPC 流");
|
||||
try {
|
||||
// 移除所有事件监听,避免它们触发重复 reconnectAll
|
||||
stream.removeAllListeners();
|
||||
stream.end();
|
||||
} catch (_) { /* ignore */ }
|
||||
stream = null;
|
||||
}
|
||||
|
||||
const client = new vda5050.AgvManagement(
|
||||
SERVER_URL,
|
||||
grpc.credentials.createInsecure(),
|
||||
);
|
||||
stream = client.CommunicationChannel();
|
||||
console.log("gRPC 流已建立");
|
||||
|
||||
isGrpcConnected = true;
|
||||
|
||||
if (!stream) {
|
||||
console.error("无法创建 gRPC 流");
|
||||
return false;
|
||||
}
|
||||
|
||||
stream.on("data", (msg: any) => {
|
||||
if(msg) {
|
||||
const sim = agvMap.get(msg.targetAgvId);
|
||||
if (!sim) {
|
||||
console.warn("未知 AGV,丢弃消息:", msg.agvId);
|
||||
return;
|
||||
}else{
|
||||
if (msg.order) {
|
||||
// console.log(`${msg.targetAgvId} << Order`, msg);
|
||||
sim.handleOrder(msg.order);
|
||||
} else if (msg.instantActions) {
|
||||
const acts = msg.instantActions.instantActions || [];
|
||||
// console.log(`${msg.targetAgvId} << InstantActions`, msg);
|
||||
sim.handleInstantActions(acts);
|
||||
}
|
||||
}
|
||||
}else{
|
||||
console.warn("AGV 消息为空,丢弃消息:", msg.agvId);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", async (err: any) => {
|
||||
console.error("gRPC 流错误,全体重连");
|
||||
isGrpcConnected = false;
|
||||
stream = null; // 清空引用,防止重复使用失效的流
|
||||
|
||||
// 发送 gRPC 连接丢失事件
|
||||
eventHelper.dispatchEvent("grpc-connection-lost", {
|
||||
error: err.message || "unknown error",
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
await reconnectAll();
|
||||
});
|
||||
|
||||
// stream.on("end", async () => {
|
||||
// console.warn("gRPC 流结束,全体重连");
|
||||
// isGrpcConnected = false;
|
||||
// stream = null; // 清空引用,防止重复使用失效的流
|
||||
|
||||
// // 发送 gRPC 连接结束事件
|
||||
// eventHelper.dispatchEvent("grpc-connection-ended", {
|
||||
// timestamp: Date.now()
|
||||
// });
|
||||
|
||||
// await reconnectAll();
|
||||
// });
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("gRPC 流初始化失败:", err);
|
||||
stream = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 安全发送消息到流
|
||||
function sendToStream(msg: any): boolean {
|
||||
if (!stream || !isGrpcConnected) {
|
||||
console.warn("无法发送消息,gRPC 流未初始化");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
stream.write(msg);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("发送消息失败:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 重连:停所有定时器,延迟后重建流,重发 Connection 并重启状态上报
|
||||
async function reconnectAll() {
|
||||
// 停止所有状态更新
|
||||
agvMap.forEach((s) => s.stopStatusUpdates());
|
||||
|
||||
// 等待一段时间再重连
|
||||
await delay(500);
|
||||
|
||||
// 尝试重建流
|
||||
let retryCount = 0;
|
||||
const maxRetries = 5;
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
console.log(`正在尝试重建 gRPC 流 等待次数 (${retryCount + 1}/${maxRetries})...`);
|
||||
// if (initGrpcStream()) {
|
||||
// console.log("gRPC 流重建成功");
|
||||
// break;
|
||||
// }
|
||||
|
||||
retryCount++;
|
||||
await delay(100 * retryCount); // 递增等待时间
|
||||
}
|
||||
|
||||
if (!stream) {
|
||||
console.error(`无法重建 gRPC 流,已达到最大重试等待次数 ${maxRetries}`);
|
||||
// 使用全局事件系统发送重连请求
|
||||
eventHelper.dispatchEvent("reconnect-all", {
|
||||
reason: "grpc-stream-failed",
|
||||
retryCount: maxRetries,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
self.postMessage({ type: "reconnect-all"});
|
||||
await delay(10000);
|
||||
return;
|
||||
}
|
||||
|
||||
// 成功重建流,重新发送所有 Connection 并重启状态更新
|
||||
for (const sim of agvMap.values()) {
|
||||
// sim.sendConnection("OFFLINE"); // 主动发送 ONLINE 状态
|
||||
sim.resetLastOnlineStatus();
|
||||
sim.startStatusUpdates();
|
||||
await delay(200);
|
||||
}
|
||||
}
|
||||
|
||||
// AGV 模拟器
|
||||
class AgvSimulator {
|
||||
private batteryLevel: number;
|
||||
private position = { x: 0, y: 0, theta: 0 };
|
||||
private orderId = "";
|
||||
private orderUpdateId = 0;
|
||||
private state!: AgvState;
|
||||
private updateIntervalId: number | null = null;
|
||||
private lastOperatingMode = "NONE";
|
||||
private lastOnlineStatus = "OFFLINE";
|
||||
private agvId: string;
|
||||
private manufacturer: string;
|
||||
private serialNumber: string;
|
||||
|
||||
|
||||
constructor(
|
||||
agvId: string,
|
||||
manufacturer: string,
|
||||
serialNumber: string,
|
||||
) {
|
||||
this.agvId = agvId;
|
||||
this.manufacturer = manufacturer;
|
||||
this.serialNumber = serialNumber;
|
||||
// 随机初始化
|
||||
this.batteryLevel = 75 + Math.random() * 25;
|
||||
this.position = {
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
theta: Math.random() * Math.PI * 2,
|
||||
};
|
||||
this.initState();
|
||||
}
|
||||
|
||||
// 构造初始 State
|
||||
private initState() {
|
||||
this.state = <AgvState>{
|
||||
headerId: globalHeaderId++,
|
||||
timestamp: new Date().toISOString(),
|
||||
version: "2.0.0",
|
||||
manufacturer: this.manufacturer,
|
||||
serialNumber: this.serialNumber,
|
||||
orderId: this.orderId,
|
||||
orderUpdateId: this.orderUpdateId,
|
||||
lastNodeId: "",
|
||||
lastNodeSequenceId: 0,
|
||||
driving: false,
|
||||
waitingForInteractionZoneRelease: false,
|
||||
paused: false,
|
||||
forkState: undefined,
|
||||
newBaseRequest: false,
|
||||
distanceSinceLastNode: 0,
|
||||
operatingMode: "NONE", // 初始离线
|
||||
nodeStates: [],
|
||||
edgeStates: [],
|
||||
agvPosition: {
|
||||
x: this.position.x,
|
||||
y: this.position.y,
|
||||
theta: this.position.theta,
|
||||
mapId: "warehouse",
|
||||
mapDescription: "",
|
||||
positionInitialized: true,
|
||||
deviationRange: 0,
|
||||
localizationScore: 0.95,
|
||||
},
|
||||
velocity: { vx: 0, vy: 0, omega: 0 },
|
||||
loads: [],
|
||||
actionStates: [],
|
||||
batteryState: {
|
||||
batteryCharge: this.batteryLevel,
|
||||
batteryVoltage: 24.5,
|
||||
batteryHealth: 100,
|
||||
charging: false,
|
||||
reach: 0,
|
||||
},
|
||||
errors: [],
|
||||
information: [],
|
||||
safetyState: { eStop: false, fieldViolation: false },
|
||||
};
|
||||
}
|
||||
|
||||
// 上行 Connection,默认为 ONLINE
|
||||
public sendConnection(state: "ONLINE" | "OFFLINE" = "OFFLINE") {
|
||||
const msg = {
|
||||
agvId: this.agvId,
|
||||
connection: {
|
||||
headerId: globalHeaderId++,
|
||||
timestamp: new Date().toISOString(),
|
||||
version: "2.0.0",
|
||||
manufacturer: this.manufacturer,
|
||||
serialNumber: this.serialNumber,
|
||||
connectionState: state,
|
||||
},
|
||||
};
|
||||
// console.log("发送 Connection 消息", msg);
|
||||
if (sendToStream(msg)) {
|
||||
// console.log(`${this.agvId} >> Connection(${state})`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
public resetLastOnlineStatus() {
|
||||
this.lastOnlineStatus = "OFFLINE";
|
||||
}
|
||||
// 定时上报 State
|
||||
public startStatusUpdates() {
|
||||
this.stopStatusUpdates();
|
||||
this.updateIntervalId = setInterval(async () => {
|
||||
// 检查在线/离线状态
|
||||
this.checkOnlineStatus();
|
||||
// console.log(`${this.agvId}:${this.state.operatingMode}:${this.lastOnlineStatus} 状态更新>>>>`);
|
||||
if (this.lastOnlineStatus === "ONLINE") {
|
||||
// console.log(`${this.agvId} 状态更新>>>>`);
|
||||
this.updateAndSendState().catch(err => {
|
||||
console.error(`${this.agvId} 状态更新失败:`, err);
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private async updateAndSendState() {
|
||||
// 1) 请求 KV,添加随机ID
|
||||
const requestId = Math.random().toString(36).substring(2, 15);
|
||||
self.postMessage({
|
||||
type: "requestKVData",
|
||||
requestId,
|
||||
key: `device:${this.manufacturer}/${this.serialNumber}`,
|
||||
});
|
||||
}
|
||||
|
||||
sendStateToGrpc(data: any) {
|
||||
// console.log("发送状态到 gRPC 服务器",this.agvId, this.state.serialNumber, data.agvId.serialNumber, data.state.serialNumber);
|
||||
// console.log("更新状态并发送-->",this.agvId, this.state.serialNumber);
|
||||
// 若请求失败则保持当前状态
|
||||
if (data === null) return;
|
||||
// console.log(`${this.agvId}: KV 数据获取成功`, data);
|
||||
|
||||
// 发送设备状态更新事件
|
||||
// eventHelper.dispatchEvent("device-status-update", {
|
||||
// agvId: this.agvId,
|
||||
// batteryLevel: this.batteryLevel,
|
||||
// operatingMode: data.state?.operatingMode || this.state.operatingMode,
|
||||
// position: this.position,
|
||||
// timestamp: Date.now()
|
||||
// });
|
||||
|
||||
// 更新 headerId 和时间戳
|
||||
this.state.headerId = globalHeaderId++;
|
||||
this.state.timestamp = new Date(data.lastSeen).toISOString();
|
||||
|
||||
// 根据KV数据更新状态
|
||||
if (data.state) {
|
||||
// 更新操作模式
|
||||
if (data.state.operatingMode) {
|
||||
this.state.operatingMode = data.state.operatingMode;
|
||||
this.lastOperatingMode = data.state.operatingMode;
|
||||
}
|
||||
|
||||
// 更新电池状态
|
||||
if (data.state.batteryState) {
|
||||
const kvBattery = data.state.batteryState;
|
||||
this.batteryLevel = kvBattery.batteryCharge !== undefined ?
|
||||
kvBattery.batteryCharge : this.batteryLevel;
|
||||
|
||||
// 更新完整的电池状态
|
||||
this.state.batteryState = {
|
||||
batteryCharge: this.batteryLevel,
|
||||
batteryVoltage: kvBattery.batteryVoltage || this.state.batteryState.batteryVoltage,
|
||||
batteryHealth: kvBattery.batteryHealth || this.state.batteryState.batteryHealth,
|
||||
charging: kvBattery.charging !== undefined ? kvBattery.charging : this.state.batteryState.charging,
|
||||
reach: kvBattery.reach !== undefined ? kvBattery.reach : this.state.batteryState.reach
|
||||
};
|
||||
} else if (this.state.driving) {
|
||||
// 如果KV中没有电池数据但AGV在移动,模拟电量消耗
|
||||
this.batteryLevel = Math.max(0, this.batteryLevel - 0.05);
|
||||
this.state.batteryState.batteryCharge = this.batteryLevel;
|
||||
}
|
||||
|
||||
// 更新位置信息
|
||||
if (data.state.agvPosition) {
|
||||
const kvPos = data.state.agvPosition;
|
||||
// 使用KV中的位置数据
|
||||
this.position = {
|
||||
x: kvPos.x !== undefined ? kvPos.x : this.position.x,
|
||||
y: kvPos.y !== undefined ? kvPos.y : this.position.y,
|
||||
theta: kvPos.theta !== undefined ? kvPos.theta : this.position.theta
|
||||
};
|
||||
|
||||
// 更新完整的位置状态
|
||||
this.state.agvPosition = {
|
||||
x: this.position.x,
|
||||
y: this.position.y,
|
||||
theta: this.position.theta,
|
||||
mapId: kvPos.mapId || this.state.agvPosition.mapId,
|
||||
mapDescription: kvPos.mapDescription || this.state.agvPosition.mapDescription,
|
||||
positionInitialized: kvPos.positionInitialized !== undefined ?
|
||||
kvPos.positionInitialized : this.state.agvPosition.positionInitialized,
|
||||
deviationRange: kvPos.deviationRange !== undefined ?
|
||||
kvPos.deviationRange : this.state.agvPosition.deviationRange,
|
||||
localizationScore: kvPos.localizationScore !== undefined ?
|
||||
kvPos.localizationScore : this.state.agvPosition.localizationScore
|
||||
};
|
||||
} else if (this.state.driving) {
|
||||
// 如果KV中没有位置数据但AGV在移动,模拟位置变化
|
||||
this.position.x += (Math.random() - 0.5) * 0.5;
|
||||
this.position.y += (Math.random() - 0.5) * 0.5;
|
||||
this.position.theta += (Math.random() - 0.5) * 0.1;
|
||||
|
||||
this.state.agvPosition.x = this.position.x;
|
||||
this.state.agvPosition.y = this.position.y;
|
||||
this.state.agvPosition.theta = this.position.theta;
|
||||
}
|
||||
|
||||
// 更新其他状态字段
|
||||
if (data.state.orderId !== undefined) {
|
||||
this.state.orderId = data.state.orderId;
|
||||
}
|
||||
if (data.state.orderUpdateId !== undefined) {
|
||||
this.state.orderUpdateId = data.state.orderUpdateId;
|
||||
}
|
||||
if (data.state.lastNodeId !== undefined) {
|
||||
this.state.lastNodeId = data.state.lastNodeId;
|
||||
}
|
||||
if (data.state.lastNodeSequenceId !== undefined) {
|
||||
this.state.lastNodeSequenceId = data.state.lastNodeSequenceId;
|
||||
}
|
||||
if (data.state.driving !== undefined) {
|
||||
this.state.driving = data.state.driving;
|
||||
}
|
||||
if (data.state.waitingForInteractionZoneRelease !== undefined) {
|
||||
this.state.waitingForInteractionZoneRelease = data.state.waitingForInteractionZoneRelease;
|
||||
}
|
||||
if (data.state.paused !== undefined) {
|
||||
this.state.paused = data.state.paused;
|
||||
}
|
||||
if (data.state.nodeStates) {
|
||||
this.state.nodeStates = data.state.nodeStates;
|
||||
}
|
||||
if (data.state.edgeStates) {
|
||||
this.state.edgeStates = data.state.edgeStates;
|
||||
}
|
||||
if (data.state.actionStates) {
|
||||
this.state.actionStates = data.state.actionStates;
|
||||
}
|
||||
if (data.state.errors) {
|
||||
this.state.errors = data.state.errors;
|
||||
}
|
||||
if (data.state.safetyState) {
|
||||
this.state.safetyState = data.state.safetyState;
|
||||
}
|
||||
if (data.state.serialNumber) {
|
||||
this.state.serialNumber = data.state.serialNumber;
|
||||
}
|
||||
if (data.state.manufacturer) {
|
||||
this.state.manufacturer = data.state.manufacturer;
|
||||
}
|
||||
if (data.state.velocity) {
|
||||
this.state.velocity = data.state.velocity;
|
||||
}
|
||||
if (data.state.loads) {
|
||||
this.state.loads = data.state.loads;
|
||||
}
|
||||
if (data.state.information) {
|
||||
this.state.information = data.state.information;
|
||||
}
|
||||
if (data.state.forkState) {
|
||||
this.state.forkState = data.state.forkState;
|
||||
}
|
||||
if (data.state.safetyState) {
|
||||
this.state.safetyState = data.state.safetyState;
|
||||
}
|
||||
if (data.state.actionStates) {
|
||||
this.state.actionStates = data.state.actionStates;
|
||||
}
|
||||
if (data.state.headerId) {
|
||||
this.state.headerId = data.state.headerId;
|
||||
}
|
||||
if (data.state.timestamp) {
|
||||
this.state.timestamp = data.state.timestamp;
|
||||
}
|
||||
if (data.state.version) {
|
||||
this.state.version = data.state.version;
|
||||
}
|
||||
if (data.state.operatingMode) {
|
||||
this.state.operatingMode = data.state.operatingMode;
|
||||
}
|
||||
if (data.state.nodeStates) {
|
||||
this.state.nodeStates = data.state.nodeStates;
|
||||
}
|
||||
if (data.state.edgeStates) {
|
||||
this.state.edgeStates = data.state.edgeStates;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
// 发送 State,按照 gRPC 定义的 AgvMessage 结构
|
||||
const agvMessage = {
|
||||
agvId: this.state.serialNumber,
|
||||
state: this.state
|
||||
// message_type 会由 oneof 自动处理,我们只需提供 state 字段
|
||||
};
|
||||
|
||||
if (!sendToStream(agvMessage)) {
|
||||
console.error(`${this.agvId}: 发送状态到 gRPC 服务器失败`);
|
||||
}
|
||||
}
|
||||
|
||||
public stopStatusUpdates() {
|
||||
if (this.updateIntervalId != null) {
|
||||
clearInterval(this.updateIntervalId);
|
||||
this.updateIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 下行 Order 处理
|
||||
public handleOrder(order: any) {
|
||||
// 1) 更新内部状态
|
||||
// console.log("=====>",order);
|
||||
this.orderId = order.orderId;
|
||||
this.orderUpdateId = order.orderUpdateId;
|
||||
this.state.orderId = this.orderId;
|
||||
this.state.orderUpdateId = this.orderUpdateId;
|
||||
this.state.nodeStates = order.nodes.map((n: any, i: number) => ({
|
||||
nodeId: n.nodeId,
|
||||
sequenceId: n.sequenceId ?? i,
|
||||
nodeDescription: n.nodeDescription ?? "",
|
||||
nodePosition: n.nodePosition,
|
||||
released: true,
|
||||
}));
|
||||
this.state.edgeStates = order.edges.map((e: any, i: number) => ({
|
||||
edgeId: e.edgeId,
|
||||
sequenceId: e.sequenceId ?? i,
|
||||
edgeDescription: e.edgeDescription ?? "",
|
||||
released: true,
|
||||
trajectory: e.trajectory,
|
||||
}));
|
||||
this.state.driving = true;
|
||||
|
||||
// 2) 主动通知主线程收到新 Order
|
||||
self.postMessage({
|
||||
type: "orderForwarded",
|
||||
agvId: this.agvId,
|
||||
data: {
|
||||
manufacturer: order.manufacturer,
|
||||
serialNumber: order.serialNumber,
|
||||
orderId: this.orderId,
|
||||
orderUpdateId: this.orderUpdateId,
|
||||
nodes: this.state.nodeStates,
|
||||
edges: this.state.edgeStates,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 下行 InstantActions 处理
|
||||
public handleInstantActions(actions: any[]) {
|
||||
// 1) 更新内部状态
|
||||
// console.log(`${this.agvId} 收到即时动作`, actions);
|
||||
actions.forEach((a) => {
|
||||
switch (a.actionType) {
|
||||
case "STOP":
|
||||
this.state.driving = false;
|
||||
break;
|
||||
case "RESUME":
|
||||
this.state.driving = true;
|
||||
break;
|
||||
case "CHARGE":
|
||||
this.state.batteryState.charging = true;
|
||||
break;
|
||||
case "pick":
|
||||
console.log("pick");
|
||||
break;
|
||||
case "drop":
|
||||
console.log("drop");
|
||||
break;
|
||||
case "stopPause":
|
||||
this.state.paused = true;
|
||||
this.state.driving = false;
|
||||
console.log("stopPause");
|
||||
break;
|
||||
case "startPose":
|
||||
console.log("startPose");
|
||||
this.state.driving = true;
|
||||
break;
|
||||
case "factsheetRequest":
|
||||
console.log("factsheetRequest");
|
||||
break;
|
||||
case "instantActions":
|
||||
console.log("instantActions");
|
||||
break;
|
||||
case "finePositioning":
|
||||
console.log("finePositioning");
|
||||
break;
|
||||
case "startCharging":
|
||||
console.log("startCharging");
|
||||
break;
|
||||
case "stopCharging":
|
||||
console.log("stopCharging");
|
||||
break;
|
||||
case "initPosition":
|
||||
console.log("initPosition");
|
||||
break;
|
||||
case "stateRequst":
|
||||
console.log("stateRequst");
|
||||
break;
|
||||
case "logReport":
|
||||
console.log("logReport");
|
||||
break;
|
||||
case "detectObject":
|
||||
console.log("detectObject");
|
||||
break;
|
||||
case "waitForRtigger":
|
||||
console.log("waitForRtigger");
|
||||
break;
|
||||
case "Standard":
|
||||
console.log("Standard");
|
||||
break;
|
||||
case "switchMap":
|
||||
console.log("switchMap");
|
||||
break;
|
||||
case "reloc":
|
||||
console.log("reloc");
|
||||
break;
|
||||
case "turn":
|
||||
console.log("turn");
|
||||
break;
|
||||
case "confirmLoc":
|
||||
console.log("confirmLoc");
|
||||
break;
|
||||
case "cancelReloc":
|
||||
console.log("cancelReloc");
|
||||
break;
|
||||
case "rotateAgv":
|
||||
console.log("rotateAgv");
|
||||
break;
|
||||
case "rotateLoad":
|
||||
console.log("rotateLoad");
|
||||
break;
|
||||
case "deviceSetup":
|
||||
console.log("deviceSetup:", a);
|
||||
break;
|
||||
case "deviceWrite":
|
||||
console.log("deviceWrite:", a);
|
||||
break;
|
||||
case "deviceDelete":
|
||||
console.log("deviceDelete:", a);
|
||||
break;
|
||||
default:
|
||||
console.warn(`${this.agvId} 未知即时动作`, a);
|
||||
}
|
||||
});
|
||||
|
||||
// 2) 主动通知主线程收到 InstantActions
|
||||
self.postMessage({
|
||||
type: "instantActionsForwarded",
|
||||
agvId: this.agvId,
|
||||
data: actions.map((a) => ({
|
||||
actionType: a.actionType,
|
||||
actionParameters: a.actionParameters,
|
||||
actionDescription: a.actionDescription,
|
||||
actionId: a.actionId,
|
||||
blockingType: a.blockingType,
|
||||
// …你需要的其它字段…
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
sendOnlineStatusGrpc(data: any) {
|
||||
// 若请求失败则保持当前状态
|
||||
if (data === null) return;
|
||||
// agvId: { manufacturer: "gateway", serialNumber: "ZKG-2" }
|
||||
if (data.agvId.manufacturer === this.manufacturer
|
||||
&& data.agvId.serialNumber === this.agvId
|
||||
&& this.agvId === this.serialNumber) {
|
||||
// console.log(`${this.agvId}: KV 数据请求成功`, data);
|
||||
// 3) 计算是否在线
|
||||
const lastSeen = data?.lastSeen
|
||||
? Date.now() - new Date(data.lastSeen).getTime() < 3180 //如果上次 KV 中的 lastSeen 距今超过 3.18 秒,就算离线(无 connection)
|
||||
: false;
|
||||
const isOnline = lastSeen && data.state === "ONLINE";
|
||||
const newMode = isOnline ? "ONLINE" : "OFFLINE";
|
||||
|
||||
// 只有状态变化时才发送 Connection
|
||||
if (newMode !== this.lastOnlineStatus) {
|
||||
this.lastOnlineStatus = newMode;
|
||||
this.sendConnection(this.lastOnlineStatus as any);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 检查在线/离线:从主线程取 KV,再比较时间,若状态变更则发 Connection
|
||||
private async checkOnlineStatus() {
|
||||
// console.log(`${this.agvId} 检查在线状态`);
|
||||
try {
|
||||
// 1) 请求 KV
|
||||
const requestId = Math.random().toString(36).substring(2, 15);
|
||||
self.postMessage({
|
||||
type: "requestKVDataOnline",
|
||||
requestId,
|
||||
key: `device-online:${this.manufacturer}/${this.serialNumber}`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`${this.agvId}: 检查在线状态失败:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 简易状态用于 UI
|
||||
public getStatus() {
|
||||
return {
|
||||
agvId: this.agvId,
|
||||
batteryCharge: this.batteryLevel,
|
||||
timestamp: this.state.timestamp,
|
||||
position: this.state.agvPosition,
|
||||
driving: this.state.driving,
|
||||
charging: this.state.batteryState.charging,
|
||||
operatingMode: this.state.operatingMode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 主流程
|
||||
async function main() {
|
||||
console.log("agv_workermain");
|
||||
|
||||
// 设置事件监听器
|
||||
eventHelper.addEventListener("test-event", (event) => {
|
||||
console.log("🧪 AGV Worker 收到测试事件:", event);
|
||||
});
|
||||
|
||||
eventHelper.addEventListener("grpc-reconnect", (event) => {
|
||||
console.log("🔄 AGV Worker 收到 gRPC 重连指令:", event);
|
||||
reconnectAll();
|
||||
});
|
||||
|
||||
if (!SERVER_URL || devices.length === 0) {
|
||||
console.error("缺少 SERVER_URL 或 无设备,无法启动");
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化 gRPC 流
|
||||
if (!initGrpcStream()) {
|
||||
console.error("gRPC 流初始化失败,无法启动");
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化所有 AGV 并发送上线消息
|
||||
for (const dev of devices) {
|
||||
const sim = new AgvSimulator(dev.serialNumber, dev.manufacturer, dev.serialNumber);
|
||||
agvMap.set(dev.serialNumber, sim);
|
||||
sim.sendConnection("OFFLINE"); // 发送 OFFLINE 状态
|
||||
sim.startStatusUpdates();
|
||||
await delay(200);
|
||||
}
|
||||
console.log("所有 AGV 已上线并开始状态上报");
|
||||
|
||||
// 发送 AGV Worker 启动完成事件
|
||||
eventHelper.dispatchEvent("agv-worker-ready", {
|
||||
deviceCount: devices.length,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// 回主线程汇总状态
|
||||
setInterval(() => {
|
||||
const statuses = Array.from(agvMap.values()).map((s) => s.getStatus());
|
||||
self.postMessage({ type: "devicesStatus", data: statuses });
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// worker 接收消息
|
||||
self.addEventListener("message", (evt: MessageEvent) => {
|
||||
const msg = evt.data;
|
||||
if (msg.type === "init") {
|
||||
SERVER_URL = msg.data.serverUrl;
|
||||
self.postMessage({ type: "inited"});
|
||||
console.log("设置 SERVER_URL =", SERVER_URL);
|
||||
} else if (msg.type === "deviceList") {
|
||||
devices = msg.data as Device[];
|
||||
console.log("devices", devices[0]);
|
||||
main().catch(err => console.error("主流程启动失败:", err));
|
||||
} else if (msg.type === "shutdown") {
|
||||
console.log("Shutdown:关闭 gRPC 流 & 停止所有 AGV");
|
||||
if (stream) {
|
||||
try { stream.end(); } catch (e) { /* 忽略关闭错误 */ }
|
||||
}
|
||||
agvMap.forEach((s) => s.stopStatusUpdates());
|
||||
setTimeout(() => self.close(), 500);
|
||||
} else if (msg.type === "factsheetResponse") {
|
||||
|
||||
// const agvMessage = {
|
||||
// agvId: msg.data.agvId,
|
||||
// state: msg.data.factsheet
|
||||
// // message_type 会由 oneof 自动处理,我们只需提供 state 字段
|
||||
// };
|
||||
// message Factsheet {
|
||||
// uint32 headerId = 1;
|
||||
// string timestamp = 2;
|
||||
// string version = 3;
|
||||
// string manufacturer = 4;
|
||||
// string serialNumber = 5;
|
||||
// TypeSpecification typeSpecification = 6;
|
||||
// PhysicalParameters physicalParameters = 7;
|
||||
// ProtocolLimits protocolLimits = 8;
|
||||
// ProtocolFeatures protocolFeatures = 9;
|
||||
// AgvGeometry agvGeometry = 10;
|
||||
// LoadSpecification loadSpecification = 11;
|
||||
// VehicleConfig vehicleConfig = 12;
|
||||
// }
|
||||
const factsheetMsg = {
|
||||
agvId: msg.data.agvId.serialNumber,
|
||||
factsheet: {
|
||||
headerId: globalHeaderId++,
|
||||
timestamp: new Date().toISOString(),
|
||||
version: "2.0.0",
|
||||
manufacturer: msg.data.agvId.manufacturer,
|
||||
serialNumber: msg.data.agvId.serialNumber,
|
||||
typeSpecification: msg.data.factsheet.typeSpecification,
|
||||
physicalParameters: msg.data.factsheet.physicalParameters,
|
||||
},
|
||||
};
|
||||
// console.log("==>factsheetResponse", factsheetMsg);
|
||||
if (!sendToStream(factsheetMsg)) {
|
||||
console.error(`${msg.data.agvId}: 发送状态到 factsheet 的 gRPC 服务器失败`);
|
||||
}
|
||||
} else if (msg.type === "requestKVData") {
|
||||
// console.log("收到 Worker requestKVData 消息,准备更新设备状态",msg.data.agvId);
|
||||
if(msg && msg.data && msg.data.agvId && msg.data.agvId.serialNumber) {
|
||||
const agv = agvMap.get(msg.data.agvId.serialNumber);
|
||||
if (agv) {
|
||||
agv.sendStateToGrpc(msg.data);
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "requestKVDataOnline") {
|
||||
// console.log("收到 Worker requestKVDataOnline 消息,准备更新设备状态",msg.data.agvId);
|
||||
if(msg && msg.data && msg.data.agvId && msg.data.agvId.serialNumber) {
|
||||
const agv = agvMap.get(msg.data.agvId.serialNumber);
|
||||
if (agv) {
|
||||
agv.sendOnlineStatusGrpc(msg.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
188
cli.ts
Normal file
188
cli.ts
Normal file
@ -0,0 +1,188 @@
|
||||
import { DeviceManager } from "./device_manager.ts";
|
||||
import { updateDeviceListFromConfig, removeDeviceFromWorker } from "./master_manager.ts";
|
||||
|
||||
// 处理设备管理命令
|
||||
async function handleDeviceCommand(args: string[], masterWorker: Worker, instanceId: string) {
|
||||
const deviceManager = new DeviceManager();
|
||||
const subCommand = args[0];
|
||||
|
||||
try {
|
||||
switch (subCommand) {
|
||||
case "add":
|
||||
if (args.length >= 3) {
|
||||
await deviceManager.loadDevices();
|
||||
const success = await deviceManager.addDevice(args[1], args[2]);
|
||||
if (success) {
|
||||
console.log("🔄 正在重新加载设备配置...");
|
||||
// 读取配置文件并获取config对象
|
||||
const configText = await Deno.readTextFile("./config.json");
|
||||
const config = JSON.parse(configText);
|
||||
await updateDeviceListFromConfig(masterWorker, config, instanceId);
|
||||
}
|
||||
} else {
|
||||
console.log("用法: device add <manufacturer> <serialNumber>");
|
||||
}
|
||||
break;
|
||||
|
||||
case "remove":
|
||||
if (args.length >= 3) {
|
||||
await deviceManager.loadDevices();
|
||||
const success = await deviceManager.removeDevice(args[1], args[2]);
|
||||
if (success) {
|
||||
console.log("🔄 正在重新加载设备配置...");
|
||||
const configText = await Deno.readTextFile("./config.json");
|
||||
const config = JSON.parse(configText);
|
||||
await updateDeviceListFromConfig(masterWorker, config, instanceId);
|
||||
}
|
||||
} else {
|
||||
console.log("用法: device remove <manufacturer> <serialNumber>");
|
||||
}
|
||||
break;
|
||||
|
||||
case "force-remove":
|
||||
if (args.length >= 3) {
|
||||
await deviceManager.loadDevices();
|
||||
// 强制从配置文件中删除
|
||||
await deviceManager.forceRemoveDevice(args[1], args[2]);
|
||||
// 立即从Worker中移除
|
||||
await removeDeviceFromWorker(masterWorker, args[1], args[2]);
|
||||
console.log("✅ 设备已完全移除(配置文件 + 运行时状态)");
|
||||
} else {
|
||||
console.log("用法: device force-remove <manufacturer> <serialNumber>");
|
||||
}
|
||||
break;
|
||||
|
||||
case "instant-remove":
|
||||
if (args.length >= 3) {
|
||||
// 仅从运行时移除,不修改配置文件
|
||||
await removeDeviceFromWorker(masterWorker, args[1], args[2]);
|
||||
console.log("✅ 设备已从运行时移除(配置文件保持不变)");
|
||||
} else {
|
||||
console.log("用法: device instant-remove <manufacturer> <serialNumber>");
|
||||
}
|
||||
break;
|
||||
|
||||
case "list":
|
||||
await deviceManager.loadDevices();
|
||||
deviceManager.listDevices();
|
||||
break;
|
||||
|
||||
case "status":
|
||||
// 查询运行时设备状态
|
||||
console.log("🔍 查询运行时设备状态...");
|
||||
masterWorker.postMessage({ type: "discoverDevices" });
|
||||
break;
|
||||
|
||||
case "reload":{
|
||||
console.log("🔄 正在重新加载设备配置...");
|
||||
const configText = await Deno.readTextFile("./config.json");
|
||||
const config = JSON.parse(configText);
|
||||
await updateDeviceListFromConfig(masterWorker, config, instanceId);
|
||||
break;
|
||||
}
|
||||
case "generate":{
|
||||
const count = parseInt(args[1]) || 10;
|
||||
const prefix = args[2] || "TEST";
|
||||
await deviceManager.loadDevices();
|
||||
await deviceManager.generateTestDevices(count, prefix);
|
||||
console.log("🔄 正在重新加载设备配置...");
|
||||
const configText2 = await Deno.readTextFile("./config.json");
|
||||
const config2 = JSON.parse(configText2);
|
||||
await updateDeviceListFromConfig(masterWorker, config2, instanceId);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log(`
|
||||
🔧 设备管理命令:
|
||||
device add <manufacturer> <serialNumber> - 添加新设备
|
||||
device remove <manufacturer> <serialNumber> - 删除设备(配置文件+重新加载)
|
||||
device force-remove <manufacturer> <serialNumber> - 强制删除设备(配置文件+运行时)
|
||||
device instant-remove <manufacturer> <serialNumber> - 即时删除设备(仅运行时)
|
||||
device list - 列出所有设备
|
||||
device status - 查询运行时设备状态
|
||||
device reload - 重新加载设备配置
|
||||
device generate <count> [prefix] - 生成测试设备
|
||||
`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 设备命令执行失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function startCli(
|
||||
masterWorker: Worker,
|
||||
agvWorker: Worker,
|
||||
sendNavigationOrder: () => void,
|
||||
cancelOrder: (orderId: string) => void,
|
||||
sendCustomCommand: (cmd: string) => void,
|
||||
instanceId: string
|
||||
) {
|
||||
const decoder = new TextDecoder();
|
||||
const reader = Deno.stdin.readable.getReader();
|
||||
|
||||
console.log("\n可用命令:");
|
||||
console.log(" send - 发送新的导航订单");
|
||||
console.log(" cancel <orderId> - 取消指定的订单");
|
||||
console.log(" discover - 请求当前所有发现的设备信息");
|
||||
console.log(" setcmd <command> - 发送自定义命令设置到 Worker");
|
||||
console.log(" requestKV - 请求从 KV 中获取设备数据,并发送给 agvWorker");
|
||||
console.log(" device add <manufacturer> <serialNumber> - 动态添加设备");
|
||||
console.log(" device remove <manufacturer> <serialNumber> - 删除设备(配置+重载)");
|
||||
console.log(" device force-remove <manufacturer> <serialNumber> - 强制删除设备");
|
||||
console.log(" device instant-remove <manufacturer> <serialNumber> - 即时删除设备");
|
||||
console.log(" device list - 列出所有配置的设备");
|
||||
console.log(" device reload - 重新加载设备配置");
|
||||
console.log(" device generate <count> [prefix] - 生成测试设备");
|
||||
console.log(" exit - 退出程序");
|
||||
|
||||
(async () => {
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value);
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
const [cmd, ...args] = trimmed.split(/\s+/);
|
||||
switch (cmd) {
|
||||
case "send":
|
||||
sendNavigationOrder();
|
||||
break;
|
||||
case "cancel":
|
||||
if (args[0]) cancelOrder(args[0]);
|
||||
else console.log("请提供订单ID");
|
||||
break;
|
||||
case "discover":
|
||||
masterWorker.postMessage({ type: "discoverDevices" });
|
||||
break;
|
||||
case "setcmd":
|
||||
if (args.length) sendCustomCommand(args.join(" "));
|
||||
else console.log("请提供自定义命令内容");
|
||||
break;
|
||||
case "requestKV":
|
||||
agvWorker.postMessage({ type: "requestKVData" });
|
||||
break;
|
||||
case "device":
|
||||
await handleDeviceCommand(args, masterWorker, instanceId);
|
||||
break;
|
||||
case "exit":
|
||||
masterWorker.postMessage({ type: "shutdown" });
|
||||
agvWorker.postMessage("shutdown");
|
||||
setTimeout(() => {
|
||||
agvWorker.terminate();
|
||||
masterWorker.terminate();
|
||||
console.log("所有 Worker 已终止,程序退出");
|
||||
Deno.exit(0);
|
||||
}, 2000);
|
||||
return;
|
||||
default:
|
||||
if (trimmed) console.log(`未知命令: ${trimmed}`);
|
||||
console.log("可用命令: send, cancel <orderId>, discover, setcmd <command>, requestKV, device <subcommand>, exit");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
12
config.json
Normal file
12
config.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"mqtt": {
|
||||
"brokerUrl": "mqtt://10.2.0.6:1883",
|
||||
"clientId": "transformer13",
|
||||
"reconnectInterval": 5000,
|
||||
"qos": 1
|
||||
},
|
||||
"serverUrl": "127.0.0.1:50052",
|
||||
"interfaceName": "oagv",
|
||||
"manufacturer": "gateway",
|
||||
"mappingFile": "mapping.json"
|
||||
}
|
283
debug_logger.ts
Normal file
283
debug_logger.ts
Normal file
@ -0,0 +1,283 @@
|
||||
// debug_logger.ts - 调试日志模块
|
||||
export enum LogLevel {
|
||||
ERROR = 0,
|
||||
WARN = 1,
|
||||
INFO = 2,
|
||||
DEBUG = 3,
|
||||
TRACE = 4
|
||||
}
|
||||
|
||||
export interface LogConfig {
|
||||
level: LogLevel;
|
||||
enableTimestamp: boolean;
|
||||
enableColors: boolean;
|
||||
enableFileOutput: boolean;
|
||||
logFilePath?: string;
|
||||
modules: {
|
||||
[moduleName: string]: LogLevel;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: LogConfig = {
|
||||
level: LogLevel.INFO,
|
||||
enableTimestamp: true,
|
||||
enableColors: true,
|
||||
enableFileOutput: false,
|
||||
modules: {}
|
||||
};
|
||||
|
||||
class DebugLogger {
|
||||
private config: LogConfig;
|
||||
private logFile?: Deno.FsFile;
|
||||
|
||||
constructor(config: Partial<LogConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
|
||||
if (this.config.enableFileOutput && this.config.logFilePath) {
|
||||
this.initLogFile();
|
||||
}
|
||||
}
|
||||
|
||||
private async initLogFile() {
|
||||
try {
|
||||
this.logFile = await Deno.open(this.config.logFilePath!, {
|
||||
create: true,
|
||||
write: true,
|
||||
append: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to open log file: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getTimestamp(): string {
|
||||
if (!this.config.enableTimestamp) return "";
|
||||
return `[${new Date().toISOString()}] `;
|
||||
}
|
||||
|
||||
private getColorCode(level: LogLevel): string {
|
||||
if (!this.config.enableColors) return "";
|
||||
|
||||
switch (level) {
|
||||
case LogLevel.ERROR: return "\x1b[31m"; // Red
|
||||
case LogLevel.WARN: return "\x1b[33m"; // Yellow
|
||||
case LogLevel.INFO: return "\x1b[36m"; // Cyan
|
||||
case LogLevel.DEBUG: return "\x1b[32m"; // Green
|
||||
case LogLevel.TRACE: return "\x1b[35m"; // Magenta
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
private getResetCode(): string {
|
||||
return this.config.enableColors ? "\x1b[0m" : "";
|
||||
}
|
||||
|
||||
private getLevelName(level: LogLevel): string {
|
||||
switch (level) {
|
||||
case LogLevel.ERROR: return "ERROR";
|
||||
case LogLevel.WARN: return "WARN ";
|
||||
case LogLevel.INFO: return "INFO ";
|
||||
case LogLevel.DEBUG: return "DEBUG";
|
||||
case LogLevel.TRACE: return "TRACE";
|
||||
default: return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel, module?: string): boolean {
|
||||
// 检查模块特定的日志级别
|
||||
if (module && this.config.modules[module] !== undefined) {
|
||||
return level <= this.config.modules[module];
|
||||
}
|
||||
|
||||
// 使用全局日志级别
|
||||
return level <= this.config.level;
|
||||
}
|
||||
|
||||
private formatMessage(level: LogLevel, module: string, message: string, ...args: any[]): string {
|
||||
const timestamp = this.getTimestamp();
|
||||
const colorCode = this.getColorCode(level);
|
||||
const resetCode = this.getResetCode();
|
||||
const levelName = this.getLevelName(level);
|
||||
|
||||
const formattedArgs = args.length > 0 ? " " + args.map(arg =>
|
||||
typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)
|
||||
).join(" ") : "";
|
||||
|
||||
return `${timestamp}${colorCode}[${levelName}]${resetCode} [${module}] ${message}${formattedArgs}`;
|
||||
}
|
||||
|
||||
private async writeLog(formattedMessage: string) {
|
||||
// 输出到控制台
|
||||
console.log(formattedMessage);
|
||||
|
||||
// 输出到文件
|
||||
if (this.logFile) {
|
||||
try {
|
||||
const encoder = new TextEncoder();
|
||||
await this.logFile.write(encoder.encode(formattedMessage + "\n"));
|
||||
await this.logFile.sync();
|
||||
} catch (error) {
|
||||
console.error(`Failed to write to log file: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log(level: LogLevel, module: string, message: string, ...args: any[]) {
|
||||
if (!this.shouldLog(level, module)) return;
|
||||
|
||||
const formattedMessage = this.formatMessage(level, module, message, ...args);
|
||||
this.writeLog(formattedMessage);
|
||||
}
|
||||
|
||||
error(module: string, message: string, ...args: any[]) {
|
||||
this.log(LogLevel.ERROR, module, message, ...args);
|
||||
}
|
||||
|
||||
warn(module: string, message: string, ...args: any[]) {
|
||||
this.log(LogLevel.WARN, module, message, ...args);
|
||||
}
|
||||
|
||||
info(module: string, message: string, ...args: any[]) {
|
||||
this.log(LogLevel.INFO, module, message, ...args);
|
||||
}
|
||||
|
||||
debug(module: string, message: string, ...args: any[]) {
|
||||
this.log(LogLevel.DEBUG, module, message, ...args);
|
||||
}
|
||||
|
||||
trace(module: string, message: string, ...args: any[]) {
|
||||
this.log(LogLevel.TRACE, module, message, ...args);
|
||||
}
|
||||
|
||||
// 设置模块特定的日志级别
|
||||
setModuleLevel(module: string, level: LogLevel) {
|
||||
this.config.modules[module] = level;
|
||||
}
|
||||
|
||||
// 设置全局日志级别
|
||||
setGlobalLevel(level: LogLevel) {
|
||||
this.config.level = level;
|
||||
}
|
||||
|
||||
// 获取当前配置
|
||||
getConfig(): LogConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
// 关闭日志文件
|
||||
async close() {
|
||||
if (this.logFile) {
|
||||
await this.logFile.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局调试器实例
|
||||
export const debugLogger = new DebugLogger({
|
||||
level: LogLevel.DEBUG,
|
||||
enableTimestamp: true,
|
||||
enableColors: true,
|
||||
enableFileOutput: false,
|
||||
logFilePath: "./debug.log",
|
||||
modules: {
|
||||
"DEVICE_SIMULATOR": LogLevel.ERROR,
|
||||
"MODBUS": LogLevel.ERROR,
|
||||
"MQTT": LogLevel.ERROR,
|
||||
"REGISTER_CONFIG": LogLevel.ERROR,
|
||||
"DEVICE_MANAGER": LogLevel.ERROR,
|
||||
"SIMULATOR_MAIN": LogLevel.ERROR
|
||||
}
|
||||
});
|
||||
|
||||
// 便捷的模块日志器创建函数
|
||||
export function createModuleLogger(moduleName: string) {
|
||||
return {
|
||||
error: (message: string, ...args: any[]) => debugLogger.error(moduleName, message, ...args),
|
||||
warn: (message: string, ...args: any[]) => debugLogger.warn(moduleName, message, ...args),
|
||||
info: (message: string, ...args: any[]) => debugLogger.info(moduleName, message, ...args),
|
||||
debug: (message: string, ...args: any[]) => debugLogger.debug(moduleName, message, ...args),
|
||||
trace: (message: string, ...args: any[]) => debugLogger.trace(moduleName, message, ...args)
|
||||
};
|
||||
}
|
||||
|
||||
// 性能监控工具
|
||||
export class PerformanceMonitor {
|
||||
private timers: Map<string, number> = new Map();
|
||||
private logger = createModuleLogger("PERFORMANCE");
|
||||
|
||||
start(label: string) {
|
||||
this.timers.set(label, performance.now());
|
||||
this.logger.trace(`⏱️ Started timer: ${label}`);
|
||||
}
|
||||
|
||||
end(label: string): number {
|
||||
const startTime = this.timers.get(label);
|
||||
if (!startTime) {
|
||||
this.logger.trace(`⚠️ Timer not found: ${label}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
this.timers.delete(label);
|
||||
this.logger.trace(`⏱️ ${label}: ${duration.toFixed(2)}ms`);
|
||||
return duration;
|
||||
}
|
||||
|
||||
measure<T>(label: string, fn: () => T): T {
|
||||
this.start(label);
|
||||
try {
|
||||
const result = fn();
|
||||
this.end(label);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.end(label);
|
||||
this.logger.error(`❌ Error in ${label}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async measureAsync<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
this.start(label);
|
||||
try {
|
||||
const result = await fn();
|
||||
this.end(label);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.end(label);
|
||||
this.logger.error(`❌ Error in ${label}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const perfMonitor = new PerformanceMonitor();
|
||||
|
||||
// 内存使用监控
|
||||
export function logMemoryUsage(module: string) {
|
||||
if (typeof Deno !== 'undefined' && Deno.memoryUsage) {
|
||||
const memory = Deno.memoryUsage();
|
||||
const logger = createModuleLogger(module);
|
||||
logger.debug(`💾 Memory usage:`, {
|
||||
rss: `${(memory.rss / 1024 / 1024).toFixed(2)}MB`,
|
||||
heapTotal: `${(memory.heapTotal / 1024 / 1024).toFixed(2)}MB`,
|
||||
heapUsed: `${(memory.heapUsed / 1024 / 1024).toFixed(2)}MB`,
|
||||
external: `${(memory.external / 1024 / 1024).toFixed(2)}MB`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 调试配置管理
|
||||
export function setDebugLevel(level: LogLevel | string, module?: string) {
|
||||
const logLevel = typeof level === 'string' ?
|
||||
LogLevel[level.toUpperCase() as keyof typeof LogLevel] : level;
|
||||
|
||||
if (module) {
|
||||
debugLogger.setModuleLevel(module, logLevel);
|
||||
console.log(`🔧 Set debug level for ${module}: ${LogLevel[logLevel]}`);
|
||||
} else {
|
||||
debugLogger.setGlobalLevel(logLevel);
|
||||
console.log(`🔧 Set global debug level: ${LogLevel[logLevel]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 调试工具已在上面导出
|
53
deno.json
Normal file
53
deno.json
Normal file
@ -0,0 +1,53 @@
|
||||
{
|
||||
"tasks": {
|
||||
"check": "deno check agv_main.ts",
|
||||
"check:watch": "deno check --watch agv_main.ts",
|
||||
"run": "deno run --log-level=info --inspect --allow-net --allow-read --unstable-kv --allow-env --node-modules-dir agv_main.ts",
|
||||
"run:watch": "deno run --watch --allow-net --allow-read --allow-hrtime --unstable-kv --allow-env --node-modules-dir agv_main.ts",
|
||||
"compile": "deno compile --include agv_worker.ts --include agv_manager.ts --include master_manager.ts --include web_worker.ts --include cli.ts --include vda_worker.ts --include vda5050_transformer_worker.ts --allow-net --allow-read --unstable-kv --allow-env --node-modules-dir --output agv_api agv_main.ts",
|
||||
"compile:all": "deno task compile:linux && deno task compile:windows && deno task compile:mac",
|
||||
"compile:linux": "deno compile --allow-net --allow-read --allow-hrtime --unstable-kv --allow-env --node-modules-dir --target x86_64-unknown-linux-gnu --output agv_simulator_linux agv_main.ts",
|
||||
"compile:windows": "deno compile --allow-net --allow-read --allow-hrtime --unstable-kv --allow-env --node-modules-dir --target x86_64-pc-windows-msvc --output agv_simulator_windows agv_main.ts",
|
||||
"compile:mac": "deno compile --allow-net --allow-read --allow-hrtime --unstable-kv --allow-env --node-modules-dir --target x86_64-apple-darwin --output agv_simulator_mac agv_main.ts",
|
||||
"clean": "rm -f agv_simulator agv_simulator_linux agv_simulator_windows agv_simulator_mac",
|
||||
"install:deps": "deno cache --reload agv_main.ts",
|
||||
"bundle": "deno bundle agv_main.ts > agv_simulator.js",
|
||||
"sim": "deno run --allow-net --allow-read --allow-env --allow-write --allow-run simulator_main.ts",
|
||||
"sim:compile": "deno compile --include simulator.ts --include device_simulator.ts --include simulator_config.ts --include debug_logger.ts --allow-net --allow-read --allow-env --allow-write --allow-run --output simulator simulator_main.ts",
|
||||
"sim:compile:all": "deno task sim:compile:linux && deno task sim:compile:windows && deno task sim:compile:mac",
|
||||
"sim:compile:linux": "deno compile --include simulator.ts --include device_simulator.ts --include simulator_config.ts --include debug_logger.ts --allow-net --allow-read --allow-env --allow-write --allow-run --target x86_64-unknown-linux-gnu --output simulator_linux simulator_main.ts",
|
||||
"sim:compile:windows": "deno compile --include simulator.ts --include device_simulator.ts --include simulator_config.ts --include debug_logger.ts --allow-net --allow-read --allow-env --allow-write --allow-run --target x86_64-pc-windows-msvc --output simulator_windows simulator_main.ts",
|
||||
"sim:compile:mac": "deno compile --include simulator.ts --include device_simulator.ts --include simulator_config.ts --include debug_logger.ts --allow-net --allow-read --allow-env --allow-write --allow-run --target x86_64-apple-darwin --output simulator_mac simulator_main.ts",
|
||||
"sim:clean": "rm -f simulator simulator_linux simulator_windows simulator_mac"
|
||||
|
||||
},
|
||||
"env": {
|
||||
"DEBUG": "*"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"lib": ["deno.window", "deno.worker"],
|
||||
"strict": true
|
||||
},
|
||||
"importMap": "import_map.json",
|
||||
"fmt": {
|
||||
"files": {
|
||||
"include": ["*.ts", "*.js", "*.json"]
|
||||
},
|
||||
"options": {
|
||||
"useTabs": false,
|
||||
"lineWidth": 100,
|
||||
"indentWidth": 2,
|
||||
"singleQuote": true,
|
||||
"proseWrap": "preserve"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"files": {
|
||||
"include": ["*.ts", "*.js"]
|
||||
},
|
||||
"rules": {
|
||||
"tags": ["recommended"]
|
||||
}
|
||||
}
|
||||
}
|
279
device_manager.ts
Normal file
279
device_manager.ts
Normal file
@ -0,0 +1,279 @@
|
||||
// device_manager.ts - 动态设备管理工具
|
||||
|
||||
interface DeviceConfig {
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
}
|
||||
|
||||
export class DeviceManager {
|
||||
private devicesFilePath: string;
|
||||
private devices: DeviceConfig[] = [];
|
||||
|
||||
constructor(devicesFilePath = "./devices.json") {
|
||||
this.devicesFilePath = devicesFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载现有设备列表
|
||||
*/
|
||||
async loadDevices(): Promise<DeviceConfig[]> {
|
||||
try {
|
||||
const text = await Deno.readTextFile(this.devicesFilePath);
|
||||
this.devices = JSON.parse(text);
|
||||
console.log(`📱 已加载 ${this.devices.length} 个设备`);
|
||||
return this.devices;
|
||||
} catch (error) {
|
||||
if (error instanceof Deno.errors.NotFound) {
|
||||
console.log("📄 devices.json 不存在,创建新文件");
|
||||
this.devices = [];
|
||||
await this.saveDevices();
|
||||
return this.devices;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存设备列表到文件
|
||||
*/
|
||||
async saveDevices(): Promise<void> {
|
||||
const json = JSON.stringify(this.devices, null, 2);
|
||||
await Deno.writeTextFile(this.devicesFilePath, json);
|
||||
console.log(`💾 已保存 ${this.devices.length} 个设备到 ${this.devicesFilePath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加新设备
|
||||
*/
|
||||
async addDevice(manufacturer: string, serialNumber: string): Promise<boolean> {
|
||||
const deviceKey = `${manufacturer}-${serialNumber}`;
|
||||
const exists = this.devices.some(d =>
|
||||
d.manufacturer === manufacturer && d.serialNumber === serialNumber
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
console.log(`⚠️ 设备 ${deviceKey} 已存在`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.devices.push({ manufacturer, serialNumber });
|
||||
await this.saveDevices();
|
||||
console.log(`➕ 已添加设备: ${deviceKey}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加设备
|
||||
*/
|
||||
async addDevices(newDevices: DeviceConfig[]): Promise<number> {
|
||||
let addedCount = 0;
|
||||
|
||||
for (const device of newDevices) {
|
||||
const exists = this.devices.some(d =>
|
||||
d.manufacturer === device.manufacturer && d.serialNumber === device.serialNumber
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
this.devices.push(device);
|
||||
addedCount++;
|
||||
console.log(`➕ 已添加设备: ${device.manufacturer}-${device.serialNumber}`);
|
||||
} else {
|
||||
console.log(`⚠️ 设备 ${device.manufacturer}-${device.serialNumber} 已存在,跳过`);
|
||||
}
|
||||
}
|
||||
|
||||
if (addedCount > 0) {
|
||||
await this.saveDevices();
|
||||
}
|
||||
|
||||
console.log(`✅ 批量添加完成: 新增 ${addedCount} 个设备`);
|
||||
return addedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除设备
|
||||
*/
|
||||
async removeDevice(manufacturer: string, serialNumber: string): Promise<boolean> {
|
||||
const deviceKey = `${manufacturer}-${serialNumber}`;
|
||||
const initialLength = this.devices.length;
|
||||
|
||||
this.devices = this.devices.filter(d =>
|
||||
!(d.manufacturer === manufacturer && d.serialNumber === serialNumber)
|
||||
);
|
||||
|
||||
if (this.devices.length < initialLength) {
|
||||
await this.saveDevices();
|
||||
console.log(`➖ 已删除设备: ${deviceKey}`);
|
||||
return true;
|
||||
} else {
|
||||
console.log(`⚠️ 设备 ${deviceKey} 不存在`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制删除设备(即使配置文件中不存在)
|
||||
*/
|
||||
async forceRemoveDevice(manufacturer: string, serialNumber: string): Promise<boolean> {
|
||||
const deviceKey = `${manufacturer}-${serialNumber}`;
|
||||
const initialLength = this.devices.length;
|
||||
|
||||
// 从内存中移除
|
||||
this.devices = this.devices.filter(d =>
|
||||
!(d.manufacturer === manufacturer && d.serialNumber === serialNumber)
|
||||
);
|
||||
|
||||
// 总是保存文件,确保一致性
|
||||
await this.saveDevices();
|
||||
|
||||
if (this.devices.length < initialLength) {
|
||||
console.log(`🗑️ 已强制删除设备: ${deviceKey}`);
|
||||
} else {
|
||||
console.log(`🗑️ 强制删除设备: ${deviceKey} (配置文件中不存在,但已清理运行时状态)`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有设备
|
||||
*/
|
||||
listDevices(): DeviceConfig[] {
|
||||
console.log(`📋 当前设备列表 (${this.devices.length} 个):`);
|
||||
this.devices.forEach((device, index) => {
|
||||
console.log(` ${index + 1}. ${device.manufacturer}/${device.serialNumber}`);
|
||||
});
|
||||
return this.devices;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有设备
|
||||
*/
|
||||
async clearDevices(): Promise<void> {
|
||||
this.devices = [];
|
||||
await this.saveDevices();
|
||||
console.log("🗑️ 已清空所有设备");
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成测试设备
|
||||
*/
|
||||
async generateTestDevices(count: number, prefix = "TEST"): Promise<void> {
|
||||
const testDevices: DeviceConfig[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
testDevices.push({
|
||||
manufacturer: "SEER",
|
||||
serialNumber: `${prefix}-${i}`
|
||||
});
|
||||
}
|
||||
|
||||
await this.addDevices(testDevices);
|
||||
console.log(`🧪 已生成 ${count} 个测试设备`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查设备是否存在
|
||||
*/
|
||||
deviceExists(manufacturer: string, serialNumber: string): boolean {
|
||||
return this.devices.some(d =>
|
||||
d.manufacturer === manufacturer && d.serialNumber === serialNumber
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备数量
|
||||
*/
|
||||
getDeviceCount(): number {
|
||||
return this.devices.length;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出便捷函数
|
||||
export async function addDeviceToConfig(manufacturer: string, serialNumber: string): Promise<boolean> {
|
||||
const manager = new DeviceManager();
|
||||
await manager.loadDevices();
|
||||
return await manager.addDevice(manufacturer, serialNumber);
|
||||
}
|
||||
|
||||
export async function removeDeviceFromConfig(manufacturer: string, serialNumber: string): Promise<boolean> {
|
||||
const manager = new DeviceManager();
|
||||
await manager.loadDevices();
|
||||
return await manager.removeDevice(manufacturer, serialNumber);
|
||||
}
|
||||
|
||||
export async function listConfigDevices(): Promise<DeviceConfig[]> {
|
||||
const manager = new DeviceManager();
|
||||
await manager.loadDevices();
|
||||
return manager.listDevices();
|
||||
}
|
||||
|
||||
// CLI 使用示例
|
||||
if (import.meta.main) {
|
||||
const manager = new DeviceManager();
|
||||
await manager.loadDevices();
|
||||
|
||||
const args = Deno.args;
|
||||
const command = args[0];
|
||||
|
||||
switch (command) {
|
||||
case "add":
|
||||
if (args.length >= 3) {
|
||||
await manager.addDevice(args[1], args[2]);
|
||||
} else {
|
||||
console.log("用法: deno run device_manager.ts add <manufacturer> <serialNumber>");
|
||||
}
|
||||
break;
|
||||
|
||||
case "remove":
|
||||
if (args.length >= 3) {
|
||||
await manager.removeDevice(args[1], args[2]);
|
||||
} else {
|
||||
console.log("用法: deno run device_manager.ts remove <manufacturer> <serialNumber>");
|
||||
}
|
||||
break;
|
||||
|
||||
case "force-remove":
|
||||
if (args.length >= 3) {
|
||||
await manager.forceRemoveDevice(args[1], args[2]);
|
||||
} else {
|
||||
console.log("用法: deno run device_manager.ts force-remove <manufacturer> <serialNumber>");
|
||||
}
|
||||
break;
|
||||
|
||||
case "list":
|
||||
manager.listDevices();
|
||||
break;
|
||||
|
||||
case "clear":
|
||||
await manager.clearDevices();
|
||||
break;
|
||||
|
||||
case "generate":
|
||||
const count = parseInt(args[1]) || 10;
|
||||
const prefix = args[2] || "TEST";
|
||||
await manager.generateTestDevices(count, prefix);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`
|
||||
🔧 设备管理工具
|
||||
|
||||
用法:
|
||||
deno run --allow-read --allow-write device_manager.ts <command> [args]
|
||||
|
||||
命令:
|
||||
add <manufacturer> <serialNumber> - 添加设备
|
||||
remove <manufacturer> <serialNumber> - 删除设备
|
||||
force-remove <manufacturer> <serialNumber> - 强制删除设备
|
||||
list - 列出所有设备
|
||||
clear - 清空所有设备
|
||||
generate <count> [prefix] - 生成测试设备
|
||||
|
||||
示例:
|
||||
deno run --allow-read --allow-write device_manager.ts add SEER AGV-001
|
||||
deno run --allow-read --allow-write device_manager.ts list
|
||||
deno run --allow-read --allow-write device_manager.ts generate 5 TEST
|
||||
`);
|
||||
}
|
||||
}
|
190
device_protocol_config.ts
Normal file
190
device_protocol_config.ts
Normal file
@ -0,0 +1,190 @@
|
||||
// device_protocol_config.ts - 设备协议配置示例
|
||||
// 展示如何根据设备信息自动配置Modbus参数
|
||||
import { createModuleLogger } from "./debug_logger.ts";
|
||||
|
||||
const logger = createModuleLogger("REGISTER_CONFIG");
|
||||
|
||||
// 获取默认轮询间隔(基于设备品牌优化)
|
||||
export function getDefaultPollInterval(brandName?: string): number {
|
||||
// 根据设备品牌返回优化的轮询间隔
|
||||
const interval = (() => {
|
||||
switch (brandName) {
|
||||
case "西门子": return 500; // 西门子设备响应较快
|
||||
case "台达": return 1000; // 台达设备标准间隔
|
||||
case "三菱": return 1500; // 三菱设备较保守间隔
|
||||
default: return 2000; // 通用设备默认间隔
|
||||
}
|
||||
})();
|
||||
|
||||
logger.debug("⏱️ Poll interval determined", {
|
||||
brandName: brandName || "unknown",
|
||||
interval: `${interval}ms`,
|
||||
reason: brandName ? "brand_specific" : "default"
|
||||
});
|
||||
|
||||
return interval;
|
||||
}
|
||||
|
||||
// 寄存器定义接口
|
||||
export interface RegisterDefinition {
|
||||
fnCode: string; // 功能码:1=读线圈,2=读离散输入,3=读保持寄存器,4=读输入寄存器,6=写单个寄存器
|
||||
name: string; // 寄存器名称
|
||||
bind?: string; // 可选:绑定 ID
|
||||
regCount: string; // 寄存器数量
|
||||
regAddress: string; // 寄存器地址
|
||||
}
|
||||
|
||||
// 功能码映射到Modbus函数
|
||||
const FUNCTION_CODE_MAP: Record<string, "readHoldingRegisters" | "readInputRegisters" | "readCoils" | "readDiscreteInputs"> = {
|
||||
"1": "readCoils", // 读线圈
|
||||
"2": "readDiscreteInputs", // 读离散输入
|
||||
"3": "readHoldingRegisters", // 读保持寄存器
|
||||
"4": "readInputRegisters", // 读输入寄存器
|
||||
"6": "readHoldingRegisters" // 写单个寄存器(读取时用保持寄存器)
|
||||
};
|
||||
|
||||
// 从寄存器定义创建轮询配置
|
||||
export function createModbusPollConfigFromRegisters(registers: RegisterDefinition[]) {
|
||||
logger.info("🔄 Creating Modbus polling configuration", {
|
||||
totalRegisters: registers.length,
|
||||
registers: registers.map(r => ({ name: r.name, fnCode: r.fnCode, address: r.regAddress }))
|
||||
});
|
||||
|
||||
const pollConfig = [];
|
||||
const skippedRegisters = [];
|
||||
|
||||
for (const reg of registers) {
|
||||
const fnCode = reg.fnCode;
|
||||
const modbusFunction = FUNCTION_CODE_MAP[fnCode];
|
||||
|
||||
logger.trace("🔍 Processing register", {
|
||||
name: reg.name,
|
||||
fnCode,
|
||||
address: reg.regAddress,
|
||||
count: reg.regCount,
|
||||
modbusFunction
|
||||
});
|
||||
|
||||
if (!modbusFunction) {
|
||||
const warning = `Unsupported function code: ${fnCode} for register ${reg.name}`;
|
||||
logger.warn("⚠️ " + warning, {
|
||||
register: reg,
|
||||
supportedCodes: Object.keys(FUNCTION_CODE_MAP)
|
||||
});
|
||||
console.warn(warning);
|
||||
skippedRegisters.push({ register: reg.name, reason: "unsupported_function_code" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 只为读取功能码创建轮询配置(功能码6是写操作,但我们也可以读取它的当前值)
|
||||
if (["1", "2", "3", "4", "6"].includes(fnCode)) {
|
||||
const pollItem = {
|
||||
fn: modbusFunction,
|
||||
start: parseInt(reg.regAddress),
|
||||
len: parseInt(reg.regCount),
|
||||
mqttKey: `device/register/${reg.name}`,
|
||||
bind: reg.bind || reg.name
|
||||
};
|
||||
|
||||
logger.debug("✅ Created poll config for register", {
|
||||
register: reg.name,
|
||||
pollItem
|
||||
});
|
||||
|
||||
pollConfig.push(pollItem);
|
||||
} else {
|
||||
logger.warn("⚠️ Function code not supported for polling", {
|
||||
register: reg.name,
|
||||
fnCode,
|
||||
reason: "not_readable"
|
||||
});
|
||||
skippedRegisters.push({ register: reg.name, reason: "not_readable" });
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("🎯 Polling configuration creation completed", {
|
||||
totalInput: registers.length,
|
||||
totalOutput: pollConfig.length,
|
||||
skipped: skippedRegisters.length,
|
||||
skippedDetails: skippedRegisters,
|
||||
successRate: `${((pollConfig.length / registers.length) * 100).toFixed(1)}%`
|
||||
});
|
||||
|
||||
return pollConfig;
|
||||
}
|
||||
|
||||
// 解析寄存器字符串
|
||||
export function parseRegistersFromString(registersStr: string): RegisterDefinition[] {
|
||||
logger.debug("📝 Parsing registers string", {
|
||||
inputLength: registersStr.length,
|
||||
inputPreview: registersStr.substring(0, 100) + (registersStr.length > 100 ? '...' : '')
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(registersStr);
|
||||
|
||||
logger.trace("🔍 JSON parsing successful", {
|
||||
parsedType: typeof parsed,
|
||||
isArray: Array.isArray(parsed),
|
||||
length: Array.isArray(parsed) ? parsed.length : 'not_array'
|
||||
});
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
logger.error("❌ Parsed data is not an array", { parsedType: typeof parsed, parsed });
|
||||
return [];
|
||||
}
|
||||
|
||||
const registers = parsed as RegisterDefinition[];
|
||||
const validRegisters = [];
|
||||
const invalidRegisters = [];
|
||||
|
||||
for (const reg of registers) {
|
||||
const isValid = reg.fnCode && reg.name && reg.regCount && reg.regAddress;
|
||||
|
||||
logger.trace("🔍 Validating register", {
|
||||
register: reg,
|
||||
isValid,
|
||||
missingFields: {
|
||||
fnCode: !reg.fnCode,
|
||||
name: !reg.name,
|
||||
regCount: !reg.regCount,
|
||||
regAddress: !reg.regAddress
|
||||
}
|
||||
});
|
||||
|
||||
if (isValid) {
|
||||
validRegisters.push(reg);
|
||||
} else {
|
||||
invalidRegisters.push({
|
||||
register: reg,
|
||||
missingFields: Object.keys(reg).filter(key => !reg[key as keyof RegisterDefinition])
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("✅ Register parsing completed", {
|
||||
totalInput: registers.length,
|
||||
validRegisters: validRegisters.length,
|
||||
invalidRegisters: invalidRegisters.length,
|
||||
invalidDetails: invalidRegisters,
|
||||
successRate: `${((validRegisters.length / registers.length) * 100).toFixed(1)}%`
|
||||
});
|
||||
|
||||
return validRegisters;
|
||||
} catch (error) {
|
||||
logger.error("❌ Failed to parse registers string", {
|
||||
error: (error as Error).message,
|
||||
inputString: registersStr
|
||||
});
|
||||
console.error("Failed to parse registers string:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default {
|
||||
getDefaultPollInterval,
|
||||
createModbusPollConfigFromRegisters,
|
||||
parseRegistersFromString
|
||||
};
|
1374
device_simulator.ts
Normal file
1374
device_simulator.ts
Normal file
File diff suppressed because it is too large
Load Diff
538
device_simulator_main.ts
Normal file
538
device_simulator_main.ts
Normal file
@ -0,0 +1,538 @@
|
||||
// device_simulator_main.ts - 设备模拟器管理器
|
||||
import { loadConfig, RawConfig } from "./simulator_config.ts";
|
||||
import { connect, MqttClient } from "npm:mqtt@5.10.1";
|
||||
|
||||
interface DeviceInfo {
|
||||
ip: string;
|
||||
port: string;
|
||||
slaveId: string;
|
||||
deviceName: string;
|
||||
protocolType: string;
|
||||
brandName: string;
|
||||
}
|
||||
|
||||
interface DeviceWorkerMessage {
|
||||
type: "init" | "close" | "reconnect" | "action";
|
||||
data?: any;
|
||||
}
|
||||
|
||||
interface DeviceMainMessage {
|
||||
type: "ready" | "error" | "status" | "log" | "reset";
|
||||
data?: any;
|
||||
}
|
||||
|
||||
interface DeviceWorker {
|
||||
worker: Worker;
|
||||
deviceId: string;
|
||||
status: "starting" | "running" | "error" | "stopped";
|
||||
config: any;
|
||||
deviceInfo: DeviceInfo;
|
||||
}
|
||||
|
||||
class DeviceSimulatorManager {
|
||||
private workers: Map<string, DeviceWorker> = new Map();
|
||||
private config!: RawConfig;
|
||||
private mqttClient!: MqttClient;
|
||||
private isConnected = false;
|
||||
|
||||
async initialize() {
|
||||
this.config = await loadConfig();
|
||||
console.log("📋 Device Simulator Manager initialized");
|
||||
|
||||
// 连接MQTT并监听instantActions消息
|
||||
await this.connectMqtt();
|
||||
}
|
||||
|
||||
private async connectMqtt() {
|
||||
const brokerUrl = `mqtt://${this.config.mqttBroker.host}:${this.config.mqttBroker.port}`;
|
||||
console.log(`🔌 Connecting to MQTT broker: ${brokerUrl}`);
|
||||
|
||||
this.mqttClient = connect(brokerUrl, {
|
||||
clean: true,
|
||||
keepalive: 60,
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("MQTT connection timeout"));
|
||||
}, 10000);
|
||||
|
||||
this.mqttClient.on("connect", () => {
|
||||
clearTimeout(timeout);
|
||||
this.isConnected = true;
|
||||
console.log("✅ Connected to MQTT broker");
|
||||
this.setupMqttHandlers();
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.mqttClient.on("error", (error: Error) => {
|
||||
clearTimeout(timeout);
|
||||
console.error(`❌ MQTT connection error: ${error.message}`);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private setupMqttHandlers() {
|
||||
// 订阅所有设备的instantActions主题
|
||||
const instantActionsTopic = `${this.config.mqttBroker.vdaInterface}/+/instantActions`;
|
||||
this.mqttClient.subscribe(instantActionsTopic, (err: Error | null) => {
|
||||
if (err) {
|
||||
console.error(`❌ Failed to subscribe to ${instantActionsTopic}: ${err.message}`);
|
||||
} else {
|
||||
console.log(`📡 Subscribed to ${instantActionsTopic}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.mqttClient.on("message", (topic: string, message: Uint8Array) => {
|
||||
try {
|
||||
const data = JSON.parse(new TextDecoder().decode(message));
|
||||
console.log(`📥 Received instantActions message on ${topic}`);
|
||||
|
||||
if (topic.includes("/instantActions")) {
|
||||
this.handleInstantActionsMessage(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error parsing MQTT message: ${(error as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.mqttClient.on("disconnect", () => {
|
||||
this.isConnected = false;
|
||||
console.log("⚠️ Disconnected from MQTT broker");
|
||||
});
|
||||
|
||||
this.mqttClient.on("error", (error: Error) => {
|
||||
console.error(`❌ MQTT error: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleInstantActionsMessage(message: any) {
|
||||
console.log("🔍 Processing instantActions message...");
|
||||
|
||||
try {
|
||||
// 支持两种格式:标准VDA 5050的instantActions和实际使用的actions
|
||||
const actions = message.instantActions || message.actions || [];
|
||||
|
||||
if (!Array.isArray(actions)) {
|
||||
console.error("❌ Invalid actions format - not an array");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const action of actions) {
|
||||
if (action.actionType === "deviceSetup") {
|
||||
await this.handleDeviceAction(action);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error processing instantActions: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 从actionParameters中提取设备信息
|
||||
private extractDeviceInfo(actionParameters: any[]): DeviceInfo | null {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
for (const param of actionParameters) {
|
||||
if (param.key && param.value) {
|
||||
params[param.key] = param.value;
|
||||
}
|
||||
}
|
||||
|
||||
const required = ['ip', 'port', 'slaveId'];
|
||||
for (const field of required) {
|
||||
if (!params[field]) {
|
||||
console.error(`❌ Missing required parameter: ${field}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ip: params.ip,
|
||||
port: params.port,
|
||||
slaveId: params.slaveId,
|
||||
deviceName: params.deviceName || 'Unknown Device',
|
||||
protocolType: params.protocolType || 'Unknown Protocol',
|
||||
brandName: params.brandName || 'Unknown Brand'
|
||||
};
|
||||
}
|
||||
|
||||
// 生成设备ID
|
||||
private generateDeviceId(deviceInfo: DeviceInfo): string {
|
||||
return `${deviceInfo.ip}-${deviceInfo.port}-${deviceInfo.slaveId}`;
|
||||
}
|
||||
|
||||
// 处理instantActions消息,动态创建设备模拟器
|
||||
async handleDeviceAction(actionMessage: any): Promise<void> {
|
||||
console.log("🔧 Processing device action:", actionMessage.actionType);
|
||||
|
||||
if (!actionMessage.actionParameters || !Array.isArray(actionMessage.actionParameters)) {
|
||||
console.error("❌ Invalid action parameters");
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceInfo = this.extractDeviceInfo(actionMessage.actionParameters);
|
||||
if (!deviceInfo) {
|
||||
console.error("❌ Failed to extract device info");
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceId = this.generateDeviceId(deviceInfo);
|
||||
|
||||
// 检查设备模拟器是否已存在
|
||||
if (this.workers.has(deviceId)) {
|
||||
console.log(`📱 Device ${deviceId} already exists, sending action to existing worker`);
|
||||
const worker = this.workers.get(deviceId)!;
|
||||
worker.worker.postMessage({
|
||||
type: "action",
|
||||
data: actionMessage
|
||||
} as DeviceWorkerMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建新的设备模拟器
|
||||
console.log(`🆕 Creating new device simulator for ${deviceId}`);
|
||||
console.log(`📱 Device: ${deviceInfo.deviceName} (${deviceInfo.brandName})`);
|
||||
console.log(`🔌 Protocol: ${deviceInfo.protocolType}`);
|
||||
|
||||
await this.startDeviceWorker(deviceId, deviceInfo, actionMessage);
|
||||
}
|
||||
|
||||
private async startDeviceWorker(deviceId: string, deviceInfo: DeviceInfo, initialAction: any): Promise<void> {
|
||||
console.log(`🔧 Creating device worker for ${deviceId}`);
|
||||
|
||||
const worker = new Worker(new URL("./device_simulator.ts", import.meta.url).href, {
|
||||
type: "module",
|
||||
});
|
||||
|
||||
const workerConfig = {
|
||||
deviceId,
|
||||
deviceInfo,
|
||||
vdaInterface: this.config.mqttBroker.vdaInterface,
|
||||
mqtt: {
|
||||
brokerUrl: `mqtt://${this.config.mqttBroker.host}:${this.config.mqttBroker.port}`,
|
||||
options: {
|
||||
clean: true,
|
||||
keepalive: 60,
|
||||
}
|
||||
},
|
||||
vehicle: {
|
||||
manufacturer: deviceInfo.brandName,
|
||||
serialNumber: deviceId,
|
||||
vdaVersion: this.config.vehicle.vdaVersion,
|
||||
}
|
||||
};
|
||||
|
||||
const deviceWorker: DeviceWorker = {
|
||||
worker,
|
||||
deviceId,
|
||||
status: "starting",
|
||||
config: workerConfig,
|
||||
deviceInfo,
|
||||
};
|
||||
|
||||
this.workers.set(deviceId, deviceWorker);
|
||||
this.setupDeviceWorkerHandlers(deviceWorker);
|
||||
|
||||
// 发送初始化消息
|
||||
worker.postMessage({ type: "init", data: workerConfig } as DeviceWorkerMessage);
|
||||
|
||||
// 等待worker准备就绪,然后发送初始action
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
deviceWorker.status = "error";
|
||||
reject(new Error(`Device worker ${deviceId} failed to start within timeout`));
|
||||
}, 15000);
|
||||
|
||||
const originalHandler = worker.onmessage;
|
||||
worker.onmessage = (event: MessageEvent<DeviceMainMessage>) => {
|
||||
if (event.data.type === "ready") {
|
||||
clearTimeout(timeout);
|
||||
deviceWorker.status = "running";
|
||||
worker.onmessage = originalHandler;
|
||||
console.log(`✅ Device worker ${deviceId} started successfully`);
|
||||
|
||||
// 发送初始action
|
||||
worker.postMessage({
|
||||
type: "action",
|
||||
data: initialAction
|
||||
} as DeviceWorkerMessage);
|
||||
|
||||
resolve();
|
||||
} else if (event.data.type === "error") {
|
||||
clearTimeout(timeout);
|
||||
deviceWorker.status = "error";
|
||||
worker.onmessage = originalHandler;
|
||||
reject(new Error(`Device worker ${deviceId} failed to start: ${event.data.data?.error}`));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private setupDeviceWorkerHandlers(deviceWorker: DeviceWorker) {
|
||||
const { worker, deviceId } = deviceWorker;
|
||||
|
||||
worker.onmessage = (event: MessageEvent<DeviceMainMessage>) => {
|
||||
const { type, data } = event.data;
|
||||
|
||||
switch (type) {
|
||||
case "ready":
|
||||
deviceWorker.status = "running";
|
||||
console.log(`✅ Device ${deviceId} is ready`);
|
||||
break;
|
||||
|
||||
case "error":
|
||||
deviceWorker.status = "error";
|
||||
console.error(`❌ Device ${deviceId} error:`, data?.error);
|
||||
break;
|
||||
|
||||
case "status":
|
||||
console.log(`📊 Device ${deviceId} status:`, data?.status);
|
||||
if (data?.status === "closed") {
|
||||
deviceWorker.status = "stopped";
|
||||
}
|
||||
break;
|
||||
|
||||
case "log":
|
||||
// 转发日志到控制台
|
||||
console.log(data?.message);
|
||||
break;
|
||||
|
||||
case "reset":
|
||||
console.log(`🔄 Worker reset requested for device ${deviceId}: ${data?.reason}`);
|
||||
console.log(`Error: ${data?.error}`);
|
||||
this.handleDeviceWorkerReset(deviceWorker, data);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`Unknown message type from device ${deviceId}:`, type);
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (error) => {
|
||||
deviceWorker.status = "error";
|
||||
console.error(`❌ Device worker ${deviceId} error:`, error.message);
|
||||
};
|
||||
|
||||
worker.onmessageerror = (error) => {
|
||||
console.error(`❌ Message error from device worker ${deviceId}:`, error);
|
||||
};
|
||||
}
|
||||
|
||||
private async handleDeviceWorkerReset(deviceWorker: DeviceWorker, resetData: any): Promise<void> {
|
||||
const { deviceId } = deviceWorker;
|
||||
console.log(`🔄 Resetting device worker ${deviceId} due to: ${resetData?.reason}`);
|
||||
|
||||
try {
|
||||
// 终止当前worker
|
||||
deviceWorker.worker.terminate();
|
||||
|
||||
// 创建新的worker
|
||||
const newWorker = new Worker(new URL("./device_simulator.ts", import.meta.url).href, {
|
||||
type: "module",
|
||||
});
|
||||
|
||||
// 更新worker引用
|
||||
deviceWorker.worker = newWorker;
|
||||
deviceWorker.status = "starting";
|
||||
|
||||
// 重新设置消息处理
|
||||
this.setupDeviceWorkerHandlers(deviceWorker);
|
||||
|
||||
// 重新初始化
|
||||
newWorker.postMessage({ type: "init", data: deviceWorker.config } as DeviceWorkerMessage);
|
||||
|
||||
console.log(`✅ Device worker ${deviceId} reset completed`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to reset device worker ${deviceId}:`, (error as Error).message);
|
||||
deviceWorker.status = "error";
|
||||
}
|
||||
}
|
||||
|
||||
async stopDeviceWorker(deviceId: string): Promise<void> {
|
||||
const deviceWorker = this.workers.get(deviceId);
|
||||
if (!deviceWorker) {
|
||||
console.warn(`⚠️ Device worker ${deviceId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🛑 Stopping device worker ${deviceId}`);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
deviceWorker.worker.terminate();
|
||||
this.workers.delete(deviceId);
|
||||
console.log(`🔄 Force terminated device worker ${deviceId}`);
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
deviceWorker.worker.onmessage = (event: MessageEvent<DeviceMainMessage>) => {
|
||||
if (event.data.type === "status" && event.data.data?.status === "closed") {
|
||||
clearTimeout(timeout);
|
||||
deviceWorker.worker.terminate();
|
||||
this.workers.delete(deviceId);
|
||||
console.log(`✅ Device worker ${deviceId} stopped gracefully`);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
deviceWorker.worker.postMessage({ type: "close" } as DeviceWorkerMessage);
|
||||
});
|
||||
}
|
||||
|
||||
async stopAllDeviceWorkers(): Promise<void> {
|
||||
console.log("🛑 Stopping all device workers...");
|
||||
const stopPromises = Array.from(this.workers.keys()).map(deviceId => this.stopDeviceWorker(deviceId));
|
||||
await Promise.all(stopPromises);
|
||||
console.log("✅ All device workers stopped");
|
||||
|
||||
// 关闭MQTT连接
|
||||
if (this.mqttClient && this.isConnected) {
|
||||
try {
|
||||
await this.mqttClient.endAsync();
|
||||
console.log("✅ MQTT connection closed");
|
||||
} catch (error) {
|
||||
console.error(`❌ Error closing MQTT connection: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getDeviceWorkerStatus(deviceId?: string) {
|
||||
if (deviceId) {
|
||||
const worker = this.workers.get(deviceId);
|
||||
return worker ? { deviceId, status: worker.status, deviceInfo: worker.deviceInfo } : null;
|
||||
}
|
||||
|
||||
return Array.from(this.workers.entries()).map(([id, worker]) => ({
|
||||
deviceId: id,
|
||||
status: worker.status,
|
||||
deviceInfo: worker.deviceInfo
|
||||
}));
|
||||
}
|
||||
|
||||
// 命令行接口
|
||||
async handleCommand(command: string, args: string[] = []) {
|
||||
const cmd = command.toLowerCase();
|
||||
|
||||
switch (cmd) {
|
||||
case "status":{
|
||||
const statuses = this.getDeviceWorkerStatus();
|
||||
console.log("📊 Device Worker Status:");
|
||||
if (Array.isArray(statuses)) {
|
||||
statuses.forEach((s: any) => {
|
||||
console.log(` ${s.deviceId}: ${s.status}`);
|
||||
console.log(` Device: ${s.deviceInfo.deviceName} (${s.deviceInfo.brandName})`);
|
||||
console.log(` Address: ${s.deviceInfo.ip}:${s.deviceInfo.port}/${s.deviceInfo.slaveId}`);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "stop":
|
||||
if (args[0]) {
|
||||
await this.stopDeviceWorker(args[0]);
|
||||
} else {
|
||||
await this.stopAllDeviceWorkers();
|
||||
}
|
||||
break;
|
||||
|
||||
case "list":{
|
||||
const devices = this.getDeviceWorkerStatus();
|
||||
console.log("📱 Active Device Simulators:");
|
||||
if (Array.isArray(devices)) {
|
||||
devices.forEach((d: any) => {
|
||||
console.log(` ${d.deviceId} - ${d.deviceInfo.deviceName}`);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "help":
|
||||
console.log(`
|
||||
📖 Device Simulator Commands:
|
||||
status [deviceId] - Show device worker status
|
||||
stop [deviceId] - Stop device worker(s)
|
||||
list - List all active devices
|
||||
quit/exit - Exit the manager
|
||||
help - Show this help
|
||||
`);
|
||||
break;
|
||||
|
||||
case "quit":
|
||||
case "exit":
|
||||
await this.stopAllDeviceWorkers();
|
||||
Deno.exit(0);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`❌ Unknown command: ${command}. Type 'help' for available commands.`);
|
||||
}
|
||||
}
|
||||
|
||||
async startInteractiveMode() {
|
||||
console.log(`
|
||||
🎮 Device Simulator Manager - Interactive Mode
|
||||
Type 'help' for available commands
|
||||
Type 'quit' or 'exit' to stop all workers and exit
|
||||
`);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (true) {
|
||||
await Deno.stdout.write(encoder.encode("device-sim> "));
|
||||
|
||||
const buf = new Uint8Array(1024);
|
||||
const n = await Deno.stdin.read(buf);
|
||||
if (n === null) break;
|
||||
|
||||
const input = decoder.decode(buf.subarray(0, n)).trim();
|
||||
if (!input) continue;
|
||||
|
||||
const [command, ...args] = input.split(/\s+/);
|
||||
|
||||
try {
|
||||
await this.handleCommand(command, args);
|
||||
} catch (error) {
|
||||
console.error(`❌ Command failed:`, (error as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 主程序入口
|
||||
if (import.meta.main) {
|
||||
const manager = new DeviceSimulatorManager();
|
||||
|
||||
try {
|
||||
await manager.initialize();
|
||||
|
||||
console.log(`
|
||||
🎯 Device Simulator Manager started!
|
||||
📡 Listening for instantActions messages on MQTT
|
||||
🔧 Will automatically create device simulators when deviceSetup actions are received
|
||||
`);
|
||||
|
||||
// 处理进程信号
|
||||
Deno.addSignalListener("SIGINT", async () => {
|
||||
console.log("\n⚠️ Received SIGINT, shutting down...");
|
||||
await manager.stopAllDeviceWorkers();
|
||||
Deno.exit(0);
|
||||
});
|
||||
|
||||
if (Deno.build.os !== "windows") {
|
||||
Deno.addSignalListener("SIGTERM", async () => {
|
||||
console.log("\n⚠️ Received SIGTERM, shutting down...");
|
||||
await manager.stopAllDeviceWorkers();
|
||||
Deno.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// 启动交互模式
|
||||
await manager.startInteractiveMode();
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to start device simulator manager:", (error as Error).message);
|
||||
Deno.exit(1);
|
||||
}
|
||||
}
|
111
devices.json
Normal file
111
devices.json
Normal file
@ -0,0 +1,111 @@
|
||||
[
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "ZKG-0"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "ZKG-1"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "ZKG-2"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "ZKG-3"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "ZKG-4"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "ZKG-5"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "SIM-01"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "SIM-02"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "SIM-03"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "SIM-04"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "SIM-05"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "SIM-06"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "SIM-07"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "SIM-08"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "SIM-09"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "SIM-010"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "SIM-011"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "SIM-012"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "SIM-013"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "SIM-014"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "SIM-015"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "SIM-016"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "EXT-0"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "EXT-1"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "10.2.0.10-5020-17"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "10.2.0.10-5021-17"
|
||||
},
|
||||
{
|
||||
"manufacturer": "SEER",
|
||||
"serialNumber": "10.2.0.10-5022-17"
|
||||
}
|
||||
|
||||
]
|
139
event_manager.ts
Normal file
139
event_manager.ts
Normal file
@ -0,0 +1,139 @@
|
||||
// event_manager.ts
|
||||
// 全局事件管理器
|
||||
|
||||
export interface GlobalEvent {
|
||||
type: string;
|
||||
source: string;
|
||||
target?: string;
|
||||
data?: any;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export class GlobalEventManager extends EventTarget {
|
||||
private workers: Map<string, Worker> = new Map();
|
||||
private eventLog: GlobalEvent[] = [];
|
||||
private maxLogSize = 1000;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
console.log("🎯 全局事件管理器已启动");
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 Worker
|
||||
*/
|
||||
registerWorker(name: string, worker: Worker) {
|
||||
this.workers.set(name, worker);
|
||||
console.log(`📝 已注册 Worker: ${name}`);
|
||||
|
||||
// 监听来自 Worker 的事件请求
|
||||
worker.onmessage = (event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
if (message.type === "dispatchGlobalEvent") {
|
||||
this.dispatchGlobalEvent(message.event);
|
||||
} else if (message.type === "addEventListener") {
|
||||
this.addWorkerListener(name, message.eventType, message.listenerId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 分发全局事件
|
||||
*/
|
||||
dispatchGlobalEvent(eventData: GlobalEvent) {
|
||||
eventData.timestamp = Date.now();
|
||||
|
||||
// 记录事件日志
|
||||
this.logEvent(eventData);
|
||||
|
||||
console.log(`🚀 分发全局事件: ${eventData.type} (来源: ${eventData.source})`);
|
||||
|
||||
// 创建自定义事件
|
||||
const customEvent = new CustomEvent(eventData.type, {
|
||||
detail: eventData
|
||||
});
|
||||
|
||||
// 在主线程分发事件
|
||||
this.dispatchEvent(customEvent);
|
||||
|
||||
// 转发给指定的 Worker 或所有 Worker
|
||||
if (eventData.target) {
|
||||
const targetWorker = this.workers.get(eventData.target);
|
||||
if (targetWorker) {
|
||||
targetWorker.postMessage({
|
||||
type: "globalEvent",
|
||||
event: eventData
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 广播给所有 Worker
|
||||
this.workers.forEach((worker, name) => {
|
||||
if (name !== eventData.source) { // 不发回给发送者
|
||||
worker.postMessage({
|
||||
type: "globalEvent",
|
||||
event: eventData
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 Worker 事件监听器
|
||||
*/
|
||||
private addWorkerListener(workerName: string, eventType: string, listenerId: string) {
|
||||
this.addEventListener(eventType, (event: Event) => {
|
||||
const customEvent = event as CustomEvent<GlobalEvent>;
|
||||
const worker = this.workers.get(workerName);
|
||||
if (worker) {
|
||||
worker.postMessage({
|
||||
type: "globalEventReceived",
|
||||
eventType,
|
||||
listenerId,
|
||||
event: customEvent.detail
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录事件日志
|
||||
*/
|
||||
private logEvent(event: GlobalEvent) {
|
||||
this.eventLog.push(event);
|
||||
if (this.eventLog.length > this.maxLogSize) {
|
||||
this.eventLog.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事件日志
|
||||
*/
|
||||
getEventLog(): GlobalEvent[] {
|
||||
return [...this.eventLog];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的事件
|
||||
*/
|
||||
getRecentEvents(count: number = 10): GlobalEvent[] {
|
||||
return this.eventLog.slice(-count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理事件日志
|
||||
*/
|
||||
clearEventLog() {
|
||||
this.eventLog = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已注册的 Worker 列表
|
||||
*/
|
||||
getRegisteredWorkers(): string[] {
|
||||
return Array.from(this.workers.keys());
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局单例
|
||||
export const globalEventManager = new GlobalEventManager();
|
12
generate_devices.ts
Normal file
12
generate_devices.ts
Normal file
@ -0,0 +1,12 @@
|
||||
// Script to generate 3000 devices
|
||||
const devices = [];
|
||||
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
devices.push({
|
||||
manufacturer: "SEER",
|
||||
serialNumber: `ZKG-${i}`
|
||||
});
|
||||
}
|
||||
|
||||
await Deno.writeTextFile("devices.json", JSON.stringify(devices, null, 2));
|
||||
console.log("Generated 3000 devices in devices.json");
|
12
import_map.json
Normal file
12
import_map.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"imports": {
|
||||
"vda-5050-lib": "npm:vda-5050-lib",
|
||||
"mqtt": "npm:mqtt",
|
||||
"@grpc/": "npm:@grpc/",
|
||||
"uuid": "npm:uuid",
|
||||
"std/": "https://deno.land/std/",
|
||||
"@hono/hono": "jsr:@hono/hono@^4.6.11",
|
||||
"@hono/hono/serve-static": "jsr:@hono/hono@^4.6.11/serve-static",
|
||||
"@hono/hono/html": "jsr:@hono/hono@^4.6.11/html"
|
||||
}
|
||||
}
|
144
mapping.json
Normal file
144
mapping.json
Normal file
@ -0,0 +1,144 @@
|
||||
[
|
||||
{
|
||||
"sourceTopic": "uagv/v2/SEER/+/state",
|
||||
"targetTopicTemplate": "oagv/v2/{instanceId}/{agvId}/state",
|
||||
"mapping": {
|
||||
"version": { "op": "const", "value": "2.0.0", "source": "version" },
|
||||
"headerId": "headerId",
|
||||
"timestamp": "timestamp",
|
||||
"manufacturer": "manufacturer",
|
||||
"serialNumber": "serialNumber",
|
||||
"orderId": "orderId",
|
||||
"orderUpdateId": "orderUpdateId",
|
||||
"zoneSetId": "zoneSetId",
|
||||
"lastNodeId": "lastNodeId",
|
||||
"lastNodeSequenceId": "lastNodeSequenceId",
|
||||
"nodeStates": "nodeStates",
|
||||
"edgeStates": {
|
||||
"type": "array",
|
||||
"source": "edgeStates",
|
||||
"mapping": {
|
||||
"edgeId": "edgeId",
|
||||
"sequenceId": "sequenceId",
|
||||
"edgeDescription": "edgeDescription",
|
||||
"released": "released"
|
||||
}
|
||||
},
|
||||
"driving": "driving",
|
||||
"waitingForInteractionZoneRelease": "waitingForInteractionZoneRelease",
|
||||
"paused": "paused",
|
||||
"actionStates": "actionStates",
|
||||
"agvPosition": "agvPosition",
|
||||
"operatingMode": "operatingMode",
|
||||
"batteryState": "batteryState",
|
||||
"errors": "errors",
|
||||
"safetyState": "safetyState",
|
||||
"information": {
|
||||
"type": "array",
|
||||
"source": "information",
|
||||
"mapping": {
|
||||
"infoType": "infoType",
|
||||
"infoLevel": "infoLevel",
|
||||
"infoDescription": "infoDescription",
|
||||
"infoReferences": {
|
||||
"type": "array",
|
||||
"source": "infoReferences",
|
||||
"mapping": {
|
||||
"referenceKey": "referenceKey",
|
||||
"referenceValue": {
|
||||
"type": "array",
|
||||
"source": "referenceValue",
|
||||
"op": "toString"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"sourceTopic": "uagv/v2/SEER/+/connection",
|
||||
"targetTopicTemplate": "oagv/v2/{instanceId}/{agvId}/connection",
|
||||
"mapping": {
|
||||
"version": { "op": "const", "value": "2.0.0", "source": "version" },
|
||||
"headerId": "headerId",
|
||||
"timestamp": "timestamp",
|
||||
"manufacturer": "manufacturer",
|
||||
"serialNumber": "serialNumber",
|
||||
"connectionState": "connectionState"
|
||||
}
|
||||
},
|
||||
{
|
||||
"sourceTopic": "uagv/v2/SEER/+/factsheet",
|
||||
"targetTopicTemplate": "oagv/v2/{instanceId}/{agvId}/factsheet",
|
||||
"mapping": {
|
||||
"version": { "op": "const", "value": "2.0.0", "source": "version" },
|
||||
"headerId": "headerId",
|
||||
"timestamp": "timestamp",
|
||||
"manufacturer": "manufacturer",
|
||||
"serialNumber": "serialNumber",
|
||||
"typeSpecification": {
|
||||
"type": "object",
|
||||
"source": "typeSpecification",
|
||||
"mapping": {
|
||||
"seriesName": "seriesName",
|
||||
"seriesDescription": "seriesDescription",
|
||||
"agvKinematic": "agvKinematic",
|
||||
"agvClass": "agvClass",
|
||||
"maxLoadMass": "maxLoadMass",
|
||||
"localizationTypes": {
|
||||
"op": "const",
|
||||
"value": ["NATURAL"],
|
||||
"source": "localizationTypes"
|
||||
},
|
||||
"navigationTypes": "navigationTypes"
|
||||
}
|
||||
},
|
||||
"physicalParameters": {
|
||||
"type": "object",
|
||||
"source": "physicalParameters",
|
||||
"mapping": {
|
||||
"speedMin": "speedMin",
|
||||
"speedMax": "speedMax",
|
||||
"accelerationMax": "accelerationMax",
|
||||
"decelerationMax": "decelerationMax",
|
||||
"heightMin": "heightMin",
|
||||
"heightMax": "heightMax",
|
||||
"width": "width",
|
||||
"length": "length"
|
||||
}
|
||||
},
|
||||
"localizationParameters": "localizationParameters"
|
||||
}
|
||||
},
|
||||
{
|
||||
"sourceTopic": "oagv/v2/{instanceId}/+/instantActions",
|
||||
"targetTopicTemplate": "uagv/v2/SEER/{agvId}/instantActions",
|
||||
"mapping": {
|
||||
"version": "version",
|
||||
"headerId": "headerId",
|
||||
"timestamp": "timestamp",
|
||||
"manufacturer": { "op": "const", "value": "SEER", "source": "manufacturer" },
|
||||
"serialNumber": "serialNumber",
|
||||
"actions": "actions",
|
||||
"actionParameters": "actionParameters"
|
||||
}
|
||||
},
|
||||
{
|
||||
"sourceTopic": "oagv/v2/{instanceId}/+/order",
|
||||
"targetTopicTemplate": "uagv/v2/SEER/{agvId}/order",
|
||||
"mapping": {
|
||||
"version": "version",
|
||||
"headerId": "headerId",
|
||||
"timestamp": "timestamp",
|
||||
"manufacturer": { "op": "const", "value": "SEER", "source": "manufacturer" },
|
||||
"serialNumber": "serialNumber",
|
||||
"edges": "edges",
|
||||
"nodes": "nodes",
|
||||
"released": "released",
|
||||
"sequenceId": "sequenceId",
|
||||
"orderId": "orderId",
|
||||
"orderUpdateId": "orderUpdateId"
|
||||
}
|
||||
}
|
||||
]
|
309
master_manager.ts
Normal file
309
master_manager.ts
Normal file
@ -0,0 +1,309 @@
|
||||
// master_manager.ts
|
||||
// Module to setup and handle the Master Controller (VDA 5050) Worker
|
||||
|
||||
import { initAgvWorker, setupAgvWorker } from './agv_manager.ts';
|
||||
|
||||
/**
|
||||
* Sets up the Master Controller Worker, wiring message handlers and returning the instance.
|
||||
* @param kv Deno KV instance for storing/retrieving AGV state
|
||||
* @param webWorker Web Worker for UI updates
|
||||
* @param masterWorker The master VDA 5050 Worker to forward AGV events to
|
||||
* @param agvWorker The AGV Worker instance
|
||||
* @returns The initialized Master Controller Worker
|
||||
*/
|
||||
export function setupMasterWorker(
|
||||
kv: Deno.Kv,
|
||||
webWorker: Worker,
|
||||
masterWorker: Worker,
|
||||
agvWorker: Worker,
|
||||
config: any,
|
||||
mappings: any,
|
||||
instanceId: string
|
||||
): Worker {
|
||||
|
||||
console.log("VDA 5050 Master Controller Worker 已启动", instanceId);
|
||||
|
||||
masterWorker.onmessage = async (event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
try {
|
||||
switch (message.type) {
|
||||
case "started":
|
||||
// 同步通知 Master Worker 初始化完成
|
||||
masterWorker.postMessage({ type: "started" });
|
||||
break;
|
||||
|
||||
case "factsheet": {
|
||||
// console.log("收到 master Worker factsheet 消息", message);
|
||||
const { agvId, factsheet, timestamp } = message.data;
|
||||
const deviceKey = `device-factsheet:${agvId.manufacturer}/${agvId.serialNumber}`;
|
||||
const res = await kv.get([deviceKey]);
|
||||
const newData = { ...(res.value || {}), agvId, factsheet, timestamp };
|
||||
await kv.set([deviceKey], newData);
|
||||
agvWorker.postMessage({ type: "factsheetResponse", data: { agvId, factsheet, timestamp } });
|
||||
break
|
||||
}
|
||||
|
||||
case "connectionState": {
|
||||
const { agvId, state, timestamp } = message.data;
|
||||
const deviceKey = `device-online:${agvId.manufacturer}/${agvId.serialNumber}`;
|
||||
const res = await kv.get([deviceKey]);
|
||||
const newData = { ...(res.value || {}), agvId, state, lastSeen: timestamp };
|
||||
let needsUpdate = false;
|
||||
if (!res.value) {
|
||||
needsUpdate = true;
|
||||
} else {
|
||||
const existing = res.value as any;
|
||||
if (existing.state !== state || existing.lastSeen !== timestamp ||
|
||||
JSON.stringify(existing.agvId) !== JSON.stringify(agvId)) {
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
if (needsUpdate) {
|
||||
// console.log("1----------------更新设备状态---------------<",deviceKey, newData);
|
||||
await kv.set([deviceKey], newData);
|
||||
}
|
||||
masterWorker.postMessage({ type: "connectionState", data: { agvId, state, timestamp } });
|
||||
break;
|
||||
}
|
||||
|
||||
case "stateUpdate": {
|
||||
const { agvId, state, timestamp } = message.data;
|
||||
const deviceKey = `device:${agvId.manufacturer}/${agvId.serialNumber}`;
|
||||
const res = await kv.get([deviceKey]);
|
||||
const newData = { ...(res.value || {}), agvId, state, lastSeen: timestamp };
|
||||
let needsUpdate = false;
|
||||
if (!res.value) {
|
||||
needsUpdate = true;
|
||||
} else {
|
||||
const existing = res.value as any;
|
||||
if (existing.lastSeen !== timestamp ||
|
||||
JSON.stringify(existing.state) !== JSON.stringify(state)) {
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
if (needsUpdate) {
|
||||
// console.log("2----------------更新设备状态---------------<",deviceKey, newData);
|
||||
await kv.set([deviceKey], newData);
|
||||
}
|
||||
masterWorker.postMessage({ type: "stateUpdate", data: { agvId, state, timestamp } });
|
||||
webWorker.postMessage({ type: "positionUpdate", data: {
|
||||
agvId: { manufacturer: agvId.manufacturer, serialNumber: agvId.serialNumber },
|
||||
position: { x: state.agvPosition.x, y: state.agvPosition.y, theta: state.agvPosition.theta }
|
||||
}});
|
||||
break;
|
||||
}
|
||||
|
||||
case "deviceDiscovered": {
|
||||
// console.log("收到 deviceDiscovered 消息", message);
|
||||
const { agvId, timestamp, isOnline } = message.data;
|
||||
const deviceKey = `device-discovered:${agvId.manufacturer}/${agvId.serialNumber}`;
|
||||
const res = await kv.get([deviceKey]);
|
||||
const newData = { agvId, lastSeen: timestamp, isOnline };
|
||||
let needsUpdate = false;
|
||||
if (!res.value) {
|
||||
needsUpdate = true;
|
||||
} else {
|
||||
const existing = res.value as any;
|
||||
if (existing.lastSeen !== timestamp || existing.isOnline !== isOnline ||
|
||||
JSON.stringify(existing.agvId) !== JSON.stringify(agvId)) {
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
if (needsUpdate) {
|
||||
// console.log("3----------------更新设备状态---------------<",deviceKey, newData);
|
||||
await kv.set([deviceKey], newData);
|
||||
}
|
||||
masterWorker.postMessage({ type: "deviceDiscovered", data: { agvId, timestamp, isOnline } });
|
||||
break;
|
||||
}
|
||||
|
||||
case "devicesList":
|
||||
masterWorker.postMessage({ type: "devicesList", data: message.data });
|
||||
break;
|
||||
|
||||
case "orderSent":
|
||||
masterWorker.postMessage({ type: "orderSent", orderId: message.orderId });
|
||||
break;
|
||||
|
||||
case "orderCompleted":
|
||||
masterWorker.postMessage({
|
||||
type: "orderCompleted",
|
||||
orderId: message.orderId,
|
||||
withError: message.withError,
|
||||
byCancelation: message.byCancelation,
|
||||
active: message.active
|
||||
});
|
||||
break;
|
||||
|
||||
case "nodeTraversed":
|
||||
masterWorker.postMessage({ type: "nodeTraversed", data: message.data });
|
||||
break;
|
||||
|
||||
case "edgeTraversing":
|
||||
masterWorker.postMessage({ type: "edgeTraversing", data: message.data });
|
||||
break;
|
||||
|
||||
case "edgeTraversed":
|
||||
masterWorker.postMessage({ type: "edgeTraversed", data: message.data });
|
||||
break;
|
||||
|
||||
case "orderCancelled":
|
||||
masterWorker.postMessage({ type: "orderCancelled", orderId: message.orderId });
|
||||
break;
|
||||
|
||||
case "commandSettings":
|
||||
masterWorker.postMessage({ type: "commandSettings", data: message.data });
|
||||
break;
|
||||
|
||||
case "reconnect-all":
|
||||
console.log("收到 master1 Worker reconnect-all 消息,准备重连");
|
||||
reconnectAllWorker(kv, webWorker, masterWorker, agvWorker, config, mappings, instanceId);
|
||||
break;
|
||||
|
||||
case "deviceRemoved": {
|
||||
const { manufacturer, serialNumber, deviceKey, remainingDevices } = message.data;
|
||||
console.log(`🗑️ 设备已从VDA Worker中移除: ${manufacturer}/${serialNumber}`);
|
||||
console.log(`📊 剩余设备数量: ${remainingDevices}`);
|
||||
|
||||
// 清理KV存储中的设备数据
|
||||
try {
|
||||
const keysToDelete = [
|
||||
`device:${manufacturer}/${serialNumber}`,
|
||||
`device-online:${manufacturer}/${serialNumber}`,
|
||||
`device-discovered:${manufacturer}/${serialNumber}`,
|
||||
`device-factsheet:${manufacturer}/${serialNumber}`
|
||||
];
|
||||
|
||||
for (const key of keysToDelete) {
|
||||
await kv.delete([key]);
|
||||
console.log(`🧹 已清理KV数据: ${key}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 清理KV数据失败: ${error}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "deviceListUpdated": {
|
||||
const { total, added, updated, removed, devices } = message.data;
|
||||
console.log(`📊 设备列表更新统计: 总数=${total}, 新增=${added}, 更新=${updated}, 移除=${removed}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "shutdown":
|
||||
masterWorker.postMessage({ type: "shutdown" });
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn("收到未知类型的消息:", message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("处理消息时发生异常:", err);
|
||||
}
|
||||
};
|
||||
|
||||
masterWorker.onerror = (error: any) => {
|
||||
console.error("Master Controller Worker 执行错误:", error);
|
||||
};
|
||||
|
||||
return masterWorker;
|
||||
}
|
||||
|
||||
// 定义读取并发送设备列表更新消息的函数
|
||||
// 直接移除单个设备(不依赖文件更新)
|
||||
export async function removeDeviceFromWorker(masterWorker: Worker, manufacturer: string, serialNumber: string) {
|
||||
console.log(`🗑️ 正在从VDA Worker中移除设备: ${manufacturer}/${serialNumber}`);
|
||||
masterWorker.postMessage({
|
||||
type: "removeDevice",
|
||||
data: { manufacturer, serialNumber }
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateDeviceListFromConfig(masterWorker: Worker, config: any, instanceId: string) {
|
||||
try {
|
||||
console.log("🔄 开始读取设备配置文件...");
|
||||
const text = await Deno.readTextFile("./devices.json");
|
||||
const devices = JSON.parse(text);
|
||||
|
||||
if (Array.isArray(devices)) {
|
||||
console.log(`📱 读取到 ${devices.length} 个设备配置:`);
|
||||
devices.forEach((device, index) => {
|
||||
console.log(` ${index + 1}. ${device.manufacturer}/${device.serialNumber}`);
|
||||
});
|
||||
|
||||
// 发送初始化消息(如果需要)
|
||||
masterWorker.postMessage({
|
||||
type: "init",
|
||||
data: {
|
||||
brokerUrl: config.mqtt.brokerUrl,
|
||||
interfaceName: config.interfaceName,
|
||||
manufacturer: config.manufacturer,
|
||||
instanceId: instanceId
|
||||
},
|
||||
});
|
||||
|
||||
// 将最新设备列表发送给 masterWorker
|
||||
masterWorker.postMessage({ type: "updateDeviceList", data: devices });
|
||||
console.log("✅ 设备列表已成功发送到 VDA Worker");
|
||||
} else {
|
||||
console.error("❌ 配置文件格式错误,要求为数组格式");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 读取设备配置文件失败:", error);
|
||||
if (error instanceof Deno.errors.NotFound) {
|
||||
console.log("💡 提示:devices.json 文件不存在,将使用空设备列表");
|
||||
// 发送空设备列表
|
||||
masterWorker.postMessage({ type: "updateDeviceList", data: [] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flag to prevent concurrent full-worker reconnects
|
||||
let isReconnectingAll = false;
|
||||
|
||||
export function reconnectAllWorker(
|
||||
kv: Deno.Kv,
|
||||
webWorker: Worker,
|
||||
masterWorker: Worker,
|
||||
agvWorker: Worker,
|
||||
config: any,
|
||||
mappings: any,
|
||||
instanceId: string
|
||||
): Worker {
|
||||
if (isReconnectingAll) {
|
||||
console.log("reconnectAllWorker: 已在重连中,忽略重复调用");
|
||||
return masterWorker;
|
||||
}
|
||||
isReconnectingAll = true;
|
||||
console.log("vda 收到 master Worker reconnect 消息,准备重连");
|
||||
agvWorker.postMessage({
|
||||
type: "shutdown"
|
||||
});
|
||||
masterWorker.postMessage({
|
||||
type: "shutdown"
|
||||
});
|
||||
masterWorker.terminate();
|
||||
agvWorker.terminate();
|
||||
setTimeout( () => {
|
||||
masterWorker.terminate();
|
||||
agvWorker.terminate();
|
||||
setTimeout( () => {
|
||||
const agvWorker = new Worker(
|
||||
new URL("./agv_worker.ts", import.meta.url).href,
|
||||
{ type: "module" }
|
||||
);
|
||||
const masterWorker = new Worker(
|
||||
new URL("./vda_worker.ts", import.meta.url).href,
|
||||
{ type: "module" }
|
||||
);
|
||||
setupAgvWorker(kv, config, masterWorker, agvWorker, webWorker, mappings, instanceId);
|
||||
initAgvWorker(agvWorker, config);
|
||||
setupMasterWorker(kv, webWorker, masterWorker, agvWorker, config, mappings, instanceId);
|
||||
updateDeviceListFromConfig(masterWorker, config, instanceId);
|
||||
// Reset flag after reconnect completes
|
||||
isReconnectingAll = false;
|
||||
}, 2000);
|
||||
|
||||
}, 2000);
|
||||
return masterWorker;
|
||||
}
|
240
modbus_manager.ts
Normal file
240
modbus_manager.ts
Normal file
@ -0,0 +1,240 @@
|
||||
// modbus_manager.ts - Modbus TCP 管理器,支持写操作高优先级
|
||||
import Modbus from "npm:jsmodbus@4.0.10";
|
||||
import net from "node:net";
|
||||
|
||||
type ReadJob = {
|
||||
fn: "readHoldingRegisters" | "readInputRegisters" | "readCoils" | "readDiscreteInputs";
|
||||
start: number;
|
||||
len: number;
|
||||
resolve: (vals: number[]) => void;
|
||||
reject: (err: Error) => void;
|
||||
};
|
||||
|
||||
type WriteJob = {
|
||||
fn: "writeSingleRegister" | "writeMultipleRegisters";
|
||||
start: number;
|
||||
payload: number | number[];
|
||||
resolve: () => void;
|
||||
reject: (err: Error) => void;
|
||||
};
|
||||
|
||||
export class ModbusManager {
|
||||
private socket!: net.Socket;
|
||||
private client!: any;
|
||||
|
||||
// 双队列实现写操作高优先级
|
||||
private writeQueue: WriteJob[] = [];
|
||||
private readQueue: ReadJob[] = [];
|
||||
|
||||
private reconnectTimer?: number;
|
||||
private isConnected = false;
|
||||
private isRunning = false;
|
||||
|
||||
constructor(
|
||||
private host: string,
|
||||
private port: number,
|
||||
private unitId: number,
|
||||
private logger: (msg: string) => void
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
this.logger("Initializing ModbusManager...");
|
||||
await this.connect();
|
||||
this.runLoop();
|
||||
this.logger("ModbusManager initialized successfully");
|
||||
}
|
||||
|
||||
private connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.socket = new net.Socket();
|
||||
this.client = new Modbus.client.TCP(this.socket, this.unitId);
|
||||
|
||||
// 设置socket选项
|
||||
this.socket.setKeepAlive(true, 60000);
|
||||
this.socket.setTimeout(5000);
|
||||
|
||||
const cleanup = () => {
|
||||
this.socket.off("error", onError);
|
||||
this.socket.off("close", onClose);
|
||||
this.socket.off("timeout", onTimeout);
|
||||
};
|
||||
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
this.isConnected = false;
|
||||
this.logger(`Modbus socket error: ${error.message}`);
|
||||
reject(error);
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
cleanup();
|
||||
this.isConnected = false;
|
||||
this.logger("Modbus socket closed");
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
const onTimeout = () => {
|
||||
cleanup();
|
||||
this.isConnected = false;
|
||||
this.logger("Modbus socket timeout");
|
||||
this.socket.destroy();
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
this.socket.once("connect", () => {
|
||||
cleanup();
|
||||
this.isConnected = true;
|
||||
this.logger(`Modbus connected to ${this.host}:${this.port}, unitId: ${this.unitId}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.socket.once("error", onError);
|
||||
this.socket.once("close", onClose);
|
||||
this.socket.once("timeout", onTimeout);
|
||||
|
||||
// 连接到Modbus设备
|
||||
this.socket.connect({ host: this.host, port: this.port });
|
||||
} catch (error) {
|
||||
this.logger(`Failed to create Modbus connection: ${(error as Error).message}`);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectTimer) return;
|
||||
|
||||
this.reconnectTimer = setTimeout(async () => {
|
||||
this.reconnectTimer = undefined;
|
||||
this.logger("Attempting Modbus reconnect...");
|
||||
try {
|
||||
await this.connect();
|
||||
this.logger("Modbus reconnected successfully");
|
||||
} catch (error) {
|
||||
this.logger(`Modbus reconnect failed: ${(error as Error).message}`);
|
||||
// 会继续重试
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
enqueueRead(
|
||||
fn: ReadJob["fn"],
|
||||
start: number,
|
||||
len: number
|
||||
): Promise<number[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.readQueue.push({ fn, start, len, resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
enqueueWrite(
|
||||
fn: WriteJob["fn"],
|
||||
start: number,
|
||||
payload: number | number[]
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.writeQueue.push({ fn, start, payload, resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
private async runLoop() {
|
||||
if (this.isRunning) return;
|
||||
this.isRunning = true;
|
||||
|
||||
this.logger("Starting Modbus job processing loop");
|
||||
|
||||
while (this.isRunning) {
|
||||
let job: ReadJob | WriteJob | undefined;
|
||||
|
||||
// 写操作高优先级:永远先检查写队列
|
||||
if (this.writeQueue.length > 0) {
|
||||
job = this.writeQueue.shift();
|
||||
} else if (this.readQueue.length > 0) {
|
||||
job = this.readQueue.shift();
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
// 没有任务时短暂休眠
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查连接状态
|
||||
if (!this.isConnected) {
|
||||
job.reject(new Error("Modbus not connected"));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if ("len" in job) {
|
||||
// 读操作
|
||||
const response = await this.client[job.fn](job.start, job.len);
|
||||
const values = Array.from(response.response.body.values as number[]);
|
||||
job.resolve(values);
|
||||
this.logger(`Read ${job.fn} at ${job.start}, length ${job.len}: [${values.join(', ')}]`);
|
||||
} else {
|
||||
// 写操作
|
||||
await this.client[job.fn](job.start, job.payload);
|
||||
job.resolve();
|
||||
this.logger(`Write ${job.fn} at ${job.start}, payload: ${Array.isArray(job.payload) ? `[${job.payload.join(', ')}]` : job.payload}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = (error as Error).message;
|
||||
this.logger(`Modbus operation failed: ${errorMsg}`);
|
||||
job.reject(error as Error);
|
||||
|
||||
// 如果是连接相关错误,标记连接断开
|
||||
if (errorMsg.includes("ECONNRESET") || errorMsg.includes("ENOTCONN") || errorMsg.includes("ETIMEDOUT")) {
|
||||
this.isConnected = false;
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 操作间隔,避免过于频繁的请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
this.logger("Modbus job processing loop stopped");
|
||||
}
|
||||
|
||||
getQueueStatus() {
|
||||
return {
|
||||
writeQueue: this.writeQueue.length,
|
||||
readQueue: this.readQueue.length,
|
||||
isConnected: this.isConnected
|
||||
};
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.logger("Closing ModbusManager...");
|
||||
this.isRunning = false;
|
||||
|
||||
// 清空队列,拒绝所有等待的任务
|
||||
[...this.writeQueue, ...this.readQueue].forEach(job => {
|
||||
job.reject(new Error("ModbusManager is closing"));
|
||||
});
|
||||
this.writeQueue = [];
|
||||
this.readQueue = [];
|
||||
|
||||
// 清理重连定时器
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = undefined;
|
||||
}
|
||||
|
||||
// 关闭socket连接
|
||||
if (this.socket) {
|
||||
try {
|
||||
this.socket.destroy();
|
||||
this.logger("Modbus socket closed");
|
||||
} catch (error) {
|
||||
this.logger(`Error closing Modbus socket: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.isConnected = false;
|
||||
this.logger("ModbusManager closed");
|
||||
}
|
||||
}
|
519
proto/vda5050.proto
Normal file
519
proto/vda5050.proto
Normal file
@ -0,0 +1,519 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package vda5050;
|
||||
|
||||
// 服务定义
|
||||
service AgvManagement {
|
||||
// 双向流通信通道
|
||||
rpc CommunicationChannel(stream AgvMessage) returns (stream ControlMessage) {}
|
||||
}
|
||||
|
||||
// 节点位置
|
||||
message NodePosition {
|
||||
float x = 1;
|
||||
float y = 2;
|
||||
optional float theta = 3;
|
||||
optional float allowedDeviationXy = 4;
|
||||
optional float allowedDeviationTheta = 5;
|
||||
string mapId = 6;
|
||||
optional string mapDescription = 7;
|
||||
}
|
||||
|
||||
// 节点信息
|
||||
message Node {
|
||||
string nodeId = 1;
|
||||
uint32 sequenceId = 2;
|
||||
optional string nodeDescription = 3;
|
||||
bool released = 4;
|
||||
optional NodePosition nodePosition = 5;
|
||||
repeated Action actions = 6;
|
||||
}
|
||||
|
||||
// 边信息
|
||||
message Edge {
|
||||
string edgeId = 1;
|
||||
uint32 sequenceId = 2;
|
||||
optional string edgeDescription = 3;
|
||||
bool released = 4;
|
||||
string startNodeId = 5;
|
||||
string endNodeId = 6;
|
||||
optional float maxSpeed = 7;
|
||||
optional float maxHeight = 8;
|
||||
optional float minHeight = 9;
|
||||
optional float orientation = 10;
|
||||
optional string direction = 11;
|
||||
optional bool rotationAllowed = 12;
|
||||
optional float maxRotationSpeed = 13;
|
||||
optional Trajectory trajectory = 14;
|
||||
optional float length = 15;
|
||||
}
|
||||
|
||||
// 动作信息
|
||||
message Action {
|
||||
string actionType = 1;
|
||||
string actionId = 2;
|
||||
optional string actionDescription = 3;
|
||||
string blockingType = 4;
|
||||
repeated ActionParameter actionParameters = 5;
|
||||
}
|
||||
|
||||
// 动作参数
|
||||
message ActionParameter {
|
||||
string key = 1;
|
||||
string value = 2;
|
||||
}
|
||||
|
||||
// 即时动作
|
||||
message InstantActions {
|
||||
uint32 headerId = 1;
|
||||
string timestamp = 2;
|
||||
string version = 3;
|
||||
string manufacturer = 4;
|
||||
string serialNumber = 5;
|
||||
repeated Action instantActions = 6;
|
||||
}
|
||||
|
||||
// 订单
|
||||
message Order {
|
||||
uint32 headerId = 1;
|
||||
string timestamp = 2;
|
||||
string version = 3;
|
||||
string manufacturer = 4;
|
||||
string serialNumber = 5;
|
||||
string orderId = 6;
|
||||
uint32 orderUpdateId = 7;
|
||||
optional string zoneSetId = 8;
|
||||
repeated Node nodes = 9;
|
||||
repeated Edge edges = 10;
|
||||
}
|
||||
|
||||
// 连接信息
|
||||
message Connection {
|
||||
uint32 headerId = 1;
|
||||
string timestamp = 2;
|
||||
string version = 3;
|
||||
string manufacturer = 4;
|
||||
string serialNumber = 5;
|
||||
string connectionState = 6;
|
||||
}
|
||||
|
||||
// 状态消息
|
||||
message State {
|
||||
uint32 headerId = 1;
|
||||
string timestamp = 2;
|
||||
string version = 3;
|
||||
string manufacturer = 4;
|
||||
string serialNumber = 5;
|
||||
string orderId = 6;
|
||||
uint32 orderUpdateId = 7;
|
||||
string lastNodeId = 8;
|
||||
uint32 lastNodeSequenceId = 9;
|
||||
bool driving = 10;
|
||||
optional bool waitingForInteractionZoneRelease = 11;
|
||||
optional bool paused = 12;
|
||||
optional ForkState forkState = 13;
|
||||
optional bool newBaseRequest = 14;
|
||||
optional float distanceSinceLastNode = 15;
|
||||
string operatingMode = 16;
|
||||
repeated NodeState nodeStates = 17;
|
||||
repeated EdgeState edgeStates = 18;
|
||||
optional AgvPosition agvPosition = 19;
|
||||
optional Velocity velocity = 20;
|
||||
repeated Load loads = 21;
|
||||
repeated ActionState actionStates = 22;
|
||||
BatteryState batteryState = 23;
|
||||
repeated Error errors = 24;
|
||||
repeated Information information = 25;
|
||||
SafetyState safetyState = 26;
|
||||
}
|
||||
|
||||
// AGV位置
|
||||
message AgvPosition {
|
||||
float x = 1;
|
||||
float y = 2;
|
||||
float theta = 3;
|
||||
string mapId = 4;
|
||||
optional string mapDescription = 5;
|
||||
bool positionInitialized = 6;
|
||||
optional float deviationRange = 7;
|
||||
optional float localizationScore = 8;
|
||||
}
|
||||
|
||||
// 速度
|
||||
message Velocity {
|
||||
optional float vx = 1;
|
||||
optional float vy = 2;
|
||||
optional float omega = 3;
|
||||
}
|
||||
|
||||
// 错误
|
||||
message Error {
|
||||
string errorType = 1;
|
||||
repeated ErrorReference errorReferences = 2;
|
||||
optional string errorDescription = 3;
|
||||
string errorLevel = 4;
|
||||
}
|
||||
|
||||
// 错误引用
|
||||
message ErrorReference {
|
||||
string referenceKey = 1;
|
||||
string referenceValue = 2;
|
||||
}
|
||||
|
||||
// 信息
|
||||
message Information {
|
||||
string infoType = 1;
|
||||
repeated InfoReference infoReferences = 2;
|
||||
optional string infoDescription = 3;
|
||||
string infoLevel = 4;
|
||||
}
|
||||
|
||||
// 信息引用
|
||||
message InfoReference {
|
||||
string referenceKey = 1;
|
||||
string referenceValue = 2;
|
||||
}
|
||||
|
||||
// 动作状态
|
||||
message ActionState {
|
||||
string actionId = 1;
|
||||
optional string actionType = 2;
|
||||
optional string actionDescription = 3;
|
||||
string actionStatus = 4;
|
||||
optional string resultDescription = 5;
|
||||
}
|
||||
|
||||
// 节点状态
|
||||
message NodeState {
|
||||
string nodeId = 1;
|
||||
uint32 sequenceId = 2;
|
||||
optional string nodeDescription = 3;
|
||||
optional NodePosition nodePosition = 4;
|
||||
bool released = 5;
|
||||
}
|
||||
|
||||
// 边状态
|
||||
message EdgeState {
|
||||
string edgeId = 1;
|
||||
uint32 sequenceId = 2;
|
||||
optional string edgeDescription = 3;
|
||||
bool released = 4;
|
||||
optional Trajectory trajectory = 5;
|
||||
}
|
||||
|
||||
// 负载
|
||||
message Load {
|
||||
optional string loadId = 1;
|
||||
optional string loadType = 2;
|
||||
optional string loadPosition = 3;
|
||||
optional BoundingBoxReference boundingBoxReference = 4;
|
||||
optional LoadDimensions loadDimensions = 5;
|
||||
optional float weight = 6;
|
||||
}
|
||||
|
||||
// 边界框引用
|
||||
message BoundingBoxReference {
|
||||
float x = 1;
|
||||
float y = 2;
|
||||
float z = 3;
|
||||
optional float theta = 4;
|
||||
}
|
||||
|
||||
// 负载尺寸
|
||||
message LoadDimensions {
|
||||
float length = 1;
|
||||
float width = 2;
|
||||
optional float height = 3;
|
||||
}
|
||||
|
||||
// 控制点
|
||||
message ControlPoint {
|
||||
float x = 1;
|
||||
float y = 2;
|
||||
optional float weight = 3;
|
||||
optional float orientation = 4;
|
||||
}
|
||||
|
||||
// 轨迹
|
||||
message Trajectory {
|
||||
int64 degree = 1;
|
||||
repeated float knotVector = 2;
|
||||
repeated ControlPoint controlPoints = 3;
|
||||
}
|
||||
|
||||
// 货叉状态
|
||||
message ForkState {
|
||||
float forkHeight = 1;
|
||||
}
|
||||
|
||||
// 安全状态
|
||||
message SafetyState {
|
||||
string eStop = 1;
|
||||
bool fieldViolation = 2;
|
||||
}
|
||||
|
||||
// 电池状态
|
||||
message BatteryState {
|
||||
float batteryCharge = 1;
|
||||
optional float batteryVoltage = 2;
|
||||
optional uint32 batteryHealth = 3;
|
||||
bool charging = 4;
|
||||
optional float reach = 5;
|
||||
}
|
||||
|
||||
// 可视化信息
|
||||
message Visualization {
|
||||
uint32 headerId = 1;
|
||||
string timestamp = 2;
|
||||
string version = 3;
|
||||
string manufacturer = 4;
|
||||
string serialNumber = 5;
|
||||
optional AgvPosition agvPosition = 6;
|
||||
optional Velocity velocity = 7;
|
||||
}
|
||||
|
||||
// 控制消息(从控制系统到AGV)
|
||||
message ControlMessage {
|
||||
string targetAgvId = 1;
|
||||
oneof message_type {
|
||||
Order order = 2;
|
||||
InstantActions instantActions = 3;
|
||||
}
|
||||
}
|
||||
|
||||
// AGV消息(从AGV到控制系统)
|
||||
message AgvMessage {
|
||||
string agvId = 1;
|
||||
oneof message_type {
|
||||
State state = 2;
|
||||
Connection connection = 3;
|
||||
Error error = 4;
|
||||
Visualization visualization = 5;
|
||||
Factsheet factsheet = 6;
|
||||
}
|
||||
}
|
||||
|
||||
// AGV 数据服务
|
||||
service AgvDataService {
|
||||
// 获取单个 AGV 的数据
|
||||
rpc GetAgvData (GetAgvDataRequest) returns (AgvDataResponse) {}
|
||||
// 获取所有 AGV 的数据
|
||||
rpc GetAllAgvData (GetAllAgvDataRequest) returns (GetAllAgvDataResponse) {}
|
||||
}
|
||||
|
||||
// 请求消息
|
||||
message GetAgvDataRequest {
|
||||
string agvId = 1;
|
||||
}
|
||||
|
||||
message GetAllAgvDataRequest {
|
||||
// 可以添加过滤条件
|
||||
bool includeRealAgvs = 1;
|
||||
bool includeSimulatedAgvs = 2;
|
||||
}
|
||||
|
||||
// 响应消息
|
||||
message AgvDataResponse {
|
||||
string agvId = 1;
|
||||
State state = 2;
|
||||
uint32 timestamp = 3;
|
||||
bool isReal = 4;
|
||||
}
|
||||
|
||||
message GetAllAgvDataResponse {
|
||||
repeated AgvDataResponse agvs = 1;
|
||||
}
|
||||
|
||||
// Factsheet provides AGV factsheet information to master control
|
||||
message Factsheet {
|
||||
uint32 headerId = 1;
|
||||
string timestamp = 2;
|
||||
string version = 3;
|
||||
string manufacturer = 4;
|
||||
string serialNumber = 5;
|
||||
TypeSpecification typeSpecification = 6;
|
||||
PhysicalParameters physicalParameters = 7;
|
||||
ProtocolLimits protocolLimits = 8;
|
||||
ProtocolFeatures protocolFeatures = 9;
|
||||
AgvGeometry agvGeometry = 10;
|
||||
LoadSpecification loadSpecification = 11;
|
||||
VehicleConfig vehicleConfig = 12;
|
||||
}
|
||||
|
||||
message TypeSpecification {
|
||||
string seriesName = 1;
|
||||
optional string seriesDescription = 2;
|
||||
string agvKinematic = 3;
|
||||
string agvClass = 4;
|
||||
double maxLoadMass = 5;
|
||||
repeated string localizationTypes = 6;
|
||||
repeated string navigationTypes = 7;
|
||||
}
|
||||
|
||||
message PhysicalParameters {
|
||||
double speedMin = 1;
|
||||
double speedMax = 2;
|
||||
optional double angularSpeedMin = 3;
|
||||
optional double angularSpeedMax = 4;
|
||||
double accelerationMax = 5;
|
||||
double decelerationMax = 6;
|
||||
double heightMin = 7;
|
||||
double heightMax = 8;
|
||||
double width = 9;
|
||||
double length = 10;
|
||||
}
|
||||
|
||||
message ProtocolLimits {
|
||||
MaxStringLens maxStringLens = 1;
|
||||
MaxArrayLens maxArrayLens = 2;
|
||||
Timing timing = 3;
|
||||
}
|
||||
|
||||
message MaxStringLens {
|
||||
optional uint32 msgLen = 1;
|
||||
optional uint32 topicSerialLen = 2;
|
||||
optional uint32 topicElemLen = 3;
|
||||
optional uint32 idLen = 4;
|
||||
optional bool idNumericalOnly = 5;
|
||||
optional uint32 enumLen = 6;
|
||||
optional uint32 loadIdLen = 7;
|
||||
}
|
||||
|
||||
message MaxArrayLens {
|
||||
optional uint32 orderNodes = 1;
|
||||
optional uint32 orderEdges = 2;
|
||||
optional uint32 nodeActions = 3;
|
||||
optional uint32 edgeActions = 4;
|
||||
optional uint32 actionParameters = 5;
|
||||
optional uint32 instantActions = 6;
|
||||
optional uint32 trajectoryKnotVector = 7;
|
||||
optional uint32 trajectoryControlPoints = 8;
|
||||
optional uint32 stateNodeStates = 9;
|
||||
optional uint32 stateEdgeStates = 10;
|
||||
optional uint32 stateLoads = 11;
|
||||
optional uint32 stateActionStates = 12;
|
||||
optional uint32 stateErrors = 13;
|
||||
optional uint32 stateInformation = 14;
|
||||
optional uint32 errorErrorReferences = 15;
|
||||
optional uint32 informationInfoReferences = 16;
|
||||
}
|
||||
|
||||
message Timing {
|
||||
optional float minOrderInterval = 1;
|
||||
optional float minStateInterval = 2;
|
||||
optional float defaultStateInterval = 3;
|
||||
optional float visualizationInterval = 4;
|
||||
}
|
||||
|
||||
message ProtocolFeatures {
|
||||
repeated OptionalParameter optionalParameters = 1;
|
||||
repeated AgvAction agvActions = 2;
|
||||
}
|
||||
|
||||
message OptionalParameter {
|
||||
string parameter = 1;
|
||||
string support = 2;
|
||||
optional string description = 3;
|
||||
}
|
||||
|
||||
message AgvAction {
|
||||
string actionType = 1;
|
||||
optional string actionDescription = 2;
|
||||
repeated string actionScopes = 3;
|
||||
repeated ActionParameterDefinition actionParameters = 4;
|
||||
optional string resultDescription = 5;
|
||||
repeated string blockingTypes = 6;
|
||||
}
|
||||
|
||||
message ActionParameterDefinition {
|
||||
string key = 1;
|
||||
string valueDataType = 2;
|
||||
optional string description = 3;
|
||||
optional bool isOptional = 4;
|
||||
}
|
||||
|
||||
message AgvGeometry {
|
||||
repeated WheelDefinition wheelDefinitions = 1;
|
||||
repeated Envelope2d envelopes2d = 2;
|
||||
repeated Envelope3d envelopes3d = 3;
|
||||
}
|
||||
|
||||
message WheelDefinition {
|
||||
string type = 1;
|
||||
bool isActiveDriven = 2;
|
||||
bool isActiveSteered = 3;
|
||||
Position position = 4;
|
||||
double diameter = 5;
|
||||
double width = 6;
|
||||
optional double centerDisplacement = 7;
|
||||
optional string constraints = 8;
|
||||
}
|
||||
|
||||
message Position {
|
||||
double x = 1;
|
||||
double y = 2;
|
||||
optional double theta = 3;
|
||||
}
|
||||
|
||||
message Envelope2d {
|
||||
string set = 1;
|
||||
repeated PolygonPoint polygonPoints = 2;
|
||||
optional string description = 3;
|
||||
}
|
||||
|
||||
message PolygonPoint {
|
||||
double x = 1;
|
||||
double y = 2;
|
||||
}
|
||||
|
||||
message Envelope3d {
|
||||
string set = 1;
|
||||
string format = 2;
|
||||
string data = 3;
|
||||
optional string url = 4;
|
||||
optional string description = 5;
|
||||
}
|
||||
|
||||
message LoadSpecification {
|
||||
repeated string loadPositions = 1;
|
||||
repeated LoadSet loadSets = 2;
|
||||
}
|
||||
|
||||
message LoadSet {
|
||||
string setName = 1;
|
||||
string loadType = 2;
|
||||
repeated string loadPositions = 3;
|
||||
BoundingBoxReference boundingBoxReference = 4;
|
||||
LoadDimensions loadDimensions = 5;
|
||||
optional double maxWeight = 6;
|
||||
optional double minLoadhandlingHeight = 7;
|
||||
optional double maxLoadhandlingHeight = 8;
|
||||
optional double minLoadhandlingDepth = 9;
|
||||
optional double maxLoadhandlingDepth = 10;
|
||||
optional double minLoadhandlingTilt = 11;
|
||||
optional double maxLoadhandlingTilt = 12;
|
||||
optional double agvSpeedLimit = 13;
|
||||
optional double agvAccelerationLimit = 14;
|
||||
optional double agvDecelerationLimit = 15;
|
||||
optional double pickTime = 16;
|
||||
optional double dropTime = 17;
|
||||
optional string description = 18;
|
||||
}
|
||||
|
||||
message VehicleConfig {
|
||||
repeated VersionInfo versions = 1;
|
||||
optional NetworkInfo network = 2;
|
||||
}
|
||||
|
||||
message VersionInfo {
|
||||
string key = 1;
|
||||
string value = 2;
|
||||
}
|
||||
|
||||
message NetworkInfo {
|
||||
repeated string dnsServers = 1;
|
||||
repeated string ntpServers = 2;
|
||||
string localIpAddress = 3;
|
||||
string netmask = 4;
|
||||
string defaultGateway = 5;
|
||||
}
|
796
simulator.ts
Normal file
796
simulator.ts
Normal file
@ -0,0 +1,796 @@
|
||||
// simulator.ts - Worker版本
|
||||
import mqtt, { IClientOptions, MqttClient } from "npm:mqtt";
|
||||
import { v4 as uuidv4 } from "npm:uuid";
|
||||
import { loadConfig, RawConfig } from "./simulator_config.ts";
|
||||
|
||||
interface SimulatorConfig {
|
||||
vdaInterface: string;
|
||||
zoneSetId: string;
|
||||
mapId: string;
|
||||
vehicle: {
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
vdaVersion: string;
|
||||
};
|
||||
mqtt: {
|
||||
brokerUrl: string;
|
||||
options: IClientOptions;
|
||||
};
|
||||
settings: {
|
||||
robotCount: number;
|
||||
stateFrequency: number;
|
||||
visualizationFrequency: number;
|
||||
speed: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Header {
|
||||
headerId: number;
|
||||
timestamp: string;
|
||||
version: string;
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
}
|
||||
|
||||
interface BatteryState {
|
||||
batteryCharge: number;
|
||||
charging: boolean;
|
||||
batteryHealth?: number;
|
||||
batteryVoltage?: number;
|
||||
reach?: number;
|
||||
}
|
||||
|
||||
interface SafetyState {
|
||||
eStop: string;
|
||||
fieldViolation: boolean;
|
||||
}
|
||||
|
||||
interface AGVPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
theta: number;
|
||||
mapId: string;
|
||||
positionInitialized: boolean;
|
||||
mapDescription?: string;
|
||||
localizationScore?: number;
|
||||
deviationRange?: number;
|
||||
}
|
||||
|
||||
interface Velocity {
|
||||
vx: number;
|
||||
vy: number;
|
||||
omega: number;
|
||||
}
|
||||
|
||||
type ActionStatus = "WAITING" | "INITIALIZING" | "RUNNING" | "PAUSED" | "FINISHED" | "FAILED";
|
||||
|
||||
interface ActionState {
|
||||
actionId: string;
|
||||
actionType?: string;
|
||||
actionStatus: ActionStatus;
|
||||
actionDescription?: string;
|
||||
resultDescription?: string;
|
||||
actionParameters?: Array<{ key: string; value: string }>;
|
||||
blockingType?: "NONE" | "SOFT" | "HARD";
|
||||
}
|
||||
|
||||
interface NodeState {
|
||||
nodeId: string;
|
||||
sequenceId: string;
|
||||
released: boolean;
|
||||
nodeDescription?: string;
|
||||
nodePosition: AGVPosition;
|
||||
}
|
||||
|
||||
interface EdgeState {
|
||||
edgeId: string;
|
||||
sequenceId: string;
|
||||
released: boolean;
|
||||
edgeDescription?: string;
|
||||
trajectory?: Array<Record<string,unknown>>;
|
||||
}
|
||||
|
||||
interface Factsheet {
|
||||
headerId: number;
|
||||
timestamp: string;
|
||||
version: string;
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
typeSpecification: {
|
||||
seriesName: string;
|
||||
seriesDescription: string;
|
||||
agvKinematic: string;
|
||||
agvClass: string;
|
||||
maxLoadMass: number;
|
||||
localizationTypes: string[];
|
||||
navigationTypes: string[];
|
||||
};
|
||||
physicalParameters: {
|
||||
speedMin: number;
|
||||
speedMax: number;
|
||||
accelerationMax: number;
|
||||
decelerationMax: number;
|
||||
heightMin: number;
|
||||
heightMax: number;
|
||||
width: number;
|
||||
length: number;
|
||||
};
|
||||
protocolLimits: any;
|
||||
protocolFeatures: any;
|
||||
agvGeometry: any;
|
||||
loadSpecification: any;
|
||||
localizationParameters: any;
|
||||
}
|
||||
|
||||
interface State {
|
||||
headerId: number;
|
||||
timestamp: string;
|
||||
version: string;
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
orderId: string;
|
||||
orderUpdateId: number;
|
||||
zoneSetId: string;
|
||||
lastNodeId: string;
|
||||
lastNodeSequenceId: number;
|
||||
nodeStates: NodeState[];
|
||||
edgeStates: EdgeState[];
|
||||
actionStates: ActionState[];
|
||||
batteryState: BatteryState;
|
||||
operatingMode: string;
|
||||
errors: { errorType: string; errorDescription?: string; errorLevel: string }[];
|
||||
safetyState: SafetyState;
|
||||
driving: boolean;
|
||||
paused: boolean;
|
||||
newBaseRequest: boolean;
|
||||
waitingForInteractionZoneRelease: boolean;
|
||||
agvPosition: AGVPosition;
|
||||
velocity: Velocity;
|
||||
loads: any[];
|
||||
information?: any[];
|
||||
forkState?: { forkHeight?: number };
|
||||
}
|
||||
|
||||
interface Visualization {
|
||||
header: Header;
|
||||
agvPosition: AGVPosition;
|
||||
velocity: Velocity;
|
||||
driving: boolean;
|
||||
}
|
||||
|
||||
interface Connection {
|
||||
headerId: number;
|
||||
timestamp: string;
|
||||
version: string;
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
connectionState: "ONLINE" | "OFFLINE" | "CONNECTIONBROKEN";
|
||||
}
|
||||
|
||||
interface ActionParamValue {
|
||||
Float?: number;
|
||||
Str?: string;
|
||||
}
|
||||
|
||||
interface Action {
|
||||
actionId: string;
|
||||
actionType?: string;
|
||||
actionParameters: { key: string; value: ActionParamValue }[];
|
||||
}
|
||||
|
||||
interface InstantActions {
|
||||
instantActions: Action[];
|
||||
}
|
||||
|
||||
interface Order {
|
||||
orderId: string;
|
||||
orderUpdateId: number;
|
||||
nodes: any[];
|
||||
edges: any[];
|
||||
}
|
||||
|
||||
// Worker消息类型
|
||||
interface WorkerMessage {
|
||||
type: "init" | "close" | "reconnect";
|
||||
data?: any;
|
||||
}
|
||||
|
||||
interface MainMessage {
|
||||
type: "ready" | "error" | "status" | "log" | "device_request";
|
||||
data?: any;
|
||||
}
|
||||
|
||||
class VehicleSimulator {
|
||||
connectionTopic: string = "";
|
||||
stateTopic: string = "";
|
||||
visualizationTopic: string = "";
|
||||
factsheetTopic: string = "";
|
||||
connection!: Connection;
|
||||
state!: State;
|
||||
visualization!: Visualization;
|
||||
factsheet!: Factsheet;
|
||||
private lastUpdate = Date.now();
|
||||
private speed: number = 0;
|
||||
private boundary = 40.0;
|
||||
private mqttClient?: MqttClient;
|
||||
private intervals: number[] = [];
|
||||
private isRunning = false;
|
||||
|
||||
constructor(private cfg: SimulatorConfig) {
|
||||
this.speed = cfg.settings.speed;
|
||||
this.initializeSimulator();
|
||||
}
|
||||
|
||||
private initializeSimulator() {
|
||||
const { manufacturer, serialNumber, vdaVersion } = this.cfg.vehicle;
|
||||
const base = `${this.cfg.vdaInterface}/${vdaVersion}/${manufacturer}/${serialNumber}`;
|
||||
this.connectionTopic = `${base}/connection`;
|
||||
this.stateTopic = `${base}/state`;
|
||||
this.visualizationTopic = `${base}/visualization`;
|
||||
this.factsheetTopic = `${base}/factsheet`;
|
||||
|
||||
const now = () => new Date().toISOString();
|
||||
const header0: Header = {
|
||||
headerId: 0,
|
||||
timestamp: now(),
|
||||
version: vdaVersion,
|
||||
manufacturer,
|
||||
serialNumber
|
||||
};
|
||||
|
||||
this.connection = {
|
||||
headerId: 0,
|
||||
timestamp: now(),
|
||||
version: vdaVersion,
|
||||
manufacturer,
|
||||
serialNumber,
|
||||
connectionState: "CONNECTIONBROKEN"
|
||||
};
|
||||
|
||||
// 随机初始位置
|
||||
const x0 = (Math.random() * 2 - 1) * 30;
|
||||
const y0 = (Math.random() * 2 - 1) * 30;
|
||||
const th0 = (Math.random() * 2 - 1) * Math.PI;
|
||||
|
||||
// VDA 5050 兼容的扁平化 State
|
||||
this.state = {
|
||||
headerId: 0,
|
||||
timestamp: now(),
|
||||
version: vdaVersion,
|
||||
manufacturer,
|
||||
serialNumber,
|
||||
orderId: "",
|
||||
orderUpdateId: 0,
|
||||
zoneSetId: this.cfg.zoneSetId,
|
||||
lastNodeId: "",
|
||||
lastNodeSequenceId: 0,
|
||||
nodeStates: [],
|
||||
edgeStates: [],
|
||||
actionStates: [],
|
||||
batteryState: { batteryCharge: 1.0, charging: false },
|
||||
operatingMode: "AUTOMATIC",
|
||||
errors: [],
|
||||
safetyState: { eStop: "NONE", fieldViolation: false },
|
||||
driving: true,
|
||||
paused: false,
|
||||
newBaseRequest: true,
|
||||
waitingForInteractionZoneRelease: false,
|
||||
agvPosition: {
|
||||
x: x0,
|
||||
y: y0,
|
||||
theta: th0,
|
||||
mapId: this.cfg.mapId,
|
||||
positionInitialized: true
|
||||
},
|
||||
velocity: {
|
||||
vx: this.speed * Math.cos(th0),
|
||||
vy: this.speed * Math.sin(th0),
|
||||
omega: 0
|
||||
},
|
||||
loads: [],
|
||||
information: [],
|
||||
forkState: { forkHeight: 0 }
|
||||
};
|
||||
|
||||
this.visualization = {
|
||||
header: { ...header0 },
|
||||
agvPosition: { ...this.state.agvPosition },
|
||||
velocity: { ...this.state.velocity },
|
||||
driving: true
|
||||
};
|
||||
|
||||
this.factsheet = {
|
||||
headerId: 0,
|
||||
timestamp: now(),
|
||||
version: "v3.4.7.1005",
|
||||
manufacturer,
|
||||
serialNumber,
|
||||
typeSpecification: {
|
||||
seriesName: serialNumber,
|
||||
seriesDescription: serialNumber,
|
||||
agvKinematic: "DIFF",
|
||||
agvClass: "FORKLIFT",
|
||||
maxLoadMass: 0.5,
|
||||
localizationTypes: ["NATURAL"],
|
||||
navigationTypes: ["PHYSICAL_LINE_GUIDED"]
|
||||
},
|
||||
physicalParameters: {
|
||||
speedMin: 0.01,
|
||||
speedMax: 0.8,
|
||||
accelerationMax: 0.5,
|
||||
decelerationMax: 0.15,
|
||||
heightMin: 1.83,
|
||||
heightMax: 1.83,
|
||||
width: 0.885,
|
||||
length: 1.5145
|
||||
},
|
||||
protocolLimits: null,
|
||||
protocolFeatures: null,
|
||||
agvGeometry: null,
|
||||
loadSpecification: null,
|
||||
localizationParameters: null
|
||||
};
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.isRunning) {
|
||||
this.log("warn", "Simulator already running");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isRunning = true;
|
||||
await this.connectMqtt();
|
||||
await this.subscribeVda();
|
||||
await this.publishConnection();
|
||||
this.publishFactsheet(); // 启动时发布一次 factsheet
|
||||
this.startIntervals();
|
||||
|
||||
this.postMessage({ type: "ready", data: { agvId: this.cfg.vehicle.serialNumber } });
|
||||
this.log("info", `🚛 AGV ${this.cfg.vehicle.serialNumber} started successfully`);
|
||||
} catch (error) {
|
||||
this.isRunning = false;
|
||||
this.postMessage({ type: "error", data: { error: (error as Error).message, agvId: this.cfg.vehicle.serialNumber } });
|
||||
this.log("error", `Failed to start AGV ${this.cfg.vehicle.serialNumber}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (!this.isRunning) return;
|
||||
|
||||
this.isRunning = false;
|
||||
this.clearIntervals();
|
||||
|
||||
if (this.mqttClient) {
|
||||
this.connection.connectionState = "OFFLINE";
|
||||
this.publishConnectionOnline();
|
||||
this.mqttClient.end();
|
||||
this.mqttClient = undefined;
|
||||
}
|
||||
|
||||
this.log("info", `🛑 AGV ${this.cfg.vehicle.serialNumber} stopped`);
|
||||
}
|
||||
|
||||
async reconnect() {
|
||||
this.log("info", `🔄 Reconnecting AGV ${this.cfg.vehicle.serialNumber}`);
|
||||
await this.stop();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // 等待1秒
|
||||
await this.start();
|
||||
}
|
||||
|
||||
private async connectMqtt() {
|
||||
const clientOptions: IClientOptions = {
|
||||
clientId: `deno-agv-sim-${this.cfg.vehicle.serialNumber}-${uuidv4()}`,
|
||||
clean: true,
|
||||
keepalive: 60,
|
||||
};
|
||||
|
||||
this.mqttClient = mqtt.connect(this.cfg.mqtt.brokerUrl, clientOptions);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("MQTT connection timeout"));
|
||||
}, 10000);
|
||||
|
||||
this.mqttClient!.on("connect", () => {
|
||||
clearTimeout(timeout);
|
||||
this.log("info", `✅ MQTT connected: ${this.cfg.vehicle.serialNumber}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.mqttClient!.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error as Error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async subscribeVda() {
|
||||
if (!this.mqttClient) throw new Error("MQTT client not connected");
|
||||
|
||||
const b = `${this.cfg.vdaInterface}/${this.cfg.vehicle.vdaVersion}/${this.cfg.vehicle.manufacturer}/${this.cfg.vehicle.serialNumber}`;
|
||||
const topics = [`${b}/instantActions`, `${b}/order`];
|
||||
|
||||
await new Promise<void>((res, rej) =>
|
||||
this.mqttClient!.subscribe(topics, { qos: 1 }, err => err ? rej(err) : res())
|
||||
);
|
||||
|
||||
this.mqttClient.on("message", (topic, payload) => {
|
||||
try {
|
||||
const msg = JSON.parse(payload.toString());
|
||||
this.log("info", `📨 Received MQTT message on topic: ${topic}`);
|
||||
|
||||
if (topic.endsWith("instantActions")) {
|
||||
this.instantActionsAccept(msg);
|
||||
} else if (topic.endsWith("order")) {
|
||||
this.orderAccept(msg);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log("error", `Error processing MQTT message: ${(error as Error).message}`);
|
||||
this.log("error", `Topic: ${topic}, Payload: ${payload.toString()}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startIntervals() {
|
||||
const stateInterval = setInterval(() => {
|
||||
this.stateIterate();
|
||||
this.publishState();
|
||||
}, 2000);
|
||||
|
||||
const visInterval = setInterval(() => {
|
||||
this.publishVisualization();
|
||||
}, Math.floor(1000 / this.cfg.settings.visualizationFrequency));
|
||||
|
||||
const connectionInterval = setInterval(() => {
|
||||
this.publishConnectionOnline();
|
||||
}, 1000);
|
||||
|
||||
// const factsheetInterval = setInterval(() => {
|
||||
// this.publishFactsheet();
|
||||
// }, 30000); // 每30秒发布一次 factsheet
|
||||
|
||||
this.intervals = [stateInterval, visInterval, connectionInterval];
|
||||
}
|
||||
|
||||
private clearIntervals() {
|
||||
this.intervals.forEach(interval => clearInterval(interval));
|
||||
this.intervals = [];
|
||||
}
|
||||
|
||||
stateIterate() {
|
||||
const nowMs = Date.now();
|
||||
const dt = (nowMs - this.lastUpdate) / 1000;
|
||||
this.lastUpdate = nowMs;
|
||||
this.state.headerId++;
|
||||
this.state.timestamp = new Date().toISOString();
|
||||
|
||||
if (this.state.driving) {
|
||||
let { x, y, theta } = this.state.agvPosition;
|
||||
const vx = this.speed * Math.cos(theta);
|
||||
const vy = this.speed * Math.sin(theta);
|
||||
let nx = x + vx * dt;
|
||||
let ny = y + vy * dt;
|
||||
let bounced = false;
|
||||
|
||||
if (nx > this.boundary) {
|
||||
nx = this.boundary - (nx - this.boundary);
|
||||
bounced = true;
|
||||
}
|
||||
if (nx < -this.boundary) {
|
||||
nx = -this.boundary - (-this.boundary - nx);
|
||||
bounced = true;
|
||||
}
|
||||
if (ny > this.boundary) {
|
||||
ny = this.boundary - (ny - this.boundary);
|
||||
bounced = true;
|
||||
}
|
||||
if (ny < -this.boundary) {
|
||||
ny = -this.boundary - (-this.boundary - ny);
|
||||
bounced = true;
|
||||
}
|
||||
|
||||
if (bounced) {
|
||||
theta += Math.PI;
|
||||
if (theta > Math.PI) theta -= 2 * Math.PI;
|
||||
if (theta < -Math.PI) theta += 2 * Math.PI;
|
||||
}
|
||||
|
||||
this.state.agvPosition = {
|
||||
x: nx,
|
||||
y: ny,
|
||||
theta,
|
||||
mapId: this.state.agvPosition.mapId,
|
||||
positionInitialized: true
|
||||
};
|
||||
this.state.velocity = {
|
||||
vx: this.speed * Math.cos(theta),
|
||||
vy: this.speed * Math.sin(theta),
|
||||
omega: 0
|
||||
};
|
||||
} else {
|
||||
this.state.velocity = { vx: 0, vy: 0, omega: 0 };
|
||||
}
|
||||
|
||||
// 同步 Visualization header
|
||||
this.visualization.header.headerId = this.state.headerId + 1;
|
||||
this.visualization.header.timestamp = this.state.timestamp;
|
||||
this.visualization.agvPosition = { ...this.state.agvPosition };
|
||||
this.visualization.velocity = { ...this.state.velocity };
|
||||
this.visualization.driving = this.state.driving;
|
||||
}
|
||||
|
||||
async publishConnection() {
|
||||
if (!this.mqttClient) return;
|
||||
|
||||
const pub = (msg: Connection) => new Promise<void>(res => {
|
||||
this.mqttClient!.publish(this.connectionTopic, JSON.stringify(msg), { qos: 1 }, () => res());
|
||||
});
|
||||
|
||||
// Broken
|
||||
this.connection.headerId++;
|
||||
this.connection.timestamp = new Date().toISOString();
|
||||
this.connection.connectionState = "CONNECTIONBROKEN";
|
||||
await pub(this.connection);
|
||||
|
||||
// Online
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
this.connection.headerId++;
|
||||
this.connection.timestamp = new Date().toISOString();
|
||||
this.connection.connectionState = "ONLINE";
|
||||
await pub(this.connection);
|
||||
}
|
||||
|
||||
publishConnectionOnline() {
|
||||
if (!this.mqttClient) return;
|
||||
|
||||
this.connection.headerId++;
|
||||
this.connection.timestamp = new Date().toISOString();
|
||||
this.connection.connectionState = "ONLINE";
|
||||
this.mqttClient.publish(this.connectionTopic, JSON.stringify(this.connection), { qos: 1 });
|
||||
}
|
||||
|
||||
publishState() {
|
||||
if (!this.mqttClient) return;
|
||||
this.mqttClient.publish(this.stateTopic, JSON.stringify(this.state), { qos: 1 });
|
||||
}
|
||||
|
||||
publishVisualization() {
|
||||
if (!this.mqttClient) return;
|
||||
this.mqttClient.publish(this.visualizationTopic, JSON.stringify(this.visualization), { qos: 1 });
|
||||
}
|
||||
|
||||
publishFactsheet() {
|
||||
if (!this.mqttClient) return;
|
||||
this.factsheet.headerId++;
|
||||
this.factsheet.timestamp = new Date().toISOString();
|
||||
this.mqttClient.publish(this.factsheetTopic, JSON.stringify(this.factsheet), { qos: 1 });
|
||||
}
|
||||
|
||||
async instantActionsAccept(req: any) {
|
||||
this.log("info", `📥 Received instantActions: ${JSON.stringify(req, null, 2)}`);
|
||||
|
||||
// 验证消息格式
|
||||
if (!req || typeof req !== 'object') {
|
||||
this.log("error", "Invalid instantActions request: not an object");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查actions或instantActions字段(支持两种格式)
|
||||
let actionsArray: any[];
|
||||
if (req.instantActions && Array.isArray(req.instantActions)) {
|
||||
actionsArray = req.instantActions;
|
||||
} else if (req.actions && Array.isArray(req.actions)) {
|
||||
actionsArray = req.actions;
|
||||
this.log("info", "Using 'actions' field instead of 'instantActions'");
|
||||
} else {
|
||||
this.log("error", "Missing instantActions or actions field in request");
|
||||
return;
|
||||
}
|
||||
|
||||
this.log("info", `📋 Processing ${actionsArray.length} instant actions`);
|
||||
|
||||
for (const act of actionsArray) {
|
||||
if (!act || !act.actionId) {
|
||||
this.log("error", "Invalid action: missing actionId");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否是设备相关的action
|
||||
if (act.actionType === "deviceSetup") {
|
||||
this.log("info", `🔧 Processing deviceSetup action: ${act.actionId}`);
|
||||
|
||||
// 提取设备信息
|
||||
const deviceInfo = this.extractDeviceInfo(act.actionParameters || []);
|
||||
if (deviceInfo) {
|
||||
// 通知主进程创建设备模拟器
|
||||
this.postMessage({
|
||||
type: "device_request",
|
||||
data: {
|
||||
action: "create",
|
||||
deviceInfo,
|
||||
originalAction: act
|
||||
}
|
||||
});
|
||||
|
||||
this.log("info", `📡 Requested device simulator creation for ${deviceInfo.ip}-${deviceInfo.port}-${deviceInfo.slaveId}`);
|
||||
} else {
|
||||
this.log("error", `❌ Failed to extract device info from action ${act.actionId}`);
|
||||
}
|
||||
} else if (act.actionType === "deviceStop") {
|
||||
this.log("info", `🛑 Processing deviceStop action: ${act.actionId}`);
|
||||
|
||||
// 提取设备ID或信息
|
||||
const deviceInfo = this.extractDeviceInfo(act.actionParameters || []);
|
||||
if (deviceInfo) {
|
||||
const deviceId = `${deviceInfo.ip}-${deviceInfo.port}-${deviceInfo.slaveId}`;
|
||||
// 通知主进程停止设备模拟器
|
||||
this.postMessage({
|
||||
type: "device_request",
|
||||
data: {
|
||||
action: "stop",
|
||||
deviceId,
|
||||
originalAction: act
|
||||
}
|
||||
});
|
||||
|
||||
this.log("info", `📡 Requested device simulator stop for ${deviceId}`);
|
||||
}
|
||||
} else if (act.actionType === "deviceDelete") {
|
||||
this.log("info", `🗑️ Processing deviceDelete action: ${act.actionId}`);
|
||||
|
||||
// 提取设备ID或信息
|
||||
const deviceInfo = this.extractDeviceInfo(act.actionParameters || []);
|
||||
if (deviceInfo) {
|
||||
const deviceId = `${deviceInfo.ip}-${deviceInfo.port}-${deviceInfo.slaveId}`;
|
||||
// 通知主进程删除设备模拟器
|
||||
this.postMessage({
|
||||
type: "device_request",
|
||||
data: {
|
||||
action: "delete",
|
||||
deviceId,
|
||||
originalAction: act
|
||||
}
|
||||
});
|
||||
|
||||
this.log("info", `📡 Requested device simulator deletion for ${deviceId}`);
|
||||
}
|
||||
} else if (act.actionType === "deviceWrite" || act.actionType === "deviceRead") {
|
||||
this.log("info", `🔧 Processing device action: ${act.actionType} - ${act.actionId}`);
|
||||
|
||||
// 提取设备信息
|
||||
const deviceInfo = this.extractDeviceInfo(act.actionParameters || []);
|
||||
if (deviceInfo) {
|
||||
const deviceId = `${deviceInfo.ip}-${deviceInfo.port}-${deviceInfo.slaveId}`;
|
||||
// 通知主进程转发action到对应的设备模拟器
|
||||
this.postMessage({
|
||||
type: "device_request",
|
||||
data: {
|
||||
action: "forward",
|
||||
deviceId,
|
||||
originalAction: act
|
||||
}
|
||||
});
|
||||
|
||||
this.log("info", `📡 Forwarded device action to ${deviceId}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.state.actionStates.push({
|
||||
actionId: act.actionId,
|
||||
actionType: act.actionType || "unknown",
|
||||
actionStatus: "WAITING"
|
||||
});
|
||||
|
||||
this.log("info", `✅ Added action: ${act.actionId} (${act.actionType || "unknown"})`);
|
||||
}
|
||||
}
|
||||
|
||||
// 从actionParameters中提取设备信息
|
||||
private extractDeviceInfo(actionParameters: any[]): any | null {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
for (const param of actionParameters) {
|
||||
if (param.key && param.value) {
|
||||
// 处理不同的value格式
|
||||
let value: string;
|
||||
if (typeof param.value === 'string') {
|
||||
value = param.value;
|
||||
} else if (param.value?.Str) {
|
||||
value = param.value.Str;
|
||||
} else if (param.value?.Float) {
|
||||
value = param.value.Float.toString();
|
||||
} else {
|
||||
value = String(param.value);
|
||||
}
|
||||
params[param.key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查必需的字段
|
||||
const required = ['ip', 'port', 'slaveId'];
|
||||
for (const field of required) {
|
||||
if (!params[field]) {
|
||||
this.log("error", `❌ Missing required parameter: ${field}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ip: params.ip,
|
||||
port: params.port,
|
||||
slaveId: params.slaveId,
|
||||
deviceName: params.deviceName || 'Unknown Device',
|
||||
protocolType: params.protocolType || 'Unknown Protocol',
|
||||
brandName: params.brandName || 'Unknown Brand',
|
||||
registers: params.registers || '[]'
|
||||
};
|
||||
}
|
||||
|
||||
async orderAccept(req: Order) {
|
||||
this.log("info", `📥 Received order: ${JSON.stringify(req, null, 2)}`);
|
||||
this.state.orderId = req.orderId;
|
||||
this.state.orderUpdateId = req.orderUpdateId;
|
||||
this.state.nodeStates = [];
|
||||
this.state.edgeStates = [];
|
||||
this.state.actionStates = [];
|
||||
this.log("info", `✅ Accepted order: ${req.orderId} (updateId: ${req.orderUpdateId})`);
|
||||
}
|
||||
|
||||
private log(level: "info" | "warn" | "error", message: string) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logMessage = `[${timestamp}] [${this.cfg.vehicle.serialNumber}] ${message}`;
|
||||
|
||||
console.log(logMessage);
|
||||
this.postMessage({
|
||||
type: "log",
|
||||
data: { level, message: logMessage, agvId: this.cfg.vehicle.serialNumber }
|
||||
});
|
||||
}
|
||||
|
||||
private postMessage(message: MainMessage) {
|
||||
self.postMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Worker全局变量
|
||||
let simulator: VehicleSimulator | null = null;
|
||||
|
||||
// Worker消息处理
|
||||
self.onmessage = async (event: MessageEvent<WorkerMessage>) => {
|
||||
const { type, data } = event.data;
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case "init":
|
||||
if (simulator) {
|
||||
await simulator.stop();
|
||||
}
|
||||
simulator = new VehicleSimulator(data);
|
||||
await simulator.start();
|
||||
break;
|
||||
|
||||
case "close":
|
||||
if (simulator) {
|
||||
await simulator.stop();
|
||||
simulator = null;
|
||||
}
|
||||
self.postMessage({ type: "status", data: { status: "closed" } });
|
||||
break;
|
||||
|
||||
case "reconnect":
|
||||
if (simulator) {
|
||||
await simulator.reconnect();
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`Unknown message type: ${type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
type: "error",
|
||||
data: { error: (error as Error).message, context: type }
|
||||
});
|
||||
}
|
||||
};
|
23
simulator_config.json
Normal file
23
simulator_config.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"mqtt_broker": {
|
||||
"host": "10.2.0.6",
|
||||
"port": "1883",
|
||||
"vda_interface": "uagv"
|
||||
},
|
||||
"vehicle": {
|
||||
"serial_number": "EXT-",
|
||||
"channel_name": "r10256",
|
||||
"channel_manufacturer": "vendor",
|
||||
"manufacturer": "SEER",
|
||||
"vda_version": "v2",
|
||||
"vda_full_version": "2.0.0"
|
||||
},
|
||||
"settings": {
|
||||
"map_id": "mapcc",
|
||||
"state_frequency": 1,
|
||||
"visualization_frequency": 1,
|
||||
"action_time": 1.0,
|
||||
"robot_count": 2,
|
||||
"speed": 0.05
|
||||
}
|
||||
}
|
127
simulator_config.ts
Normal file
127
simulator_config.ts
Normal file
@ -0,0 +1,127 @@
|
||||
// config.ts
|
||||
export interface MqttBrokerConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
vdaInterface: string;
|
||||
}
|
||||
export interface VehicleConfig {
|
||||
serialNumber: string;
|
||||
channelName: string;
|
||||
channelManufacturer: string;
|
||||
manufacturer: string;
|
||||
vdaVersion: string;
|
||||
vdaFullVersion: string;
|
||||
}
|
||||
export interface Settings {
|
||||
mapId: string;
|
||||
stateFrequency: number;
|
||||
visualizationFrequency: number;
|
||||
actionTime: number;
|
||||
robotCount: number;
|
||||
speed: number;
|
||||
}
|
||||
export interface RawConfig {
|
||||
mqttBroker: MqttBrokerConfig;
|
||||
vehicle: VehicleConfig;
|
||||
settings: Settings;
|
||||
}
|
||||
export async function loadConfig(): Promise<RawConfig> {
|
||||
// 配置文件可能的路径列表
|
||||
const configPaths = [
|
||||
// 1. 当前工作目录
|
||||
"./simulator_config.json",
|
||||
// 2. 相对于模块的路径(开发环境)
|
||||
new URL("./simulator_config.json", import.meta.url).pathname,
|
||||
// 3. 可执行文件同目录
|
||||
"./simulator_config.json",
|
||||
// 4. 上级目录
|
||||
"../simulator_config.json",
|
||||
// 5. 用户主目录
|
||||
`${Deno.env.get("HOME") || Deno.env.get("USERPROFILE")}/simulator_config.json`,
|
||||
];
|
||||
|
||||
let configContent: string | null = null;
|
||||
let usedPath: string = "";
|
||||
|
||||
// 尝试从多个路径加载配置文件
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
console.log(`🔍 Trying to load config from: ${configPath}`);
|
||||
configContent = await Deno.readTextFile(configPath);
|
||||
usedPath = configPath;
|
||||
console.log(`✅ Successfully loaded config from: ${configPath}`);
|
||||
break;
|
||||
} catch (error) {
|
||||
console.log(`❌ Failed to load config from ${configPath}: ${(error as Error).message}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果所有路径都失败,创建默认配置
|
||||
if (!configContent) {
|
||||
console.log("⚠️ No config file found, creating default configuration...");
|
||||
const defaultConfig = {
|
||||
mqtt_broker: {
|
||||
host: "localhost",
|
||||
port: "1883",
|
||||
vda_interface: "uagv"
|
||||
},
|
||||
vehicle: {
|
||||
serial_number: "AGV",
|
||||
channel_name: "AGV_Channel",
|
||||
channel_manufacturer: "Default",
|
||||
manufacturer: "Default Manufacturer",
|
||||
vda_version: "2.0.0",
|
||||
vda_full_version: "2.0.0"
|
||||
},
|
||||
settings: {
|
||||
map_id: "default_map",
|
||||
state_frequency: 1000,
|
||||
visualization_frequency: 1000,
|
||||
action_time: 5000,
|
||||
robot_count: 1,
|
||||
speed: 1.0
|
||||
}
|
||||
};
|
||||
|
||||
// 尝试保存默认配置到当前目录
|
||||
try {
|
||||
const defaultConfigJson = JSON.stringify(defaultConfig, null, 2);
|
||||
await Deno.writeTextFile("./simulator_config.json", defaultConfigJson);
|
||||
console.log("📝 Created default config file: ./simulator_config.json");
|
||||
configContent = defaultConfigJson;
|
||||
usedPath = "./simulator_config.json";
|
||||
} catch (error) {
|
||||
console.log("⚠️ Could not save default config file, using in-memory configuration");
|
||||
configContent = JSON.stringify(defaultConfig);
|
||||
usedPath = "default (in-memory)";
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📋 Using configuration from: ${usedPath}`);
|
||||
|
||||
const raw = JSON.parse(configContent);
|
||||
return {
|
||||
mqttBroker: {
|
||||
host: raw.mqtt_broker.host,
|
||||
port: parseInt(raw.mqtt_broker.port, 10),
|
||||
vdaInterface: raw.mqtt_broker.vda_interface,
|
||||
},
|
||||
vehicle: {
|
||||
serialNumber: raw.vehicle.serial_number,
|
||||
channelName: raw.vehicle.channel_name,
|
||||
channelManufacturer: raw.vehicle.channel_manufacturer,
|
||||
manufacturer: raw.vehicle.manufacturer,
|
||||
vdaVersion: raw.vehicle.vda_version,
|
||||
vdaFullVersion: raw.vehicle.vda_full_version,
|
||||
},
|
||||
settings: {
|
||||
mapId: raw.settings.map_id,
|
||||
stateFrequency: raw.settings.state_frequency,
|
||||
visualizationFrequency: raw.settings.visualization_frequency,
|
||||
actionTime: raw.settings.action_time,
|
||||
robotCount: raw.settings.robot_count,
|
||||
speed: raw.settings.speed,
|
||||
},
|
||||
};
|
||||
}
|
1303
simulator_main.ts
Normal file
1303
simulator_main.ts
Normal file
File diff suppressed because it is too large
Load Diff
72
test_events.ts
Normal file
72
test_events.ts
Normal file
@ -0,0 +1,72 @@
|
||||
// test_events.ts
|
||||
// 测试全局事件系统
|
||||
|
||||
import { globalEventManager } from "./event_manager.ts";
|
||||
|
||||
// 测试主线程事件监听
|
||||
globalEventManager.addEventListener("test-event", (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
console.log("🧪 主线程收到测试事件:", customEvent.detail);
|
||||
});
|
||||
|
||||
globalEventManager.addEventListener("agv-worker-ready", (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
console.log("✅ AGV Worker 已就绪:", customEvent.detail);
|
||||
});
|
||||
|
||||
// 创建测试 Worker
|
||||
const testWorker = new Worker(
|
||||
new URL("./test_worker.ts", import.meta.url).href,
|
||||
{ type: "module" }
|
||||
);
|
||||
|
||||
// 注册测试 Worker
|
||||
globalEventManager.registerWorker("testWorker", testWorker);
|
||||
|
||||
// 延迟发送一些测试事件
|
||||
setTimeout(() => {
|
||||
console.log("🚀 开始事件测试...");
|
||||
|
||||
// 发送主线程事件
|
||||
globalEventManager.dispatchGlobalEvent({
|
||||
type: "test-event",
|
||||
source: "main",
|
||||
data: { message: "Hello from main thread!", timestamp: Date.now() }
|
||||
});
|
||||
|
||||
// 发送给特定 Worker 的事件
|
||||
globalEventManager.dispatchGlobalEvent({
|
||||
type: "ping",
|
||||
source: "main",
|
||||
target: "testWorker",
|
||||
data: { message: "Ping to test worker!" }
|
||||
});
|
||||
|
||||
}, 2000);
|
||||
|
||||
// 定期显示事件日志
|
||||
setInterval(() => {
|
||||
const recentEvents = globalEventManager.getRecentEvents(5);
|
||||
if (recentEvents.length > 0) {
|
||||
console.log("\n📊 最近的事件:");
|
||||
recentEvents.forEach(event => {
|
||||
console.log(` ${event.type} (${event.source} -> ${event.target || '广播'}) [${new Date(event.timestamp!).toLocaleTimeString()}]`);
|
||||
});
|
||||
console.log(`📝 已注册的 Workers: ${globalEventManager.getRegisteredWorkers().join(', ')}\n`);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// 处理 Ctrl+C 退出
|
||||
Deno.addSignalListener("SIGINT", () => {
|
||||
console.log("\n📋 最终事件日志:");
|
||||
const allEvents = globalEventManager.getEventLog();
|
||||
allEvents.forEach(event => {
|
||||
console.log(` ${event.type} (${event.source} -> ${event.target || '广播'}) [${new Date(event.timestamp!).toLocaleTimeString()}]`);
|
||||
});
|
||||
|
||||
testWorker.terminate();
|
||||
console.log("🔚 测试结束");
|
||||
Deno.exit(0);
|
||||
});
|
||||
|
||||
console.log("🎯 事件系统测试已启动,按 Ctrl+C 退出");
|
55
test_worker.ts
Normal file
55
test_worker.ts
Normal file
@ -0,0 +1,55 @@
|
||||
// test_worker.ts
|
||||
// 测试 Worker
|
||||
|
||||
import { createWorkerEventHelper } from "./worker_event_helper.ts";
|
||||
|
||||
// 创建事件助手
|
||||
const eventHelper = createWorkerEventHelper("testWorker");
|
||||
|
||||
console.log("🧪 测试 Worker 已启动");
|
||||
|
||||
// 添加事件监听器
|
||||
eventHelper.addEventListener("ping", (event) => {
|
||||
console.log("🏓 Test Worker 收到 ping:", event.data);
|
||||
|
||||
// 回复 pong
|
||||
eventHelper.dispatchEvent("pong", {
|
||||
message: "Pong from test worker!",
|
||||
originalPing: event.data.message,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
eventHelper.addEventListener("test-event", (event) => {
|
||||
console.log("🧪 Test Worker 收到测试事件:", event.data);
|
||||
});
|
||||
|
||||
// 定期发送心跳事件
|
||||
let heartbeatCount = 0;
|
||||
setInterval(() => {
|
||||
heartbeatCount++;
|
||||
eventHelper.dispatchEvent("worker-heartbeat", {
|
||||
workerName: "testWorker",
|
||||
count: heartbeatCount,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
// 延迟发送测试事件
|
||||
setTimeout(() => {
|
||||
eventHelper.dispatchEvent("test-event", {
|
||||
message: "Hello from test worker!",
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// 监听关闭信号
|
||||
self.addEventListener("message", (event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
if (message.type === "shutdown") {
|
||||
console.log("🔚 Test Worker 收到关闭信号");
|
||||
self.close();
|
||||
}
|
||||
});
|
||||
|
||||
console.log("✅ 测试 Worker 事件系统已初始化");
|
428
vda5050_transformer_worker.ts
Normal file
428
vda5050_transformer_worker.ts
Normal file
@ -0,0 +1,428 @@
|
||||
import mqtt, { MqttClient, IClientOptions } from "npm:mqtt";
|
||||
|
||||
// ==== 消息/配置类型 ====
|
||||
|
||||
/** init 消息接口,从主线程传入 */
|
||||
interface InitMessage {
|
||||
type: "init";
|
||||
mqtt: {
|
||||
brokerUrl: string;
|
||||
clientId: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
reconnectInterval?: number;
|
||||
qos?: 0 | 1 | 2;
|
||||
};
|
||||
/**
|
||||
* 这个数组里每项配置一对 topic 和对应的映射规则
|
||||
*/
|
||||
mappings: Array<{
|
||||
/** MQTT 订阅的源 topic,可以包含 + 或 # 通配符 */
|
||||
sourceTopic: string;
|
||||
/** 发布时使用的目标 topic 模板,可包含 {agvId} 等占位符 */
|
||||
targetTopicTemplate: string;
|
||||
/** transformGeneric 用到的映射规则 */
|
||||
mapping: Record<string, MappingRule>;
|
||||
}>;
|
||||
instanceId: string;
|
||||
}
|
||||
|
||||
/** 停止 Worker 的消息 */
|
||||
interface StopMessage {
|
||||
type: "stop" | "shutdown"; // 支持 stop 和 shutdown
|
||||
}
|
||||
|
||||
// transformGeneric 中用到的规则
|
||||
type MappingRule =
|
||||
| string // 简单字符串映射,直接从源路径复制
|
||||
| {
|
||||
op: "const";
|
||||
value: any;
|
||||
source?: string;
|
||||
}
|
||||
| {
|
||||
type: "object";
|
||||
op?: "object";
|
||||
source?: string;
|
||||
mapping: Record<string, MappingRule>;
|
||||
}
|
||||
| {
|
||||
type: "array";
|
||||
op?: "array";
|
||||
source: string;
|
||||
mapping: Record<string, MappingRule>;
|
||||
}
|
||||
| {
|
||||
op: "toString";
|
||||
source: string;
|
||||
}
|
||||
| {
|
||||
// 通用对象映射(没有 type 字段)
|
||||
mapping: Record<string, MappingRule>;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
// ==== 通用工具函数 ====
|
||||
|
||||
/** 按路径 a.b.c 从 obj 取值 */
|
||||
function getByPath(obj: any, path: string): any {
|
||||
return path.split(".").reduce((o, key) => (o != null ? o[key] : undefined), obj);
|
||||
}
|
||||
|
||||
/** 按路径 a.b.c 在 target 上写入 value */
|
||||
function setByPath(target: any, path: string, value: any) {
|
||||
const keys = path.split(".");
|
||||
let cur = target;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const k = keys[i];
|
||||
if (!(k in cur) || typeof cur[k] !== "object") {
|
||||
cur[k] = {};
|
||||
}
|
||||
cur = cur[k];
|
||||
}
|
||||
cur[keys[keys.length - 1]] = value;
|
||||
}
|
||||
|
||||
/** 通用的 JSON 转换器 */
|
||||
function transformGeneric(raw: any, mapping: Record<string, MappingRule>): any {
|
||||
const out: any = {};
|
||||
|
||||
for (const [outKey, rule] of Object.entries(mapping)) {
|
||||
try {
|
||||
// 处理简单字符串映射
|
||||
if (typeof rule === "string") {
|
||||
const value = getByPath(raw, rule);
|
||||
if (value !== undefined) {
|
||||
setByPath(out, outKey, value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理对象规则
|
||||
if (typeof rule === "object" && rule !== null) {
|
||||
// 常量值映射
|
||||
if ("op" in rule && rule.op === "const") {
|
||||
setByPath(out, outKey, rule.value);
|
||||
continue;
|
||||
}
|
||||
|
||||
// toString 操作
|
||||
if ("op" in rule && rule.op === "toString") {
|
||||
const sourceValue = getByPath(raw, rule.source);
|
||||
if (sourceValue !== undefined) {
|
||||
// 如果已经是字符串,直接使用;否则进行 JSON 序列化
|
||||
const stringValue = typeof sourceValue === "string"
|
||||
? sourceValue
|
||||
: JSON.stringify(sourceValue);
|
||||
setByPath(out, outKey, stringValue);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 对象映射
|
||||
if ("type" in rule && rule.type === "object") {
|
||||
const subRaw = rule.source ? getByPath(raw, rule.source) : raw;
|
||||
if (subRaw !== undefined) {
|
||||
const transformedObj = transformGeneric(subRaw, rule.mapping);
|
||||
setByPath(out, outKey, transformedObj);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 数组映射
|
||||
if ("type" in rule && rule.type === "array") {
|
||||
const sourceArray = getByPath(raw, rule.source);
|
||||
if (Array.isArray(sourceArray)) {
|
||||
const transformedArray = sourceArray.map(item =>
|
||||
transformGeneric(item, rule.mapping)
|
||||
);
|
||||
setByPath(out, outKey, transformedArray);
|
||||
} else {
|
||||
setByPath(out, outKey, []);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果是对象但没有特殊操作,尝试作为嵌套映射处理
|
||||
if ("mapping" in rule && typeof rule.mapping === "object") {
|
||||
const mappingRule = rule as { mapping: Record<string, MappingRule>; source?: string };
|
||||
const subRaw = mappingRule.source ? getByPath(raw, mappingRule.source) : raw;
|
||||
if (subRaw !== undefined) {
|
||||
const transformedObj = transformGeneric(subRaw, mappingRule.mapping);
|
||||
setByPath(out, outKey, transformedObj);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`Unknown mapping rule for key "${outKey}":`, rule);
|
||||
} catch (error) {
|
||||
console.error(`Error processing mapping rule for key "${outKey}":`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一个 topic 是否匹配源 topic 模式(含 +, #)
|
||||
*/
|
||||
function topicMatches(pattern: string, topic: string): boolean {
|
||||
const patSeg = pattern.split("/");
|
||||
const topSeg = topic.split("/");
|
||||
for (let i = 0; i < patSeg.length; i++) {
|
||||
const p = patSeg[i];
|
||||
const t = topSeg[i];
|
||||
if (p === "#") return true; // 后面的全部都匹配
|
||||
if (p === "+") continue; // 单层通配符
|
||||
if (t === undefined) return false;
|
||||
if (p !== t) return false;
|
||||
}
|
||||
return patSeg.length === topSeg.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 topic 根据 pattern 抽取 '+' 通配符对应的位置上的参数
|
||||
* 目前只支持单个 {agvId} 占位
|
||||
*/
|
||||
function extractAgvId(pattern: string, topic: string): string | null {
|
||||
const patSeg = pattern.split("/");
|
||||
const topSeg = topic.split("/");
|
||||
for (let i = 0; i < patSeg.length; i++) {
|
||||
if (patSeg[i] === "+") {
|
||||
return topSeg[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==== 全局状态 ====
|
||||
|
||||
let mqttClient: MqttClient | null = null;
|
||||
let reconnectTimer: any = null;
|
||||
const CONFIG: {
|
||||
mqtt: {
|
||||
brokerUrl: string;
|
||||
clientId: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
reconnectInterval?: number;
|
||||
qos?: 0 | 1 | 2;
|
||||
};
|
||||
mappings: Array<{
|
||||
sourceTopic: string;
|
||||
targetTopicTemplate: string;
|
||||
mapping: Record<string, MappingRule>;
|
||||
}>;
|
||||
} = {
|
||||
mqtt: {
|
||||
brokerUrl: "",
|
||||
clientId: "",
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
reconnectInterval: 5000,
|
||||
qos: 1,
|
||||
},
|
||||
mappings: [],
|
||||
};
|
||||
|
||||
// instance identifier generated from config.manufacturer and UUID
|
||||
let INSTANCE_ID: string = "";
|
||||
|
||||
// 连续 MQTT 错误计数,只有超过10次才触发重连
|
||||
let consecutiveErrors = 0;
|
||||
|
||||
// ==== MQTT 逻辑 ====
|
||||
|
||||
async function connectAndSubscribe() {
|
||||
try {
|
||||
const opts: IClientOptions = {
|
||||
clientId: CONFIG.mqtt.clientId,
|
||||
username: CONFIG.mqtt.username,
|
||||
password: CONFIG.mqtt.password,
|
||||
reconnectPeriod: 3000, // 我们自己重连
|
||||
};
|
||||
mqttClient = mqtt.connect(CONFIG.mqtt.brokerUrl, opts);
|
||||
|
||||
// 预先注册错误、断开和关闭处理器
|
||||
mqttClient.on("error", onMqttError);
|
||||
mqttClient.on("disconnect", onMqttDisconnect);
|
||||
mqttClient.on("close", onMqttClose);
|
||||
|
||||
mqttClient.on("connect", async () => {
|
||||
// 连接成功后重置错误计数
|
||||
consecutiveErrors = 0;
|
||||
// 订阅所有 mappings 里配置的源 topic
|
||||
for (const m of CONFIG.mappings) {
|
||||
const inTopic = m.sourceTopic
|
||||
.replace("{instanceId}", INSTANCE_ID);
|
||||
// console.log("inTopic", inTopic);
|
||||
try {
|
||||
await mqttClient!.subscribe(inTopic, { qos: CONFIG.mqtt.qos! });
|
||||
} catch (err) {
|
||||
console.error(`✗ MQTT subscribe failed for ${inTopic}:`, err);
|
||||
self.postMessage({ type: "error", error: "subscribe_failed", details: (err as Error).message });
|
||||
scheduleReconnect();
|
||||
return; // 订阅失败,中断后续处理,尝试重连
|
||||
}
|
||||
}
|
||||
// 订阅完成后监听消息
|
||||
mqttClient!.on("message", onMqttMessage);
|
||||
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
self.postMessage({ type: "status", status: "connected" });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("✗ MQTT connect failed:", err);
|
||||
self.postMessage({
|
||||
type: "error",
|
||||
error: "connect_failed",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async function onMqttMessage(topic: string, payload: Uint8Array) {
|
||||
try {
|
||||
const raw = JSON.parse(new TextDecoder().decode(payload));
|
||||
|
||||
// 对每个匹配的 mappingConfig,做一次转换并发布
|
||||
for (const m of CONFIG.mappings) {
|
||||
const inTopic = m.sourceTopic
|
||||
.replace("{instanceId}", INSTANCE_ID);
|
||||
if (!topicMatches(inTopic, topic)) continue;
|
||||
|
||||
// 提取占位符 agvId
|
||||
const agvId = extractAgvId(inTopic, topic) ?? "";
|
||||
|
||||
// 转换
|
||||
const transformed = transformGeneric(raw, m.mapping);
|
||||
|
||||
// 填充目标 topic
|
||||
const outTopic = m.targetTopicTemplate
|
||||
.replace("{instanceId}", INSTANCE_ID)
|
||||
.replace("{agvId}", agvId);
|
||||
|
||||
// 发布
|
||||
// console.log("outTopic", outTopic);
|
||||
await mqttClient!.publish(outTopic, JSON.stringify(transformed), {
|
||||
qos: CONFIG.mqtt.qos!,
|
||||
});
|
||||
|
||||
self.postMessage({
|
||||
type: "published",
|
||||
topic: outTopic,
|
||||
agvId,
|
||||
sourceTopic: topic,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("✗ onMqttMessage error:", err);
|
||||
self.postMessage({
|
||||
type: "error",
|
||||
error: "process_message",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onMqttClose() {
|
||||
console.log("vda onMqttClose");
|
||||
self.postMessage({ type: "status", status: "closed" });
|
||||
// 确保在 close 时释放底层 socket
|
||||
if (mqttClient) {
|
||||
mqttClient.end(true);
|
||||
mqttClient = null;
|
||||
}
|
||||
self.postMessage({ type: "reconnect-down" });
|
||||
// connectAndSubscribe();
|
||||
}
|
||||
|
||||
function onMqttDisconnect() {
|
||||
self.postMessage({ type: "status", status: "disconnected" });
|
||||
console.log("vda onMqttDisconnect");
|
||||
// 断开时也关闭客户端,避免累积未关闭的连接
|
||||
if (mqttClient) {
|
||||
mqttClient.end(true);
|
||||
mqttClient = null;
|
||||
}
|
||||
scheduleReconnect();
|
||||
}
|
||||
|
||||
function onMqttError(err: Error) {
|
||||
// 如果是连接被拒绝错误,通知主线程重启所有 worker
|
||||
const anyErr = err as any;
|
||||
if (anyErr.code === 'ECONNREFUSED' || anyErr.code === 'ECONNRESET') {
|
||||
console.error('❌ MQTT connection refused, requesting full restart:', anyErr);
|
||||
self.postMessage({ type: 'reconnect-all' });
|
||||
}
|
||||
// 增加错误计数,只在超过10次后才重连
|
||||
consecutiveErrors++;
|
||||
console.error(`! MQTT error (#${consecutiveErrors}):`, err);
|
||||
self.postMessage({
|
||||
type: "error",
|
||||
error: "mqtt_error",
|
||||
details: err.message,
|
||||
});
|
||||
if (consecutiveErrors >= 5) {
|
||||
// 重置错误计数
|
||||
consecutiveErrors = 0;
|
||||
if (mqttClient) {
|
||||
mqttClient.end(true);
|
||||
mqttClient = null;
|
||||
}
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (!reconnectTimer) {
|
||||
const t = CONFIG.mqtt.reconnectInterval;
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
console.log("vda schedule Worker 自身重连");
|
||||
self.postMessage({ type: "reconnect-down" });
|
||||
connectAndSubscribe();
|
||||
}, t);
|
||||
}
|
||||
}
|
||||
|
||||
// ==== 主线程消息处理 ====
|
||||
|
||||
self.onmessage = (e: MessageEvent<InitMessage | StopMessage>) => {
|
||||
const msg = e.data;
|
||||
if (msg.type === "init") {
|
||||
// 覆盖配置
|
||||
CONFIG.mqtt = {
|
||||
...CONFIG.mqtt,
|
||||
brokerUrl: msg.mqtt.brokerUrl,
|
||||
clientId: msg.mqtt.clientId,
|
||||
username: msg.mqtt.username,
|
||||
password: msg.mqtt.password,
|
||||
reconnectInterval: msg.mqtt.reconnectInterval ?? CONFIG.mqtt.reconnectInterval,
|
||||
qos: msg.mqtt.qos ?? CONFIG.mqtt.qos,
|
||||
};
|
||||
CONFIG.mappings = msg.mappings;
|
||||
// set our instance identifier
|
||||
INSTANCE_ID = msg.instanceId;
|
||||
console.log("vda5050_transformer_worker: instanceId", INSTANCE_ID);
|
||||
// 启动连接
|
||||
connectAndSubscribe();
|
||||
} else if (msg.type === "stop" || msg.type === "shutdown") {
|
||||
// 支持 stop 和 shutdown,统一关闭
|
||||
if (mqttClient) {
|
||||
mqttClient.end(true);
|
||||
mqttClient = null;
|
||||
}
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
self.postMessage({ type: "status", status: "stopped" });
|
||||
}
|
||||
};
|
837
vda_worker.ts
Normal file
837
vda_worker.ts
Normal file
@ -0,0 +1,837 @@
|
||||
/// <reference lib="deno.worker" />
|
||||
|
||||
import {
|
||||
MasterController,
|
||||
AgvId,
|
||||
ClientOptions,
|
||||
Topic,
|
||||
Order,
|
||||
State,
|
||||
Headerless,
|
||||
InstantActions,
|
||||
BlockingType,
|
||||
Connection,
|
||||
Factsheet,
|
||||
} from "vda-5050-lib";
|
||||
import { v4 as uuidv4 } from "npm:uuid";
|
||||
import { createWorkerEventHelper } from "./worker_event_helper.ts";
|
||||
|
||||
// 创建事件助手
|
||||
const eventHelper = createWorkerEventHelper("vdaWorker");
|
||||
|
||||
console.log("VDA 5050 Worker initialized");
|
||||
|
||||
// 预注册设备列表(后续主程序可动态更新)
|
||||
const preRegisteredDevices: AgvId[] = [
|
||||
];
|
||||
|
||||
// 用于保存所有设备状态及动态更新设备列表
|
||||
const currentDevices: Map<
|
||||
string,
|
||||
{ agvId: AgvId; lastSeen: number; isOnline: boolean; state?: State }
|
||||
> = new Map();
|
||||
|
||||
// 内部方法:生成设备唯一 key
|
||||
function getDeviceKey(agvId: AgvId) {
|
||||
return `${agvId.manufacturer}-${agvId.serialNumber}`;
|
||||
}
|
||||
|
||||
// 先将预注册设备保存到 currentDevices(初始状态为离线)
|
||||
preRegisteredDevices.forEach((device) => {
|
||||
const key = getDeviceKey(device);
|
||||
currentDevices.set(key, {
|
||||
agvId: device,
|
||||
lastSeen: 0,
|
||||
isOnline: false,
|
||||
});
|
||||
});
|
||||
|
||||
// 用于保存已经订阅状态的设备,避免重复订阅
|
||||
const subscribedDevices: Set<string> = new Set();
|
||||
|
||||
// 全局保存 MasterController 实例
|
||||
let masterController: MasterController | null = null;
|
||||
|
||||
// 从初始化参数读取的接口名和制造商
|
||||
let interfaceNameValue = "oagv";
|
||||
let manufacturerValue = "gateway";
|
||||
// 标记 Master Controller 是否启动完成
|
||||
let clientStarted = false;
|
||||
|
||||
/**
|
||||
* 当收到 init 消息后,从启动参数中提取 MQTT broker 地址,从而构造客户端选项并启动控制器
|
||||
*/
|
||||
function initializeControllerWithOptions(brokerUrl: string, iValue: string) {
|
||||
// Validate input parameters
|
||||
if (!brokerUrl || !iValue) {
|
||||
console.error("❌ Invalid initialization parameters:");
|
||||
console.error("brokerUrl:", brokerUrl);
|
||||
console.error("interfaceName:", iValue);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset previous state
|
||||
clientStarted = false;
|
||||
if (masterController) {
|
||||
try {
|
||||
masterController.stop().catch(() => {});
|
||||
} catch (e) {
|
||||
// Ignore stop errors
|
||||
}
|
||||
masterController = null;
|
||||
}
|
||||
|
||||
const clientOptions: ClientOptions = {
|
||||
interfaceName: iValue,
|
||||
transport: {
|
||||
// 使用启动参数传入的 MQTT server 地址
|
||||
brokerUrl: brokerUrl,
|
||||
heartbeat:5,
|
||||
reconnectPeriod:1000,
|
||||
connectTimeout:5000,
|
||||
},
|
||||
vdaVersion: "2.0.0",
|
||||
};
|
||||
|
||||
console.log(`🚀 正在初始化VDA 5050 Master Controller...`);
|
||||
console.log(`📡 MQTT Broker: ${brokerUrl}`);
|
||||
console.log(`🏷️ Interface: ${iValue}`);
|
||||
console.log(`🏭 Manufacturer: ${manufacturerValue}`);
|
||||
|
||||
// Test MQTT broker connectivity
|
||||
console.log(`🔍 Testing MQTT broker connectivity...`);
|
||||
try {
|
||||
const url = new URL(brokerUrl);
|
||||
console.log(`📍 Broker host: ${url.hostname}, port: ${url.port || 1883}`);
|
||||
} catch (urlError) {
|
||||
console.error("❌ Invalid broker URL format:", urlError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
masterController = new MasterController(clientOptions, {});
|
||||
console.log("📦 MasterController instance created successfully");
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to create MasterController instance:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add timeout to prevent hanging
|
||||
Promise.race([
|
||||
masterController.start(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Master controller start timeout after 30 seconds")), 30000)
|
||||
)
|
||||
])
|
||||
.then(() => {
|
||||
clientStarted = true;
|
||||
console.log("✅ VDA 5050 master controller started successfully");
|
||||
self.postMessage({ type: "started" });
|
||||
|
||||
// 跟踪所有AGV连接状态
|
||||
masterController!.trackAgvs((trackAgvId, connectionState, timestamp) => {
|
||||
const key = getDeviceKey(trackAgvId);
|
||||
// 如果设备不在设备列表中,则不予上线
|
||||
// console.log("->", key, connectionState);
|
||||
// if (!currentDevices.has(key)) {
|
||||
// console.warn(
|
||||
// `收到未知设备 ${trackAgvId.manufacturer}/${trackAgvId.serialNumber} 的状态消息,忽略上线。`
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
const ts = Number(timestamp);
|
||||
const lastSeen = isNaN(ts) ? Date.now() : ts;
|
||||
// 自动添加新设备到currentDevices,无需预先配置
|
||||
let record = currentDevices.get(key);
|
||||
if (!record) {
|
||||
// 动态添加新发现的设备
|
||||
record = { agvId: trackAgvId, lastSeen: 0, isOnline: false };
|
||||
currentDevices.set(key, record);
|
||||
console.log(`🆕 自动添加新设备: ${trackAgvId.manufacturer}/${trackAgvId.serialNumber}`);
|
||||
}
|
||||
const wasOffline = !record.isOnline;
|
||||
// 无条件置为 ONLINE 并更新最后更新时间
|
||||
currentDevices.set(key, { ...record, lastSeen, isOnline: true });
|
||||
// 发送上线状态更新信息
|
||||
self.postMessage({
|
||||
type: "connectionState",
|
||||
data: { agvId: trackAgvId, state: "ONLINE", timestamp: lastSeen },
|
||||
});
|
||||
if (wasOffline) {
|
||||
// console.log(
|
||||
// `设备 ${trackAgvId.manufacturer}/${trackAgvId.serialNumber} 新上线`
|
||||
// );
|
||||
// 额外通知一次"新上线"
|
||||
self.postMessage({
|
||||
type: "connectionState",
|
||||
data: { agvId: trackAgvId, state: "ONLINE", timestamp: lastSeen },
|
||||
});
|
||||
}
|
||||
// 若设备首次出现,则订阅其状态更新
|
||||
if (!subscribedDevices.has(key)) {
|
||||
subscribedDevices.add(key);
|
||||
try {
|
||||
// 使用 trackAgvId 本身作为 AgvId 订阅
|
||||
masterController!.subscribe(
|
||||
Topic.State,
|
||||
{ manufacturer: manufacturerValue, serialNumber: trackAgvId.serialNumber },
|
||||
(state: State) => {
|
||||
const subKey = getDeviceKey(trackAgvId);
|
||||
// 自动添加新设备,无需预先配置
|
||||
let existing = currentDevices.get(subKey);
|
||||
if (!existing) {
|
||||
existing = { agvId: trackAgvId, lastSeen: 0, isOnline: false };
|
||||
currentDevices.set(subKey, existing);
|
||||
console.log(`🆕 状态订阅中自动添加新设备: ${trackAgvId.manufacturer}/${trackAgvId.serialNumber}`);
|
||||
}
|
||||
const wasOfflineInSub = !existing.isOnline;
|
||||
currentDevices.set(subKey, {
|
||||
...existing,
|
||||
lastSeen: Date.now(),
|
||||
state,
|
||||
isOnline: true,
|
||||
});
|
||||
if (wasOfflineInSub) {
|
||||
// console.log(
|
||||
// `设备 ${trackAgvId.manufacturer}/${trackAgvId.serialNumber} 新上线(订阅)`
|
||||
// );
|
||||
self.postMessage({
|
||||
type: "connectionState",
|
||||
data: {
|
||||
agvId: trackAgvId,
|
||||
state: "ONLINE",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
self.postMessage({
|
||||
type: "stateUpdate",
|
||||
data: {
|
||||
agvId: trackAgvId,
|
||||
state: state,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
// Subscribe to Factsheet topic
|
||||
masterController!.subscribe(
|
||||
Topic.Factsheet,
|
||||
{ manufacturer: manufacturerValue, serialNumber: trackAgvId.serialNumber },
|
||||
(factsheet: Factsheet) => {
|
||||
// console.log("收到 factsheet 消息", factsheet);
|
||||
self.postMessage({
|
||||
type: "factsheet",
|
||||
data: { agvId: trackAgvId, factsheet, timestamp: Date.now() },
|
||||
});
|
||||
}
|
||||
);
|
||||
// Subscribe to Connection topic
|
||||
masterController!.subscribe(
|
||||
Topic.Connection,
|
||||
{ manufacturer: manufacturerValue, serialNumber: trackAgvId.serialNumber },
|
||||
(connection: Connection) => {
|
||||
self.postMessage({
|
||||
type: "deviceDiscovered",
|
||||
data: { agvId: trackAgvId, connection, timestamp: Date.now() },
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to subscribe to tracked device ${trackAgvId.manufacturer}/${trackAgvId.serialNumber}:`, error);
|
||||
// 移除已添加的订阅标记,以便重试
|
||||
subscribedDevices.delete(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 定时检测设备状态,超时则标记为离线
|
||||
const stateUpdateCycle = 5000;
|
||||
const offlineThreshold = stateUpdateCycle * 3;
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
currentDevices.forEach((device, key) => {
|
||||
// console.log(
|
||||
// `设备 ${device.agvId.manufacturer}/${device.agvId.serialNumber} - lastSeen: ${device.lastSeen}, isOnline: ${device.isOnline}`
|
||||
// );
|
||||
if (now - device.lastSeen > offlineThreshold && device.isOnline) {
|
||||
device.isOnline = false;
|
||||
currentDevices.set(key, device);
|
||||
// console.log(
|
||||
// `设备 ${device.agvId.manufacturer}/${device.agvId.serialNumber} 超过 ${offlineThreshold} 毫秒未更新,标记为下线`
|
||||
// );
|
||||
}
|
||||
self.postMessage({
|
||||
type: "connectionState",
|
||||
data: { agvId: device.agvId, state: device.isOnline ? "ONLINE" : "OFFLINE", timestamp: now },
|
||||
});
|
||||
});
|
||||
}, stateUpdateCycle);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("❌ Failed to start VDA 5050 master controller:");
|
||||
console.error("Error details:", error);
|
||||
console.error("Error message:", error?.message || "Unknown error");
|
||||
console.error("Error stack:", error?.stack || "No stack trace");
|
||||
|
||||
// Reset client state
|
||||
clientStarted = false;
|
||||
masterController = null;
|
||||
|
||||
// Schedule retry after delay
|
||||
console.log("🔄 Scheduling retry in 10 seconds...");
|
||||
setTimeout(() => {
|
||||
console.log("🔄 Retrying VDA 5050 master controller initialization...");
|
||||
initializeControllerWithOptions(brokerUrl, iValue);
|
||||
eventHelper.dispatchEvent("reconnect-all", {
|
||||
reason: "grpc-stream-failed",
|
||||
retryCount: 5,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
self.postMessage({ type: "reconnect-all" });
|
||||
}, 10000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:
|
||||
* 当客户端尚未启动时,延迟一段时间后重试订阅
|
||||
*/
|
||||
function subscribeWithRetry(device: AgvId, retryCount = 0) {
|
||||
const key = getDeviceKey(device);
|
||||
// console.log("订阅设备->", key);
|
||||
// 自动添加新设备,无需预先配置
|
||||
if (!currentDevices.has(key)) {
|
||||
currentDevices.set(key, { agvId: device, lastSeen: 0, isOnline: false });
|
||||
console.log(`🆕 订阅时自动添加新设备: ${device.manufacturer}/${device.serialNumber}`);
|
||||
}
|
||||
if (!clientStarted) {
|
||||
if (retryCount < 50000) {
|
||||
// console.warn("Client not started, retry subscribing after delay...", device);
|
||||
setTimeout(() => {
|
||||
subscribeWithRetry(device, retryCount + 1);
|
||||
}, 3000);
|
||||
} else {
|
||||
console.error("订阅失败:超过最大重试次数", device);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!subscribedDevices.has(key)) {
|
||||
subscribedDevices.add(key);
|
||||
try {
|
||||
// 双重检查客户端状态
|
||||
if (!clientStarted || !masterController) {
|
||||
subscribedDevices.delete(key);
|
||||
setTimeout(() => {
|
||||
subscribeWithRetry(device, retryCount + 1);
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
// Subscribe to State topic
|
||||
masterController!.subscribe(
|
||||
Topic.State,
|
||||
{ manufacturer: manufacturerValue, serialNumber: device.serialNumber },
|
||||
(state: State) => {
|
||||
const subKey = getDeviceKey(device);
|
||||
// 自动添加新设备,无需预先配置
|
||||
let existing = currentDevices.get(subKey);
|
||||
if (!existing) {
|
||||
existing = { agvId: device, lastSeen: 0, isOnline: false };
|
||||
currentDevices.set(subKey, existing);
|
||||
console.log(`🆕 状态更新中自动添加新设备: ${device.manufacturer}/${device.serialNumber}`);
|
||||
}
|
||||
const wasOfflineInSub = !existing.isOnline;
|
||||
currentDevices.set(subKey, {
|
||||
...existing,
|
||||
lastSeen: Date.now(),
|
||||
state,
|
||||
isOnline: true,
|
||||
});
|
||||
if (wasOfflineInSub) {
|
||||
self.postMessage({
|
||||
type: "connectionState",
|
||||
data: { agvId: device, state: "ONLINE", timestamp: Date.now() },
|
||||
});
|
||||
}
|
||||
self.postMessage({
|
||||
type: "stateUpdate",
|
||||
data: { agvId: device, state, timestamp: Date.now() },
|
||||
});
|
||||
}
|
||||
);
|
||||
// Subscribe to Factsheet topic
|
||||
masterController!.subscribe(
|
||||
Topic.Factsheet,
|
||||
{ manufacturer: manufacturerValue, serialNumber: device.serialNumber },
|
||||
(factsheet: Factsheet) => {
|
||||
// console.log("收到 factsheet 消息", factsheet);
|
||||
self.postMessage({
|
||||
type: "factsheet",
|
||||
data: { agvId: device, factsheet, timestamp: Date.now() },
|
||||
});
|
||||
}
|
||||
);
|
||||
// Subscribe to Connection topic
|
||||
masterController!.subscribe(
|
||||
Topic.Connection,
|
||||
{ manufacturer: manufacturerValue, serialNumber: device.serialNumber },
|
||||
(connection: Connection) => {
|
||||
self.postMessage({
|
||||
type: "deviceDiscovered",
|
||||
data: { agvId: device, connection, timestamp: Date.now() },
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to subscribe to device ${device.manufacturer}/${device.serialNumber}:`, error);
|
||||
// 移除已添加的订阅标记,以便重试
|
||||
subscribedDevices.delete(key);
|
||||
// 如果客户端未启动,重新调度重试
|
||||
if (!clientStarted) {
|
||||
setTimeout(() => {
|
||||
subscribeWithRetry(device, 0);
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理来自主线程的消息
|
||||
self.onmessage = async (event) => {
|
||||
const message = event.data;
|
||||
// console.log("Received message from main thread:", message);
|
||||
|
||||
// 如果收到 init 消息,从启动参数中传入 MQTT server 地址
|
||||
if (message.type === "init") {
|
||||
// 从主线程传入初始化参数
|
||||
// data: {
|
||||
// brokerUrl: config.mqtt.brokerUrl,
|
||||
// interfaceName: config.interfaceName,
|
||||
// manufacturer: config.manufacturer,
|
||||
// instanceId: config.instanceId
|
||||
// },
|
||||
console.log("event", message);
|
||||
const { brokerUrl, interfaceName, manufacturer, instanceId } = message.data;
|
||||
console.log(`init params → brokerUrl: ${brokerUrl}, interfaceName: ${interfaceName}, manufacturer: ${manufacturer}, instanceId: ${instanceId}`);
|
||||
manufacturerValue = instanceId;
|
||||
interfaceNameValue = interfaceName;
|
||||
initializeControllerWithOptions(brokerUrl, interfaceNameValue);
|
||||
}
|
||||
|
||||
// 处理单个设备移除
|
||||
if (message.type === "removeDevice") {
|
||||
const { manufacturer, serialNumber } = message.data;
|
||||
const deviceKey = getDeviceKey({ manufacturer, serialNumber });
|
||||
|
||||
if (currentDevices.has(deviceKey)) {
|
||||
// 移除设备
|
||||
currentDevices.delete(deviceKey);
|
||||
subscribedDevices.delete(deviceKey);
|
||||
|
||||
console.log(`🗑️ 已移除设备: ${manufacturer}/${serialNumber}`);
|
||||
|
||||
// 通知主线程设备已移除
|
||||
self.postMessage({
|
||||
type: "deviceRemoved",
|
||||
data: {
|
||||
manufacturer,
|
||||
serialNumber,
|
||||
deviceKey,
|
||||
remainingDevices: currentDevices.size
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log(`⚠️ 设备 ${manufacturer}/${serialNumber} 不存在,无法移除`);
|
||||
}
|
||||
}
|
||||
|
||||
// 动态更新设备列表(主程序从配置文件发送更新消息)
|
||||
if (message.type === "updateDeviceList") {
|
||||
const newDeviceList: AgvId[] = message.data;
|
||||
console.log(`🔄 VDA Worker 收到设备列表更新,包含 ${newDeviceList.length} 个设备`);
|
||||
|
||||
// 构造新设备的 Key 集合
|
||||
const newKeys = new Set(newDeviceList.map(getDeviceKey));
|
||||
let addedCount = 0;
|
||||
let updatedCount = 0;
|
||||
|
||||
// 遍历新设备列表,新增或更新记录,并进行状态订阅
|
||||
newDeviceList.forEach((device) => {
|
||||
const key = getDeviceKey(device);
|
||||
if (!currentDevices.has(key)) {
|
||||
currentDevices.set(key, { agvId: device, lastSeen: 0, isOnline: false });
|
||||
console.log(`➕ 新增设备: ${device.manufacturer}/${device.serialNumber}`);
|
||||
addedCount++;
|
||||
} else {
|
||||
// 更新 agvId 信息(如果有必要)
|
||||
const record = currentDevices.get(key)!;
|
||||
record.agvId = device;
|
||||
currentDevices.set(key, record);
|
||||
console.log(`🔄 更新设备: ${device.manufacturer}/${device.serialNumber}`);
|
||||
updatedCount++;
|
||||
}
|
||||
// 根据设备列表订阅对应的状态更新(重试订阅,直到客户端启动)
|
||||
subscribeWithRetry(device);
|
||||
});
|
||||
|
||||
// 对于 currentDevices 中存在但不在最新列表中的设备,置为离线
|
||||
let removedCount = 0;
|
||||
currentDevices.forEach((device, key) => {
|
||||
if (!newKeys.has(key)) {
|
||||
device.isOnline = false;
|
||||
currentDevices.set(key, device);
|
||||
console.log(`⏸️ 设备离线: ${device.agvId.manufacturer}/${device.agvId.serialNumber}`);
|
||||
removedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`✅ 设备列表更新完成: 新增 ${addedCount}, 更新 ${updatedCount}, 离线 ${removedCount}`);
|
||||
console.log(`📊 当前管理设备总数: ${currentDevices.size}`);
|
||||
|
||||
// 通知主线程最新设备列表情况
|
||||
self.postMessage({
|
||||
type: "deviceListUpdated",
|
||||
data: {
|
||||
total: currentDevices.size,
|
||||
added: addedCount,
|
||||
updated: updatedCount,
|
||||
removed: removedCount,
|
||||
devices: Array.from(currentDevices.values()).map(d => ({
|
||||
manufacturer: d.agvId.manufacturer,
|
||||
serialNumber: d.agvId.serialNumber,
|
||||
isOnline: d.isOnline
|
||||
}))
|
||||
}
|
||||
});
|
||||
}
|
||||
if (message.type === "orderForwarded") {
|
||||
// Check if client is started before processing orders
|
||||
if (!clientStarted || !masterController) {
|
||||
console.warn("❌ VDA client not started yet, ignoring order request");
|
||||
return;
|
||||
}
|
||||
|
||||
// console.log("收到 AGV 订单消息1", message);
|
||||
// 解构出 agvId 串号和 order 对象
|
||||
const { agvId: agvSerial, order: msg } = message.data as { agvId: string; order: any };
|
||||
// Rebuild order payload as Headerless<Order>
|
||||
const order: Headerless<Order> = {
|
||||
orderId: msg.orderId,
|
||||
orderUpdateId: msg.orderUpdateId || 0,
|
||||
nodes: msg.nodes.map((node: any, idx: number) => ({
|
||||
nodeId: node.nodeId,
|
||||
sequenceId: idx * 2,
|
||||
released: node.released ?? true,
|
||||
nodePosition: node.nodePosition,
|
||||
actions: node.actions || []
|
||||
})),
|
||||
edges: []
|
||||
};
|
||||
if (msg.nodes.length > 1) {
|
||||
for (let i = 0; i < msg.nodes.length - 1; i++) {
|
||||
order.edges.push({
|
||||
edgeId: msg.edges?.[i]?.edgeId || `edge${i}to${i + 1}`,
|
||||
sequenceId: i * 2 + 1,
|
||||
startNodeId: msg.nodes[i].nodeId,
|
||||
endNodeId: msg.nodes[i + 1].nodeId,
|
||||
released: msg.edges?.[i]?.released ?? true,
|
||||
actions: msg.edges?.[i]?.actions || []
|
||||
});
|
||||
}
|
||||
}
|
||||
let devId: any = undefined;
|
||||
// console.log("检查设备", currentDevices, msg.agvId);
|
||||
currentDevices.forEach((device, key) => {
|
||||
// console.log("检查设备", device, key, device.agvId.serialNumber, agvSerial);
|
||||
if (device.agvId.serialNumber === agvSerial) {
|
||||
devId = device.agvId;
|
||||
}
|
||||
});
|
||||
if (devId) {
|
||||
// console.log("收到 AGV 订单消息2", devId);
|
||||
try {
|
||||
// console.log("---->",{ manufacturer: manufacturerValue, serialNumber: devId.serialNumber }, order);
|
||||
await masterController!.assignOrder({ manufacturer: manufacturerValue, serialNumber: devId.serialNumber }, order, {
|
||||
onOrderProcessed: (err, canceled, active, ctx) => {
|
||||
if (err) {
|
||||
console.error("Order 被拒绝", err);
|
||||
} else if (canceled) {
|
||||
console.log("Order 被取消", ctx.order);
|
||||
} else if (active) {
|
||||
console.log("Order 正在执行", ctx.order);
|
||||
} else {
|
||||
console.log("Order 完成", ctx.order);
|
||||
}
|
||||
},
|
||||
onNodeTraversed: (node, nextEdge, nextNode, ctx) => {
|
||||
console.log("节点遍历完成", node);
|
||||
},
|
||||
onEdgeTraversing: (edge, start, end, stateChanges, count, ctx) => {
|
||||
console.log("开始路径", edge);
|
||||
},
|
||||
onEdgeTraversed: (edge, start, end, ctx) => {
|
||||
console.log("路径遍历完成", edge);
|
||||
},
|
||||
onActionStateChanged: (actionState, error) => {
|
||||
console.log("Action 状态变化", actionState, error || "");
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("assignOrder 异常", err);
|
||||
}
|
||||
} // end if(agvId)
|
||||
} // end if(message.type === "orderForwarded")
|
||||
// 2) 收到 InstantActions 转发
|
||||
if (message.type === "instantActionsForwarded") {
|
||||
// Check if client is started before processing instant actions
|
||||
if (!clientStarted || !masterController) {
|
||||
console.warn("❌ VDA client not started yet, ignoring instant actions request");
|
||||
return;
|
||||
}
|
||||
|
||||
// console.log("收到 AGV 即时动作消息", message);
|
||||
const msg: any = message.data;
|
||||
// const actions: InstantActions = msg.actions;
|
||||
const { agvId, actions } = msg as {
|
||||
agvId: string;
|
||||
actions: Array<{
|
||||
actionType: string; /* …其它可能的字段… */
|
||||
actionParameters: Array<{ key: string; value: string }>;
|
||||
actionDescription: string;
|
||||
actionId: string;
|
||||
blockingType: string;
|
||||
}>;
|
||||
};
|
||||
// console.log("收到 AGV 即时动作消息", agvId, actions);
|
||||
|
||||
let devId: any = undefined;
|
||||
currentDevices.forEach((device, key) => {
|
||||
if (device.agvId.serialNumber === msg.agvId) {
|
||||
devId = device.agvId;
|
||||
}
|
||||
});
|
||||
|
||||
if (devId) {
|
||||
// console.log("收到 AGV 即时动作消息", msg);
|
||||
try {
|
||||
const headerless: Headerless<InstantActions> = {
|
||||
actions: actions.map(a => ({
|
||||
actionType: a.actionType, // 必填
|
||||
actionId: a.actionId, // 必填
|
||||
blockingType: a.blockingType === "HARD" ? BlockingType.Hard : BlockingType.None, // 必填,使用枚举
|
||||
actionParameters: a.actionParameters || [], // 使用原始的actionParameters或空数组
|
||||
actionDescription: "action parameters", // 可选
|
||||
}))
|
||||
};
|
||||
// console.log("=====>",headerless);
|
||||
await masterController!.initiateInstantActions({ manufacturer: manufacturerValue, serialNumber: devId.serialNumber }, headerless, {
|
||||
onActionStateChanged: (actionState, withError, action, agvId, state) => console.log("Instant action state changed: %o %o %o", actionState, withError, action),
|
||||
onActionError: (error, action, agvId, state) => console.log("Instant action error: %o %o %o", error, action, state),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("initiateInstantActions 异常", err);
|
||||
}
|
||||
} // end if(agvId)
|
||||
} // end if(message.type === "instantActionsForwarded")
|
||||
|
||||
// 处理发送订单请求
|
||||
if (message.type === "sendOrder") {
|
||||
// Check if client is started before processing orders
|
||||
if (!clientStarted || !masterController) {
|
||||
console.warn("❌ VDA client not started yet, ignoring send order request");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const order: Headerless<Order> = {
|
||||
orderId: message.orderId || masterController!.createUuid(),
|
||||
orderUpdateId: 0,
|
||||
nodes: message.nodes.map((node: any, index: number) => ({
|
||||
nodeId: node.nodeId,
|
||||
sequenceId: index * 2,
|
||||
released: true,
|
||||
nodePosition: node.nodePosition,
|
||||
actions: node.actions || [],
|
||||
})),
|
||||
edges: [],
|
||||
};
|
||||
|
||||
if (message.nodes.length > 1) {
|
||||
for (let i = 0; i < message.nodes.length - 1; i++) {
|
||||
order.edges.push({
|
||||
edgeId: `edge${i}to${i + 1}`,
|
||||
sequenceId: i * 2 + 1,
|
||||
startNodeId: message.nodes[i].nodeId,
|
||||
endNodeId: message.nodes[i + 1].nodeId,
|
||||
released: true,
|
||||
actions: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 使用预注册列表中的第一个设备发送订单
|
||||
let devId: any = undefined;
|
||||
// currentDevices.forEach((device, key) => {
|
||||
// if ( device.agvId.serialNumber === 'ZKG-0') {
|
||||
// devId = device.agvId;
|
||||
// }
|
||||
// });
|
||||
if (devId) {
|
||||
await masterController!.assignOrder({ manufacturer: manufacturerValue, serialNumber: devId.serialNumber }, order, {
|
||||
onOrderProcessed: (withError, byCancelation, active, ctx) => {
|
||||
console.log("Order processed", { withError, byCancelation, active });
|
||||
self.postMessage({
|
||||
type: "orderCompleted",
|
||||
orderId: order.orderId,
|
||||
withError,
|
||||
byCancelation,
|
||||
active,
|
||||
});
|
||||
},
|
||||
onNodeTraversed: (node, nextEdge, nextNode, ctx) => {
|
||||
console.log("Order node traversed:", node);
|
||||
self.postMessage({
|
||||
type: "nodeTraversed",
|
||||
node,
|
||||
nextEdge,
|
||||
nextNode,
|
||||
});
|
||||
},
|
||||
onEdgeTraversing: (
|
||||
edge,
|
||||
startNode,
|
||||
endNode,
|
||||
stateChanges,
|
||||
invocationCount,
|
||||
ctx
|
||||
) => {
|
||||
console.log("Order edge traversing:", edge);
|
||||
self.postMessage({
|
||||
type: "edgeTraversing",
|
||||
edge,
|
||||
startNode,
|
||||
endNode,
|
||||
});
|
||||
},
|
||||
onEdgeTraversed: (edge, startNode, endNode, ctx) => {
|
||||
console.log("Order edge traversed:", edge);
|
||||
self.postMessage({ type: "edgeTraversed", edge, startNode, endNode });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Order assigned successfully");
|
||||
self.postMessage({ type: "orderSent", orderId: order.orderId });
|
||||
} catch (error) {
|
||||
console.error("Failed to send order:", error);
|
||||
self.postMessage({ type: "error", error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
// 处理取消订单请求
|
||||
if (message.type === "cancelOrder") {
|
||||
// 此处添加取消订单逻辑……
|
||||
}
|
||||
|
||||
// 处理主动请求设备列表的消息
|
||||
if (message.type === "discoverDevices") {
|
||||
const devicesList = Array.from(currentDevices.values())
|
||||
.filter(d => d.isOnline)
|
||||
.map(d => ({
|
||||
agvId: d.agvId,
|
||||
isOnline: d.isOnline,
|
||||
x: d.state?.agvPosition?.x,
|
||||
y: d.state?.agvPosition?.y,
|
||||
theta: d.state?.agvPosition?.theta,
|
||||
actionStatus: d.state?.actionStates,
|
||||
lastNodeId: d.state?.lastNodeId,
|
||||
lastNodeSequenceId: d.state?.lastNodeSequenceId,
|
||||
nodeStates: d.state?.nodeStates,
|
||||
edgeStates: d.state?.edgeStates,
|
||||
driving: d.state?.driving,
|
||||
errors: d.state?.errors?.map(err => ({
|
||||
errorType: err.errorType,
|
||||
errorLevel: err.errorLevel,
|
||||
errorDescription: err.errorDescription,
|
||||
errorReferences: err.errorReferences?.map((ref: any) => ({
|
||||
referenceKey: ref.referenceKey,
|
||||
referenceValue: ref.referenceValue
|
||||
}))
|
||||
})),
|
||||
information: d.state?.information || []
|
||||
}));
|
||||
console.log("currentDevices", JSON.stringify(Array.from(currentDevices.values()), null, 2));
|
||||
self.postMessage({ type: "devicesList", data: devicesList });
|
||||
}
|
||||
|
||||
// 处理factsheet请求
|
||||
if (message.type === "factsheetRequest") {
|
||||
// Check if client is started before processing factsheet requests
|
||||
if (!clientStarted || !masterController) {
|
||||
console.warn("❌ VDA client not started yet, ignoring factsheet request");
|
||||
return;
|
||||
}
|
||||
|
||||
const { agvId } = message.data || {};
|
||||
if (!agvId) {
|
||||
console.warn("❌ No agvId provided for factsheet request");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📋 Processing factsheet request for AGV: ${agvId}`);
|
||||
|
||||
// Find the device in currentDevices
|
||||
let targetDevice: any = undefined;
|
||||
currentDevices.forEach((device, key) => {
|
||||
if (device.agvId.serialNumber === agvId) {
|
||||
targetDevice = device.agvId;
|
||||
}
|
||||
});
|
||||
|
||||
if (targetDevice) {
|
||||
try {
|
||||
// Request factsheet using instant action
|
||||
const factsheetAction: Headerless<InstantActions> = {
|
||||
actions: [{
|
||||
actionType: "factsheetRequest",
|
||||
actionId: `factsheet_${Date.now()}`,
|
||||
blockingType: BlockingType.None,
|
||||
actionParameters: [],
|
||||
actionDescription: "Request device factsheet"
|
||||
}]
|
||||
};
|
||||
|
||||
await masterController!.initiateInstantActions(
|
||||
{ manufacturer: manufacturerValue, serialNumber: targetDevice.serialNumber },
|
||||
factsheetAction,
|
||||
{
|
||||
onActionStateChanged: (actionState, withError, action, agvId, state) => {
|
||||
console.log("Factsheet action state changed:", actionState, withError ? "with error" : "success");
|
||||
},
|
||||
onActionError: (error, action, agvId, state) => {
|
||||
console.error("Factsheet action error:", error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`✅ Factsheet request sent for AGV: ${agvId}`);
|
||||
} catch (err) {
|
||||
console.error("❌ Failed to send factsheet request:", err);
|
||||
}
|
||||
} else {
|
||||
console.warn(`⚠️ AGV ${agvId} not found in current devices`);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 shutdown 消息
|
||||
if (message === "shutdown" || message.type === "shutdown") {
|
||||
console.log("收到 shutdown 消息,退出 Worker");
|
||||
// 此处可添加关闭逻辑
|
||||
}
|
||||
};
|
||||
|
||||
// 在 worker 退出时关闭 Master Controller
|
||||
addEventListener("unload", () => {
|
||||
console.log("Closing VDA 5050 Worker");
|
||||
masterController?.stop().catch((err: Error) => console.log(err));
|
||||
});
|
289
web_worker.ts
Normal file
289
web_worker.ts
Normal file
@ -0,0 +1,289 @@
|
||||
import { Hono } from '@hono/hono';
|
||||
import { serveStatic } from '@hono/hono/serve-static';
|
||||
import { html } from '@hono/hono/html';
|
||||
import { Context } from '@hono/hono';
|
||||
|
||||
// Create Hono app
|
||||
const app = new Hono();
|
||||
|
||||
// Store AGV positions
|
||||
const agvPositions: Map<string, { x: number; y: number; theta: number }> = new Map();
|
||||
let server: { shutdown: () => Promise<void> } | null = null;
|
||||
let isRunning = false;
|
||||
|
||||
// Serve static files
|
||||
app.use('/*', serveStatic({
|
||||
root: './',
|
||||
getContent: async (path, c) => {
|
||||
try {
|
||||
const file = await Deno.readFile(path);
|
||||
return file;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Main page with canvas
|
||||
app.get('/', (c: Context) => {
|
||||
return c.html(html`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>AGV Position Monitor</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f0f0f0;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
canvas {
|
||||
background-color: white;
|
||||
border: 1px solid #ccc;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.controls {
|
||||
margin: 20px 0;
|
||||
}
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
margin-right: 10px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>AGV Position Monitor</h1>
|
||||
<div class="controls">
|
||||
<button onclick="startUpdates()">Start Updates</button>
|
||||
<button onclick="stopUpdates()">Stop Updates</button>
|
||||
</div>
|
||||
<canvas id="agvCanvas" width="800" height="800"></canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('agvCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let updateInterval;
|
||||
|
||||
// Scale factor for visualization
|
||||
const SCALE = 2; // Scale adjusted for 400x400 meter area
|
||||
const AGV_SIZE = 8; // AGV representation size
|
||||
const GRID_SIZE = 400; // 400x400 meter grid
|
||||
const COLORS = ['#4CAF50', '#2196F3', '#FFC107', '#9C27B0', '#FF5722', '#607D8B'];
|
||||
|
||||
function getColor(id) {
|
||||
// Simple hash function to get consistent color for each AGV
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash = id.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return COLORS[Math.abs(hash) % COLORS.length];
|
||||
}
|
||||
|
||||
function drawAGV(x, y, theta, id, color) {
|
||||
ctx.save();
|
||||
// Center the canvas and flip Y axis to match coordinate system
|
||||
ctx.translate(canvas.width/2 + x * SCALE, canvas.height/2 - y * SCALE);
|
||||
ctx.rotate(-theta); // Negative theta to match coordinate system
|
||||
|
||||
// Draw AGV body
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(-AGV_SIZE/2, -AGV_SIZE/2, AGV_SIZE, AGV_SIZE);
|
||||
|
||||
// Draw direction indicator
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(AGV_SIZE/2, 0);
|
||||
ctx.strokeStyle = 'white';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// Draw AGV name
|
||||
ctx.save();
|
||||
ctx.translate(canvas.width/2 + x * SCALE, canvas.height/2 - y * SCALE - AGV_SIZE);
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = '12px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(id, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function clearCanvas() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw grid
|
||||
ctx.strokeStyle = '#ddd';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// Grid interval for labels (show every 20 units)
|
||||
const LABEL_INTERVAL = 20;
|
||||
|
||||
// Vertical lines
|
||||
for (let x = -GRID_SIZE/2; x <= GRID_SIZE/2; x++) {
|
||||
const drawX = canvas.width/2 + x * SCALE;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(drawX, 0);
|
||||
ctx.lineTo(drawX, canvas.height);
|
||||
ctx.stroke();
|
||||
|
||||
// Add X axis labels (every LABEL_INTERVAL units)
|
||||
if (x % LABEL_INTERVAL === 0) {
|
||||
ctx.fillStyle = '#666';
|
||||
ctx.font = '12px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(x.toString(), drawX, canvas.height/2 + 20);
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal lines
|
||||
for (let y = -GRID_SIZE/2; y <= GRID_SIZE/2; y++) {
|
||||
const drawY = canvas.height/2 + y * SCALE;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, drawY);
|
||||
ctx.lineTo(canvas.width, drawY);
|
||||
ctx.stroke();
|
||||
|
||||
// Add Y axis labels (every LABEL_INTERVAL units)
|
||||
if (y % LABEL_INTERVAL === 0) {
|
||||
ctx.fillStyle = '#666';
|
||||
ctx.font = '12px Arial';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText((-y).toString(), canvas.width/2 - 10, drawY + 4);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw axis lines
|
||||
ctx.strokeStyle = '#999';
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
// X axis
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, canvas.height/2);
|
||||
ctx.lineTo(canvas.width, canvas.height/2);
|
||||
ctx.stroke();
|
||||
|
||||
// Y axis
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(canvas.width/2, 0);
|
||||
ctx.lineTo(canvas.width/2, canvas.height);
|
||||
ctx.stroke();
|
||||
|
||||
// Add axes labels
|
||||
ctx.fillStyle = '#444';
|
||||
ctx.font = 'bold 14px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('X', canvas.width - 15, canvas.height/2 - 10);
|
||||
ctx.fillText('Y', canvas.width/2 + 15, 15);
|
||||
}
|
||||
|
||||
function updatePositions() {
|
||||
fetch('/positions')
|
||||
.then(response => response.json())
|
||||
.then(positions => {
|
||||
clearCanvas();
|
||||
positions.forEach(pos => {
|
||||
const color = getColor(pos.id);
|
||||
drawAGV(
|
||||
pos.position.x,
|
||||
pos.position.y,
|
||||
pos.position.theta,
|
||||
pos.id,
|
||||
color
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch(error => console.error('Error fetching positions:', error));
|
||||
}
|
||||
|
||||
function startUpdates() {
|
||||
if (!updateInterval) {
|
||||
updateInterval = setInterval(updatePositions, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function stopUpdates() {
|
||||
if (updateInterval) {
|
||||
clearInterval(updateInterval);
|
||||
updateInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Start updates automatically
|
||||
startUpdates();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
|
||||
// API endpoint to get current positions
|
||||
app.get('/positions', (c: Context) => {
|
||||
// Convert Map to array of objects with id and position
|
||||
const positions = Array.from(agvPositions.entries()).map(([id, pos]) => ({
|
||||
id,
|
||||
position: pos
|
||||
}));
|
||||
return c.json(positions);
|
||||
});
|
||||
|
||||
// Handle messages from main thread
|
||||
self.onmessage = (event) => {
|
||||
const message = event.data;
|
||||
|
||||
if (message.type === 'positionUpdate') {
|
||||
const { agvId, position } = message.data;
|
||||
// console.log("agvId", agvId, "position", position);
|
||||
agvPositions.set(`${agvId.manufacturer}/${agvId.serialNumber}`, position);
|
||||
} else if (message.type === 'shutdown') {
|
||||
stopServer();
|
||||
}
|
||||
};
|
||||
|
||||
// Start the server
|
||||
export async function startServer(port: number = 3001) {
|
||||
if (isRunning) {
|
||||
console.log("Web服务器已在运行中");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
server = Deno.serve({ port }, app.fetch);
|
||||
isRunning = true;
|
||||
console.log(`Web服务器已启动,监听端口 ${port}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`服务器启动失败: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the server
|
||||
export async function stopServer() {
|
||||
if (!isRunning || !server) {
|
||||
console.log("Web服务器未在运行");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await server.shutdown();
|
||||
isRunning = false;
|
||||
server = null;
|
||||
console.log('Web服务器已关闭');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`服务器关闭失败: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server when the worker is initialized
|
||||
startServer();
|
139
worker_event_helper.ts
Normal file
139
worker_event_helper.ts
Normal file
@ -0,0 +1,139 @@
|
||||
// worker_event_helper.ts
|
||||
// Worker 端的全局事件助手
|
||||
|
||||
export interface GlobalEvent {
|
||||
type: string;
|
||||
source: string;
|
||||
target?: string;
|
||||
data?: any;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export class WorkerEventHelper {
|
||||
private workerName: string;
|
||||
private listeners: Map<string, Map<string, Function>> = new Map();
|
||||
|
||||
constructor(workerName: string) {
|
||||
this.workerName = workerName;
|
||||
this.setupMessageHandler();
|
||||
console.log(`🔧 ${workerName} 事件助手已初始化`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置消息处理器
|
||||
*/
|
||||
private setupMessageHandler() {
|
||||
self.addEventListener("message", (event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
if (message.type === "globalEvent") {
|
||||
this.handleGlobalEvent(message.event);
|
||||
} else if (message.type === "globalEventReceived") {
|
||||
this.handleEventReceived(message.eventType, message.listenerId, message.event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理接收到的全局事件
|
||||
*/
|
||||
private handleGlobalEvent(event: GlobalEvent) {
|
||||
console.log(`📨 ${this.workerName} 收到全局事件: ${event.type} (来源: ${event.source})`);
|
||||
|
||||
// 触发本地监听器
|
||||
const eventListeners = this.listeners.get(event.type);
|
||||
if (eventListeners) {
|
||||
eventListeners.forEach((callback) => {
|
||||
try {
|
||||
callback(event);
|
||||
} catch (error) {
|
||||
console.error(`❌ ${this.workerName} 事件处理器出错:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理事件监听器回调
|
||||
*/
|
||||
private handleEventReceived(eventType: string, listenerId: string, event: GlobalEvent) {
|
||||
const eventListeners = this.listeners.get(eventType);
|
||||
if (eventListeners) {
|
||||
const callback = eventListeners.get(listenerId);
|
||||
if (callback) {
|
||||
try {
|
||||
callback(event);
|
||||
} catch (error) {
|
||||
console.error(`❌ ${this.workerName} 事件监听器出错:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分发全局事件
|
||||
*/
|
||||
dispatchEvent(type: string, data?: any, target?: string) {
|
||||
const event: GlobalEvent = {
|
||||
type,
|
||||
source: this.workerName,
|
||||
target,
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
console.log(`📤 ${this.workerName} 分发事件: ${type}${target ? ` -> ${target}` : ' (广播)'}`);
|
||||
|
||||
self.postMessage({
|
||||
type: "dispatchGlobalEvent",
|
||||
event
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加事件监听器
|
||||
*/
|
||||
addEventListener(eventType: string, callback: (event: GlobalEvent) => void): string {
|
||||
const listenerId = `${this.workerName}_${eventType}_${Date.now()}_${Math.random()}`;
|
||||
|
||||
if (!this.listeners.has(eventType)) {
|
||||
this.listeners.set(eventType, new Map());
|
||||
}
|
||||
|
||||
this.listeners.get(eventType)!.set(listenerId, callback);
|
||||
|
||||
// 注册到主线程的事件管理器
|
||||
self.postMessage({
|
||||
type: "addEventListener",
|
||||
eventType,
|
||||
listenerId
|
||||
});
|
||||
|
||||
console.log(`👂 ${this.workerName} 添加事件监听器: ${eventType}`);
|
||||
return listenerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除事件监听器
|
||||
*/
|
||||
removeEventListener(eventType: string, listenerId: string) {
|
||||
const eventListeners = this.listeners.get(eventType);
|
||||
if (eventListeners) {
|
||||
eventListeners.delete(listenerId);
|
||||
if (eventListeners.size === 0) {
|
||||
this.listeners.delete(eventType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Worker 名称
|
||||
*/
|
||||
getWorkerName(): string {
|
||||
return this.workerName;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建便捷的全局函数
|
||||
export function createWorkerEventHelper(workerName: string): WorkerEventHelper {
|
||||
return new WorkerEventHelper(workerName);
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user