feat: 从机站台吸附识别与时刻表自动同步 v1.0.73

- Lua端: 新增定期外设重扫描(PERIPHERAL_SCAN_INTERVAL=15s),支持热插拔站台方块
- Lua端: 新增定期身份同步,站台外设热插/热拔时自动刷新身份并重新register
- Lua端: 新增独立的schedule sync coroutine与station_schedule_sync消息类型(SCHEDULE_SYNC_INTERVAL=10s)
- Lua端: 扩展heartbeat携带hasStationPeripheral/stationPeripheralSide/hasBlockReader/hasCreateTarget/identityMode/upcomingTrains/currentTrain等字段
- 从机若吸附有站台方块(train_station外设),则自动进入主机逻辑: 识别到发、停靠列车检测,数据通过WS推送到服务器
- server端: 新增assignDeviceFields工具函数统一写入设备字段与时刻表
- server端: 新增handleScheduleSync处理station_schedule_sync消息,数据写入stations[name]并broadcast
- server端: 扩展handleHeartbeat与handleStationRegister,均调用assignDeviceFields存储设备状态和时刻表摘要
- server端: 扩展getStationGroups分组视图,新增hostCount/slaveCount/hasAnyStationPeripheral/upcomingTrains/currentTrain/members详细信息
- 时刻表合并: 同一站台组多节点的upcomingTrains按trainName@@dest@@platform去重合并; currentTrain优先主机数据
- 版本升级: v1.0.72 -> v1.0.73
This commit is contained in:
2026-08-16 13:33:10 +08:00
parent cd5b1d47b0
commit ae213a84a1
2 changed files with 269 additions and 15 deletions
+131 -12
View File
@@ -408,7 +408,31 @@ function getStationGroups() {
const key = getStationGroupKey(st);
if (!key) continue;
const memberTags = [];
if (st.hasStationPeripheral) memberTags.push('STATION');
if (st.hasBlockReader) memberTags.push('BLOCKREADER');
if (st.hasCreateTarget) memberTags.push('TARGET');
const isHost = Boolean(st.hasStationPeripheral);
const memberInfo = {
name: st.name,
code,
platform: st.platform || platform,
version: st.version || '',
lastSeen: st.lastSeen || 0,
hasStationPeripheral: isHost,
stationPeripheralSide: st.stationPeripheralSide || '',
hasBlockReader: Boolean(st.hasBlockReader),
hasCreateTarget: Boolean(st.hasCreateTarget),
identityMode: st.identityMode || '',
upcomingCount: Number(st.upcomingCount) || 0,
memberTags
};
if (!groups.has(key)) {
const mergedTags = ['CC'];
if (isHost) mergedTags.push('HOST');
else mergedTags.push('SLAVE');
groups.set(key, {
key,
target: getStationGroupTarget(st),
@@ -418,29 +442,72 @@ function getStationGroups() {
lastSeen: st.lastSeen || 0,
version: st.version || '',
source: 'cc',
tags: ['CC'],
tags: mergedTags,
memberNames: [st.name],
memberCount: 1,
primaryName: st.name
primaryName: st.name,
hasStationPeripheral: isHost,
hasBlockReader: Boolean(st.hasBlockReader),
hasCreateTarget: Boolean(st.hasCreateTarget),
hasAnyStationPeripheral: isHost,
hostCount: isHost ? 1 : 0,
slaveCount: isHost ? 0 : 1,
upcomingCount: Number(st.upcomingCount) || 0,
upcomingTrains: Array.isArray(st.upcomingTrains) ? st.upcomingTrains.slice() : [],
currentTrain: st.currentTrain || null,
members: [memberInfo]
});
continue;
}
const group = groups.get(key);
group.memberNames.push(st.name);
group.members.push(memberInfo);
group.memberCount = group.memberNames.length;
if (isHost) group.hostCount = (group.hostCount || 0) + 1;
else group.slaveCount = (group.slaveCount || 0) + 1;
if (!group.hasAnyStationPeripheral && isHost) group.hasAnyStationPeripheral = true;
if (!group.hasBlockReader && st.hasBlockReader) group.hasBlockReader = true;
if (!group.hasCreateTarget && st.hasCreateTarget) group.hasCreateTarget = true;
if (!group.hasStationPeripheral && isHost) {
group.hasStationPeripheral = true;
group.primaryName = st.name;
group.name = st.name;
}
if ((st.lastSeen || 0) > (group.lastSeen || 0)) {
group.lastSeen = st.lastSeen || 0;
group.version = st.version || group.version || '';
} else if (!group.version && st.version) {
group.version = st.version;
}
const existingKeys = new Set((group.upcomingTrains || []).map(t =>
String(t.trainName || '') + '@@' + String(t.dest || '') + '@@' + Number(t.platform || 0)
));
for (const t of (st.upcomingTrains || [])) {
const tk = String(t.trainName || '') + '@@' + String(t.dest || '') + '@@' + Number(t.platform || 0);
if (!existingKeys.has(tk)) {
if (!group.upcomingTrains) group.upcomingTrains = [];
group.upcomingTrains.push(t);
existingKeys.add(tk);
}
}
group.upcomingCount = (group.upcomingTrains || []).length;
if (!group.currentTrain && st.currentTrain) group.currentTrain = st.currentTrain;
}
return Array.from(groups.values()).map(group => ({
...group,
tags: group.memberCount > 1 ? ['CC', `GROUP:${group.memberCount}`] : ['CC']
}));
return Array.from(groups.values()).map(group => {
const mergedTags = ['CC'];
if ((group.hostCount || 0) > 0) mergedTags.push(`HOST:${group.hostCount}`);
if ((group.slaveCount || 0) > 0) mergedTags.push(`SLAVE:${group.slaveCount}`);
if (group.memberCount > 1) mergedTags.push(`GROUP:${group.memberCount}`);
if (group.hasAnyStationPeripheral) mergedTags.push('HAS_STATION');
if (group.hasBlockReader) mergedTags.push('HAS_BLOCKREADER');
if (group.hasCreateTarget) mergedTags.push('HAS_TARGET');
return {
...group,
tags: mergedTags
};
});
}
function resolveStationGroup(selector) {
@@ -1857,6 +1924,9 @@ function handleMessage(ws, data) {
case 'heartbeat':
handleHeartbeat(ws, data);
break;
case 'station_schedule_sync':
handleScheduleSync(ws, data);
break;
default:
log('WARNING', `Unknown message type: ${data.type}`);
}
@@ -1871,11 +1941,24 @@ function getCorrectStationName(name) {
return cleanName;
}
function assignDeviceFields(target, data) {
if (!target || !data) return;
if (data.hasStationPeripheral !== undefined) target.hasStationPeripheral = Boolean(data.hasStationPeripheral);
if (data.stationPeripheralSide !== undefined) target.stationPeripheralSide = String(data.stationPeripheralSide || '');
if (data.hasBlockReader !== undefined) target.hasBlockReader = Boolean(data.hasBlockReader);
if (data.hasCreateTarget !== undefined) target.hasCreateTarget = Boolean(data.hasCreateTarget);
if (data.identityMode !== undefined) target.identityMode = String(data.identityMode || '');
if (data.upcomingTrains !== undefined) target.upcomingTrains = Array.isArray(data.upcomingTrains) ? data.upcomingTrains : [];
if (data.upcomingCount !== undefined) target.upcomingCount = Number(data.upcomingCount) || 0;
if (data.currentTrain !== undefined) target.currentTrain = data.currentTrain || null;
if (data.tickRate !== undefined) target.tickRate = Number(data.tickRate) || 20;
if (data.syncedAt !== undefined) target.syncedAt = Number(data.syncedAt) || Date.now();
}
function handleHeartbeat(ws, data) {
if (data.stationName) {
const cleanName = data.stationName.trim();
if (!stations[cleanName]) {
// Auto-register if missing (e.g. server restarted)
stations[cleanName] = {
name: cleanName,
code: data.stationCode || '',
@@ -1884,8 +1967,13 @@ function handleHeartbeat(ws, data) {
lastSeen: Date.now(),
ws: ws
};
assignDeviceFields(stations[cleanName], data);
normalizeStationRecord(stations[cleanName]);
log('INFO', `Station auto-registered via heartbeat: "${cleanName}"${data.stationVersion ? ` ${data.stationVersion}` : ''}`);
const tags = [];
if (stations[cleanName].hasStationPeripheral) tags.push('HAS_STATION');
if (stations[cleanName].hasBlockReader) tags.push('HAS_BLOCKREADER');
if (stations[cleanName].hasCreateTarget) tags.push('HAS_TARGET');
log('INFO', `Station auto-registered via heartbeat: "${cleanName}"${data.stationVersion ? ` ${data.stationVersion}` : ''}${tags.length ? ' [' + tags.join(',') + ']' : ''}`);
maybeSendStationUpdate(ws, cleanName, data.stationVersion || stations[cleanName].version);
broadcastUpdate();
} else {
@@ -1893,10 +1981,14 @@ function handleHeartbeat(ws, data) {
if (data.stationCode) stations[cleanName].code = data.stationCode;
if (data.stationPlatform) stations[cleanName].platform = data.stationPlatform;
if (data.stationVersion) stations[cleanName].version = data.stationVersion;
if (stations[cleanName].ws !== ws) {
stations[cleanName].ws = ws; // Update WS in case of reconnect
const oldWS = stations[cleanName].ws;
assignDeviceFields(stations[cleanName], data);
if (oldWS !== ws) {
stations[cleanName].ws = ws;
log('INFO', `Station WebSocket updated via heartbeat: "${cleanName}"${data.stationVersion ? ` ${data.stationVersion}` : ''}`);
broadcastUpdate();
} else if (data.upcomingTrains || data.currentTrain || data.hasStationPeripheral !== undefined) {
broadcastUpdate();
}
normalizeStationRecord(stations[cleanName]);
maybeSendStationUpdate(ws, cleanName, data.stationVersion || stations[cleanName].version);
@@ -1904,6 +1996,33 @@ function handleHeartbeat(ws, data) {
}
}
function handleScheduleSync(ws, data) {
if (!data.stationName) return;
const cleanName = String(data.stationName || '').trim();
if (!stations[cleanName]) {
stations[cleanName] = {
name: cleanName,
code: data.stationCode || '',
platform: data.stationPlatform || getPlatformNumberFromName(cleanName),
version: '',
lastSeen: Date.now(),
ws: ws
};
assignDeviceFields(stations[cleanName], data);
normalizeStationRecord(stations[cleanName]);
log('INFO', `Station auto-registered via schedule sync: "${cleanName}" (upcoming: ${Number(data.upcomingCount) || 0})`);
broadcastUpdate();
return;
}
stations[cleanName].lastSeen = Date.now();
if (data.stationCode) stations[cleanName].code = data.stationCode;
if (data.stationPlatform) stations[cleanName].platform = data.stationPlatform;
if (stations[cleanName].ws !== ws) stations[cleanName].ws = ws;
assignDeviceFields(stations[cleanName], data);
normalizeStationRecord(stations[cleanName]);
broadcastUpdate();
}
function handleStationRegister(ws, data) {
const { stationName, stationCode, stationPlatform, stationVersion } = data;
const cleanName = stationName.trim();
@@ -1914,13 +2033,13 @@ function handleStationRegister(ws, data) {
platform: stationPlatform || getPlatformNumberFromName(cleanName),
version: stationVersion || '',
lastSeen: Date.now(),
ws: ws // Keep reference if we need to send back
ws: ws
};
assignDeviceFields(stations[cleanName], data);
normalizeStationRecord(stations[cleanName]);
log('SUCCESS', `Station registered: "${cleanName}" (${stationCode})${stationVersion ? ` ${stationVersion}` : ''}`);
// Send ack
ws.send(JSON.stringify({
type: 'station_register_ack',
stationName: cleanName