Files
2026-05-21 18:36:39 +00:00

447 lines
12 KiB
JavaScript

import { ENVIRONMENT_DEV } from "../protocol/index.mjs";
import { DEFAULT_GATEWAY_SESSION_ID, DEFAULT_PROJECT_ID } from "../sim/model.mjs";
export const PATCH_PANEL_SERVICE_ID = "hwlab-patch-panel";
const KNOWN_OUTPUT_FAMILIES = Object.freeze(["DO", "AO", "FREQ_OUT"]);
const SIGNAL_TARGET_FAMILY = Object.freeze({
DO: "DI",
AO: "AI",
FREQ_OUT: "FREQ_IN"
});
const SIGNAL_VALUE_TYPE = Object.freeze({
"DO->DI": "boolean",
"AO->AI": "number",
"FREQ_OUT->FREQ_IN": "number"
});
export function createWiringConfig({
wiringConfigId = "wir_mvp_patch_panel",
projectId = DEFAULT_PROJECT_ID,
gatewaySessionId = DEFAULT_GATEWAY_SESSION_ID,
name = "MVP simulated patch panel wiring",
status = "active",
connections,
constraints,
now = new Date().toISOString()
}) {
if (!Array.isArray(connections) || connections.length === 0) {
throw new Error("wiring config requires at least one connection");
}
return {
wiringConfigId,
projectId,
gatewaySessionId,
name,
status,
connections,
constraints: constraints ?? {
propagation: "patch-panel-only",
localLoopbackSubstituteAllowed: false
},
createdAt: now,
updatedAt: now
};
}
export function createPatchPanelState({
patchPanelStatusId = "pps_mvp_patch_panel",
gatewaySessionId = null,
wiringConfig,
state = "active",
metadata = {},
now = new Date().toISOString()
}) {
const effectiveGatewaySessionId =
gatewaySessionId ?? wiringConfig.gatewaySessionId ?? DEFAULT_GATEWAY_SESSION_ID;
return {
patchPanelStatusId,
projectId: wiringConfig.projectId ?? DEFAULT_PROJECT_ID,
gatewaySessionId: effectiveGatewaySessionId,
wiringConfigId: wiringConfig.wiringConfigId,
serviceId: PATCH_PANEL_SERVICE_ID,
state,
environment: ENVIRONMENT_DEV,
activeConnections: wiringConfig.connections.map((connection) => ({
fromResourceId: connection.from.resourceId,
fromPort: connection.from.port,
toResourceId: connection.to.resourceId,
toPort: connection.to.port
})),
observedAt: now,
metadata: {
propagation: "patch-panel-only",
connectionCount: wiringConfig.connections.length,
...metadata
}
};
}
export function endpointKey(resourceId, port) {
return `${resourceId}:${port}`;
}
export function classifySignalPort(port) {
const normalized = String(port ?? "").trim().toUpperCase();
if (!normalized) {
return null;
}
const freqOut = normalized.match(/^FREQ_OUT(?<suffix>[A-Z0-9_-]*)$/);
if (freqOut) {
return {
family: "FREQ_OUT",
direction: "output",
valueType: "number",
suffix: freqOut.groups.suffix ?? ""
};
}
const freqIn = normalized.match(/^FREQ_IN(?<suffix>[A-Z0-9_-]*)$/);
if (freqIn) {
return {
family: "FREQ_IN",
direction: "input",
valueType: "number",
suffix: freqIn.groups.suffix ?? ""
};
}
const discrete = normalized.match(/^(?<family>DO|DI|AO|AI)(?<suffix>[A-Z0-9_-]*)$/);
if (!discrete) {
return null;
}
const family = discrete.groups.family;
return {
family,
direction: family === "DO" || family === "AO" ? "output" : "input",
valueType: family === "DO" || family === "DI" ? "boolean" : "number",
suffix: discrete.groups.suffix ?? ""
};
}
function issue(level, code, message, path, extra = {}) {
return {
level,
code,
message,
path,
...extra
};
}
function endpointDiagnostic(endpoint, path) {
if (!endpoint || typeof endpoint !== "object" || Array.isArray(endpoint)) {
return issue("error", "endpoint_invalid", "wiring endpoint must be an object", path);
}
if (!endpoint.resourceId || typeof endpoint.resourceId !== "string") {
return issue("error", "endpoint_resource_missing", "wiring endpoint requires resourceId", `${path}.resourceId`);
}
if (!endpoint.port || typeof endpoint.port !== "string") {
return issue("error", "endpoint_port_missing", "wiring endpoint requires port", `${path}.port`);
}
return null;
}
function describeExpectedTarget(fromFamily) {
const targetFamily = SIGNAL_TARGET_FAMILY[fromFamily];
if (!targetFamily) {
return `one of ${KNOWN_OUTPUT_FAMILIES.join(", ")}`;
}
return targetFamily;
}
function normalizeConnection(connection, index) {
const path = `$.connections[${index}]`;
const diagnostics = [];
if (!connection || typeof connection !== "object" || Array.isArray(connection)) {
return {
diagnostics: [issue("error", "connection_invalid", "wiring connection must be an object", path)],
normalized: null
};
}
for (const side of ["from", "to"]) {
const endpointIssue = endpointDiagnostic(connection[side], `${path}.${side}`);
if (endpointIssue) {
diagnostics.push(endpointIssue);
}
}
if (diagnostics.some((item) => item.level === "error")) {
return { diagnostics, normalized: null };
}
const fromKind = classifySignalPort(connection.from.port);
const toKind = classifySignalPort(connection.to.port);
let syncKind = "generic";
let valueType = "any";
let syncable = false;
if (fromKind || toKind) {
if (!fromKind || !toKind) {
diagnostics.push(
issue(
"error",
"typed_signal_endpoint_mismatch",
`typed signal wiring requires both endpoints to use signal ports; got ${connection.from.port} -> ${connection.to.port}`,
path
)
);
} else if (fromKind.direction !== "output") {
diagnostics.push(
issue(
"error",
"source_port_not_output",
`${connection.from.port} is an input port and cannot drive ${connection.to.port}; expected source family ${KNOWN_OUTPUT_FAMILIES.join(", ")}`,
`${path}.from.port`
)
);
} else if (toKind.direction !== "input") {
diagnostics.push(
issue(
"error",
"target_port_not_input",
`${connection.to.port} is an output port and cannot be driven by ${connection.from.port}; expected target family ${describeExpectedTarget(fromKind.family)}`,
`${path}.to.port`
)
);
} else if (SIGNAL_TARGET_FAMILY[fromKind.family] !== toKind.family) {
diagnostics.push(
issue(
"error",
"unsupported_signal_wiring",
`${fromKind.family} output port ${connection.from.port} can only drive ${describeExpectedTarget(fromKind.family)} input ports; got ${connection.to.port}`,
path
)
);
} else {
syncKind = `${fromKind.family}->${toKind.family}`;
valueType = SIGNAL_VALUE_TYPE[syncKind];
syncable = true;
}
}
return {
diagnostics,
normalized: {
index,
from: connection.from,
to: connection.to,
mode: connection.mode ?? "exclusive",
syncKind,
valueType,
syncable,
bidirectionalRoute: syncKind === "generic"
}
};
}
export function validateWiringConfig(wiringConfig) {
const diagnostics = [];
const normalizedConnections = [];
if (!wiringConfig || typeof wiringConfig !== "object" || Array.isArray(wiringConfig)) {
return {
diagnostics: [
issue("error", "wiring_config_invalid", "wiring config must be an object", "$")
],
normalizedConnections
};
}
for (const field of ["wiringConfigId", "projectId", "gatewaySessionId", "status"]) {
if (!wiringConfig[field] || typeof wiringConfig[field] !== "string") {
diagnostics.push(
issue("error", "wiring_config_field_missing", `wiring config requires string ${field}`, `$.${field}`)
);
}
}
if (wiringConfig.status !== "active") {
diagnostics.push(
issue(
"error",
"wiring_config_not_active",
`patch-panel runtime can only apply active wiring configs; got ${JSON.stringify(wiringConfig.status)}`,
"$.status"
)
);
}
if (wiringConfig.constraints?.propagation !== "patch-panel-only") {
diagnostics.push(
issue(
"error",
"wiring_propagation_invalid",
"wiring config constraints.propagation must be patch-panel-only",
"$.constraints.propagation"
)
);
}
if (wiringConfig.constraints?.localLoopbackSubstituteAllowed !== false) {
diagnostics.push(
issue(
"error",
"local_loopback_substitute_invalid",
"wiring config must set constraints.localLoopbackSubstituteAllowed=false",
"$.constraints.localLoopbackSubstituteAllowed"
)
);
}
if (!Array.isArray(wiringConfig.connections) || wiringConfig.connections.length === 0) {
diagnostics.push(
issue("error", "connections_missing", "wiring config requires at least one connection", "$.connections")
);
} else {
wiringConfig.connections.forEach((connection, index) => {
const result = normalizeConnection(connection, index);
diagnostics.push(...result.diagnostics);
if (result.normalized) {
normalizedConnections.push(result.normalized);
}
});
}
return {
diagnostics,
normalizedConnections
};
}
function diagnosticSummary(diagnostics) {
return {
errorCount: diagnostics.filter((item) => item.level === "error").length,
warningCount: diagnostics.filter((item) => item.level === "warning").length,
infoCount: diagnostics.filter((item) => item.level === "info").length
};
}
function validateValueForConnection(value, connection) {
if (connection.valueType === "boolean" && typeof value !== "boolean") {
return issue(
"error",
"signal_value_type_mismatch",
`${connection.syncKind} requires a boolean value; got ${typeof value}`,
`$.connections[${connection.index}]`
);
}
if (connection.valueType === "number" && typeof value !== "number") {
return issue(
"error",
"signal_value_type_mismatch",
`${connection.syncKind} requires a number value; got ${typeof value}`,
`$.connections[${connection.index}]`
);
}
return null;
}
export function createPatchPanel({ wiringConfig, now = () => new Date().toISOString() }) {
const signals = new Map();
const validation = validateWiringConfig(wiringConfig);
const summary = diagnosticSummary(validation.diagnostics);
function routeSignal({ fromResourceId, fromPort, value }) {
const observedAt = now();
const deliveries = [];
const routeDiagnostics = [];
if (summary.errorCount > 0) {
return {
accepted: false,
propagatedBy: PATCH_PANEL_SERVICE_ID,
deliveryCount: 0,
deliveries,
diagnostics: validation.diagnostics,
observedAt
};
}
for (const connection of validation.normalizedConnections) {
const forwardMatch =
connection.from.resourceId === fromResourceId && connection.from.port === fromPort;
const reverseMatch =
connection.bidirectionalRoute &&
connection.to.resourceId === fromResourceId &&
connection.to.port === fromPort;
if (!forwardMatch && !reverseMatch) {
continue;
}
const valueIssue = validateValueForConnection(value, connection);
if (valueIssue) {
routeDiagnostics.push(valueIssue);
continue;
}
const target = forwardMatch ? connection.to : connection.from;
const key = endpointKey(target.resourceId, target.port);
const delivery = {
resourceId: target.resourceId,
port: target.port,
value,
sourceResourceId: fromResourceId,
sourcePort: fromPort,
observedAt
};
signals.set(key, delivery);
deliveries.push(delivery);
}
if (routeDiagnostics.some((item) => item.level === "error")) {
return {
accepted: false,
propagatedBy: PATCH_PANEL_SERVICE_ID,
deliveryCount: deliveries.length,
deliveries,
diagnostics: routeDiagnostics,
observedAt
};
}
const response = {
accepted: true,
propagatedBy: PATCH_PANEL_SERVICE_ID,
deliveryCount: deliveries.length,
deliveries,
observedAt
};
if (routeDiagnostics.length > 0) {
response.diagnostics = routeDiagnostics;
}
return response;
}
function status() {
return createPatchPanelState({
wiringConfig,
state: summary.errorCount > 0 ? "faulted" : "active",
metadata: {
diagnostics: summary,
syncableConnectionCount: validation.normalizedConnections.filter((connection) => connection.syncable).length
},
now: now()
});
}
return {
wiringConfig,
diagnostics: validation.diagnostics,
diagnosticSummary: summary,
normalizedConnections: validation.normalizedConnections,
routeSignal,
status,
signals
};
}