447 lines
12 KiB
JavaScript
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
|
|
};
|
|
}
|