import * as grpc from "@grpc/grpc-js"; import { Hardware as HardwarePB, MoqListenerRotateCerts, NdiDiscoveryEvent, ST2110NicEntry, ST2110NicEvent, ST2110NicStageChanged_Stage, ST2110NmosNodeEvent, MoqListenerRotateCertsSchema, NdiDiscoveryConfigurationSchema, ST2110NmosNodeConfigurationSchema, ST2110ClockSchema, ST2110Clock_ST2110InternalClockSchema, ST2110Clock_ST2110PTPClockSchema, ST2110InterfaceSchema, StringListSchema, ST2110NicConfigurationSchema, ST2110NicEntrySchema, ST2110SchedulingPinnedSchema, ST2110SchedulingFreeSchema, RecommendST2110CoresRequest, RecommendST2110CoresRequestSchema, RecommendST2110CoresResponse, MoqListenerRotateCertsResponse, GlobalOptionsSchema, GlobalOptions as GlobalOptionsPB, NodeWarningSuppression, NodeWarningSuppressionSchema, NodeWarningKind, MediaNodeIdSchema, ST2110PtpVersion as ST2110PtpVersionPB, IrohEndpointConfig, IrohEndpointConfigSchema, IrohEndpointResponse, IrohAllowedPeers, IrohAllowedPeersSchema, IrohListenersResponse} from "@norskvideo/norsk-api/lib/media_pb"; import * as util from "util"; import { CpuTopology, DeckLinkCard, MA35DCard, Nic, NvidiaCard, fromCpuTopology, fromDeckLinkCard, fromMA35DCard, fromNic, fromNvidiaCard } from "./types"; import { exhaustiveCheck, mkCase, provideFull } from "./shared/utils"; import { MediaNodeState, registerNonNodeGrpc, MediaClient as MediaClientCommon } from "./media_nodes/common"; import { MediaClient } from "@norskvideo/norsk-api/lib/media_grpc_pb"; import { ST2110InputNode, ST2110InputSettings, ST2110OutputFlow, ST2110OutputNode, ST2110OutputSettings, ST2110OutputSource, ST2110ReceiverConstraintsInternal, ST2110SenderConstraintsInternal } from "./media_nodes/st2110"; import { Empty, EmptySchema } from "@bufbuild/protobuf/wkt"; /** * @public */ export interface HardwareInfo { cpuTopology: CpuTopology; systemMemory: number; deckLinkCards: DeckLinkCard[]; ma35dCards: MA35DCard[]; nvidiaCards: NvidiaCard[]; nics: Nic[]; /** Local audio output devices (for {@link NorskOutput.localAudioMonitor}'s * deviceName). Empty when the host has no audio subsystem. */ audioDevices: AudioDevice[]; } /** * @public * One local audio output device. */ export interface AudioDevice { name: string; isDefault: boolean; } /** @internal */ export async function recommendST2110CoresFor( client: MediaClient, settings: { nic: Nic; count: number } ): Promise<{ mainCore: number; otherCores: number[] }> { const fn = util.promisify( client.recommendST2110Cores ).bind(client); const req = provideFull(RecommendST2110CoresRequestSchema, { pciBdf: settings.nic.pciBdf, count: settings.count, }); return fn(req).then((resp: RecommendST2110CoresResponse) => ({ mainCore: resp.mainLCore, otherCores: resp.otherCores, })); } /** @internal */ export async function hardwareInfo(client: MediaClient): Promise { const fn = util.promisify(client.hardwareInfo).bind(client); return (fn(provideFull(EmptySchema, {}))).then((info: HardwarePB) => { return { cpuTopology: fromCpuTopology(info.cpuTopology), systemMemory: Number(info.systemMemory), deckLinkCards: info.deckLinkCards.map(fromDeckLinkCard), ma35dCards: info.ma35dCards.map(fromMA35DCard), nvidiaCards: info.nvidiaCards.map(fromNvidiaCard), nics: info.nics.map(fromNic), audioDevices: info.audioDevices.map((d) => ({ name: d.name, isDefault: d.isDefault })), }; }); } /** * @public * Options that affect automatic conversion behaviour (e.g. decode steps * inserted when subscribing between nodes). Only supplied fields are * changed; omitted fields retain their current (or default) values. * Changes take effect on future subscriptions only. */ export interface GlobalOptions { /** Thread count for software video decoders in automatic conversions */ decodeThreadCount?: number; /** Enable low-delay mode for software video decoders in automatic conversions */ decodeLowDelay?: boolean; } /** @internal */ export async function setGlobalOptions(client: MediaClient, options: GlobalOptions): Promise { const fn = util.promisify(client.setGlobalOptions).bind(client); const pb = provideFull(GlobalOptionsSchema, { decodeThreadCountOption : options.decodeThreadCount !== undefined ? { case: "decodeThreadCount", value: options.decodeThreadCount } : undefined, decodeLowDelayOption: options.decodeLowDelay !== undefined ? { case: "decodeLowDelay", value: options.decodeLowDelay } : undefined, }); await fn(pb); } /** @internal */ export async function rotateMoqListenerCerts( client: MediaClient, type: 'wt' | 'quic', port: number, certFile: string, keyFile: string, ): Promise<{ success: boolean; error?: string }> { const fn = util.promisify(client.rotateMoqListenerCerts).bind(client); const req = provideFull(MoqListenerRotateCertsSchema, { type, port, certFile, keyFile }); return fn(req).then((resp: { success: boolean; error: string }) => ({ success: resp.success, error: resp.error || undefined, })); } /** * Configuration for the shared per-instance iroh endpoint (key-addressed * QUIC: peers dial the endpoint's public key — its NodeId — rather than an * ip:port). All fields optional; unset fields fall back to the * NORSK_IROH_* environment, then defaults (fresh unpersisted identity; * inbound rejected until an allow-list is set). * @public */ export interface IrohEndpointSettings { /** * Path to the identity key file. Load-or-generate: created (mode 0600) * if absent, so the NodeId survives restarts. */ secretKeyFile?: string; /** 'default' (relays + discovery) or 'disabled' (direct paths only). */ relayMode?: 'default' | 'disabled'; /** Verified remote NodeIds (64-char hex) allowed to connect inbound. */ allowedPeers?: string[]; /** Explicit opt-in to accept ANY peer (never the default). */ allowAnyPeer?: boolean; /** Path to an allow-list file (one hex NodeId per line, # comments). */ allowedPeersFile?: string; /** * Self-hosted relay server URLs (https). Setting these uses YOUR relays * and disables n0's discovery services entirely — peers are then reached * via addr hints (`iroh://?addr=`). Mutually exclusive * with relayMode 'disabled'. */ relayUrls?: string[]; } /** @public */ export interface IrohEndpointInfo { /** The endpoint's public key (64-char hex) — dial it as iroh://. */ nodeId: string; /** Bound socket addresses ("ip:port") — direct-dial hints for LAN use. */ directAddrs: string[]; /** 'default' | 'disabled' | 'custom' */ relayMode: string; /** Self-hosted relay URLs when relayMode is 'custom'. */ relayUrls: string[]; allowAnyPeer: boolean; allowedPeers: string[]; connections: number; } /** * One live iroh listener — the shared endpoint (slot 0) or a dedicated * one (matched by the slot configured in irohDedicated). * @public */ export interface IrohListenerInfo { name: string; slot: number; info: IrohEndpointInfo; } /** * Endpoint-level connection event on the shared iroh endpoint — delivered * via the onIrohEndpointEvent SDK setting. remoteNodeId is TLS-verified. * @public */ export interface IrohEndpointEvent { event: 'peer_connected' | 'peer_rejected' | 'peer_disconnected'; remoteNodeId: string; } function unwrapIrohResponse(resp: IrohEndpointResponse): IrohEndpointInfo { if (!resp.success || !resp.info) { throw new Error(resp.error || "iroh endpoint error"); } return unwrapIrohInfo(resp.info); } function unwrapIrohInfo(info: { nodeId: string; directAddrs: string[]; relayMode: string; relayUrls: string[]; allowAnyPeer: boolean; allowedPeers: string[]; connections: number; }): IrohEndpointInfo { return { nodeId: info.nodeId, directAddrs: info.directAddrs, relayMode: info.relayMode, relayUrls: info.relayUrls, allowAnyPeer: info.allowAnyPeer, allowedPeers: info.allowedPeers, connections: info.connections, }; } /** @internal */ export async function setupIrohEndpoint( client: MediaClient, settings: IrohEndpointSettings, ): Promise { const fn = util.promisify(client.setupIrohEndpoint).bind(client); const req = provideFull(IrohEndpointConfigSchema, { secretKeyFile: settings.secretKeyFile ?? "", relayMode: settings.relayMode ?? "", relayUrls: settings.relayUrls ?? [], allowedPeers: settings.allowedPeers ?? [], allowAnyPeer: settings.allowAnyPeer ?? false, allowedPeersFile: settings.allowedPeersFile ?? "", }); return fn(req).then(unwrapIrohResponse); } /** @internal */ export async function getIrohListeners(client: MediaClient): Promise { const fn = util.promisify(client.getIrohListeners).bind(client); return fn(provideFull(EmptySchema, {})).then((resp) => resp.listeners.flatMap((l) => l.info ? [{ name: l.name, slot: l.slot, info: unwrapIrohInfo(l.info) }] : [] ) ); } /** @internal */ export async function getIrohEndpointInfo(client: MediaClient): Promise { const fn = util.promisify(client.getIrohEndpointInfo).bind(client); return fn(provideFull(EmptySchema, {})).then(unwrapIrohResponse); } /** @internal */ export async function setIrohAllowedPeers( client: MediaClient, peers: string[] | 'any', ): Promise { const fn = util.promisify(client.setIrohAllowedPeers).bind(client); const req = provideFull(IrohAllowedPeersSchema, { allowedPeers: peers === 'any' ? [] : peers, allowAnyPeer: peers === 'any', }); return fn(req).then(unwrapIrohResponse); } /** @internal */ async function setNodeWarningSuppression( client: MediaClient, mediaNodeId: string, kind: NodeWarningKind, suppress: boolean, ): Promise { const fn = util.promisify(client.setNodeWarningSuppression).bind(client); const req = provideFull(NodeWarningSuppressionSchema, { mediaNodeId: provideFull(MediaNodeIdSchema, { id: mediaNodeId }), kind, suppress, }); await fn(req); } /** @internal */ export async function suppressNoSubscriberWarning(client: MediaClient, mediaNodeId: string): Promise { return setNodeWarningSuppression(client, mediaNodeId, NodeWarningKind.NO_SUBSCRIBER, true); } /** @internal */ export async function unsuppressNoSubscriberWarning(client: MediaClient, mediaNodeId: string): Promise { return setNodeWarningSuppression(client, mediaNodeId, NodeWarningKind.NO_SUBSCRIBER, false); } /** * @public */ export interface NdiSource { name: string; url: string; } /** * @public */ export interface NdiDiscoverySettings { showLocalSources: boolean; groups?: string; extraIps?: string; cb: (sources: NdiSource[]) => void; onClose?: (() => Promise) | (() => void), onError?: (error: Error) => void; } /** * @public */ export class NdiDiscovery { /** @internal */ client: MediaClient; /** @internal */ grpcStream: grpc.ClientReadableStream; /** @internal */ initialised: Promise; /** @internal */ public static async create( settings: NdiDiscoverySettings, client: MediaClient) { const ndi = new NdiDiscovery(settings, client); await ndi.initialised; return ndi; } /** @internal */ constructor(settings: NdiDiscoverySettings, client: MediaClient) { this.client = client; this.grpcStream = this.client.ndiDiscovery(provideFull(NdiDiscoveryConfigurationSchema, { showLocalSources: settings.showLocalSources, groups: settings.groups === undefined ? "" : settings.groups, extraIps: settings.extraIps === undefined ? "" : settings.extraIps, })); this.initialised = new Promise((resolve, reject) => { this.grpcStream.on("data", (data: NdiDiscoveryEvent) => { const messageCase = data.message.case; switch (messageCase) { case undefined: break; case "initialised": resolve(); break; case "sources": settings.cb(data.message.value.sources); break; default: exhaustiveCheck(messageCase); } }); registerNonNodeGrpc(this.grpcStream, "NDI Discovery", reject, settings.onClose, settings.onError); }); } public close() { this.grpcStream.destroy(); } } /** * @public * Methods that allow you query and update the features of the system that Norsk is running in */ export interface NorskSystem { hardwareInfo(): Promise; /** * Set global options that affect automatic conversion behaviour. * Only supplied fields are changed; omitted fields retain their * current (or default) values. Changes take effect on future * subscriptions only. */ setGlobalOptions(options: GlobalOptions): Promise; /** * Rotate TLS certificates on a running MoQT listener. Existing * connections are unaffected — only new connections use the new cert. */ rotateMoqListenerCerts(type: 'wt' | 'quic', port: number, certFile: string, keyFile: string): Promise<{ success: boolean; error?: string }>; /** * The shared per-instance iroh endpoint: one key-addressed QUIC identity * (NodeId) that every default iroh dial presents and remote peers dial * into (iroh://). Endpoint-level connection events arrive via * the onIrohEndpointEvent SDK setting. */ iroh: { /** * Start (or return) the shared endpoint. Idempotent: if already * running its info is returned and the config is ignored (first * configuration wins). Rejects on misconfiguration. */ setup(settings: IrohEndpointSettings): Promise; /** Endpoint identity/state (starting it env-configured if needed). */ info(): Promise; /** * Replace the inbound allow-list ('any' to accept every peer). * Revocation is immediate: live connections from delisted peers are * closed. */ setAllowedPeers(peers: string[] | 'any'): Promise; /** * Every live iroh listener's identity — the shared endpoint (slot 0) * plus any dedicated listeners, matched by the slot configured in * `irohDedicated`. The read-back for a dedicated listener's NodeId. */ listeners(): Promise; }; /** * Suppress the "No subscriber for node output, data is being dropped" * warning (and the matching recovery message) for a specific media * node. Use for fallback sources that are expected to spend long * stretches unsubscribed. Suppression state is cleared automatically * when the node exits. */ suppressNoSubscriberWarning(mediaNodeId: string): Promise; /** * Clear a prior `suppressNoSubscriberWarning` for a node. */ unsuppressNoSubscriberWarning(mediaNodeId: string): Promise; ndiDiscovery(settings: NdiDiscoverySettings): Promise st2110: NorskSystemST2110 } /** * @public * TODO * see: {@link NorskTransform.videoEncode} */ export interface NorskSystemST2110 { node(settings: ST2110NmosNodeSettings): Promise; nic(settings: ST2110NicSettings): Promise; /** * Like `nic`, but resolves once the server-side gen_server is up and * preflight validation has passed — without waiting for MTL_Init / * MTL_Start to finish. Useful for orchestration paths that want * downstream component creation (NMOS devices, senders, receivers) * to overlap with NIC bring-up. The returned NIC's `initialised` * Promise still resolves when MTL is fully ready, if you want to * await it separately before sending or receiving packets. */ nicAfterPreflight(settings: ST2110NicSettings): Promise; /** * Ask the server to pick a NUMA-local set of DPDK lcores for the * given NIC. The server reads the NIC's `local_cpulist` plus the * kernel's `isolcpus` / `nohz_full` sets, so callers don't have to * know the host's CPU topology themselves — handy for tests that * may run on different hardware in CI. * * The returned shape is exactly what `scheduling: { type: "pinned", … }` * on a subsequent `nic()` call expects. * * @public */ recommendCoresFor( settings: { nic: Nic; count: number } ): Promise<{ mainCore: number; otherCores: number[] }>; } export interface ST2110InternalClock { type: "internal"; name: string; } /** * PTP profile version. `IEEE1588-2008` (PTPv2 — used by SMPTE 2059-2 * and AES67) is the default and what virtually every modern ST 2110 * deployment will report; `IEEE1588-2002` (PTPv1) is legacy. */ export type ST2110PtpVersion = "IEEE1588-2008" | "IEEE1588-2002"; export interface ST2110PtpClock { type: "ptp"; name: string; traceable: boolean; gmid: string; locked: boolean; /** * PTP profile version. Defaults to `"IEEE1588-2008"`. Advertised in * IS-04 node clocks `version` field and as the `` token * in outgoing SDP `ts-refclk:ptp=::` lines. */ version?: ST2110PtpVersion; /** * PTP domain number (0–127). SMPTE 2059-2 typically uses 127. * Defaults to 127. Surfaces in the outgoing SDP * `ts-refclk:ptp=::`. MTL auto-detects the * domain from incoming PTP messages on the wire, so this field is * descriptive — set it to whatever your grandmaster advertises. */ domainNumber?: number; } export type ST2110Clock = ST2110InternalClock | ST2110PtpClock; /** * @public * Settings to create an ST2110 NMOS Node * see: {@link NorskSystemST2110.node} * */ export interface ST2110NmosNodeSettings { label: string; description: string; ip: string; hostname: string; chassisId?: string; clocks: ST2110Clock[]; interfaces?: { name: string, macAddress: string }[]; tags: { [k: string]: string[] }, onClose?: (() => Promise) | (() => void), onError?: (error: Error) => void; } /** * @public * see: {@link NorskSystemST2110.node} */ export class ST2110NmosNode { /** @internal */ grpcStream: grpc.ClientReadableStream; /** @internal */ initialised: Promise; /** @internal */ client: MediaClientCommon; /** @internal */ nmosNodeId: string; /** @internal */ clocks: ST2110Clock[]; /** @internal */ unregisterNode: (node: MediaNodeState) => void; /** @internal */ static async create(settings: ST2110NmosNodeSettings, client: MediaClientCommon, unregisterNode: (node: MediaNodeState) => void) { const hardware = await client.norsk.system.hardwareInfo(); const nics = hardware.nics.filter((nic: Nic) => nic.st2110Capable) const node = new ST2110NmosNode(settings, nics, client, unregisterNode); await node.initialised; return node; } /** @internal */ constructor(settings: ST2110NmosNodeSettings, nics: Nic[], client: MediaClientCommon, unregisterNode: (node: MediaNodeState) => void) { this.clocks = settings.clocks; this.client = client; this.unregisterNode = unregisterNode; const config = provideFull(ST2110NmosNodeConfigurationSchema, { label: settings.label, description: settings.description, ip: settings.ip, hostname: settings.hostname, chassisId: settings.chassisId == undefined ? settings.hostname : settings.chassisId, clocks: settings.clocks.map((clock) => { return provideFull(ST2110ClockSchema, { clock: (() => { switch (clock.type) { case "internal": return mkCase({ internal: provideFull(ST2110Clock_ST2110InternalClockSchema, { name: clock.name }) }); case "ptp": return mkCase({ ptp: provideFull(ST2110Clock_ST2110PTPClockSchema, { name: clock.name, traceable: clock.traceable, gmid: clock.gmid, locked: clock.locked, version: (() => { switch (clock.version ?? "IEEE1588-2008") { case "IEEE1588-2008": return ST2110PtpVersionPB.ST2110_PTP_VERSION_IEEE1588_2008; case "IEEE1588-2002": return ST2110PtpVersionPB.ST2110_PTP_VERSION_IEEE1588_2002; } })(), // Default 127 matches SMPTE 2059-2 convention. domainNumber: clock.domainNumber ?? 127 }) }); default: exhaustiveCheck(clock); } })() }); }), interfaces: settings.interfaces == undefined ? nics.map((nic: Nic) => { return provideFull(ST2110InterfaceSchema, { name: nic.ifname, macAddress: nic.macAddress.replace(/:/g, "-") }) }) : settings.interfaces.map((iface) => provideFull(ST2110InterfaceSchema, iface)), tags: Object.fromEntries( Object.entries(settings.tags).map( ([k, v]) => [k, provideFull(StringListSchema, { values: v })] ) ) }); this.grpcStream = this.client.media.createSystemST2110NmosNode(config); this.initialised = new Promise((resolve, reject) => { this.grpcStream.on("data", (data: ST2110NmosNodeEvent) => { const messageCase = data.message.case; switch (messageCase) { case undefined: break; case "nodeId": { this.nmosNodeId = data.message.value; resolve(); break; } default: exhaustiveCheck(messageCase); } }); registerNonNodeGrpc(this.grpcStream, "ST2110 NMOS Node", reject, settings.onClose, settings.onError); }); } public async createOutputDevice(settings: ST2110OutputSettings): Promise> { const sources: ST2110OutputSettings["sources"] = []; for (const src of settings.sources) { const flows: ST2110OutputSource["flows"] = []; for (const flow of src.flows) { const senders: ST2110OutputFlow["senders"] = []; for (const s of flow.senders) { const ip = await s.nic.ipAddress(); const senderConstraints = s.senderConstraints.map((c): ST2110SenderConstraintsInternal => ({ ...c, sourceIp: { default: ip } })); senders.push({ ...s, senderConstraints }); } flows.push({ ...flow, senders }); } sources.push({ ...src, flows }); } const internalSettings = { ...settings, sources }; const device = new ST2110OutputNode(internalSettings, this.nmosNodeId, this.clocks, this.client, this.unregisterNode); await device.initialised; return device; } public async createInputDevice(settings: ST2110InputSettings): Promise { const receivers: ST2110InputSettings["receivers"] = []; for (const r of settings.receivers) { const ip = await r.nic.ipAddress(); receivers.push({ ...r, receiverConstraints: r.receiverConstraints.map((c) => ({ ...c, sourceIp: { default: ip }, interfaceIp: { default: ip }, })), }); } const internalSettings = { ...settings, receivers }; const device = new ST2110InputNode(internalSettings, this.nmosNodeId, this.client, this.unregisterNode); await device.initialised; return device; } } export type ST2110DhcpAddress = { type: "dhcp"; } export type ST2110StaticAddress = { type: "static"; address: string; } /** * @public * Settings to create an ST2110 Nic * see: {@link NorskSystemST2110.nic} * */ /** * How MTL schedules its internal tasklets for an ST 2110 NIC. * * - `pinned`: MTL pins each scheduler to its own DPDK lcore. Required * for ST 2110-A (narrow) deterministic timing. Caller supplies the * EAL main lcore and a list of worker lcores; all of them should be * on the same NUMA node as the NIC. * - `free`: Schedulers run as pthreads (MTL_FLAG_TASKLET_THREAD). * Caller supplies only the EAL main lcore. Suitable for ST 2110-C * (wide) and dev/test where strict pinning isn't a goal. * * @public */ export type ST2110SchedulingMode = | { type: "pinned"; mainCore: number; otherCores: number[] } | { type: "free"; mainCore: number }; export interface ST2110NicSettings { id: string; scheduling: ST2110SchedulingMode; /** * Primary NIC. For a single-port (non-redundant) host this is the * only NIC bound. For ST 2022-7 hitless redundancy also set * `secondaryNic` + `secondaryAddress`. */ nic: Nic; /** * Optional. When set, this MTL instance binds both NICs and every * sender / receiver registered on the host gets a redundant outbound * (or inbound) path on the second NIC. Requires `secondaryAddress` to * be set alongside it. */ secondaryNic?: Nic; numTxQueues: number; numRxQueues: number; address: ST2110DhcpAddress | ST2110StaticAddress; /** * Optional. Required iff `secondaryNic` is set — gives the secondary * NIC its DHCP / static address. The two NICs typically sit on * different subnets (red/blue networks), so the addresses are * configured per-port rather than shared. */ secondaryAddress?: ST2110DhcpAddress | ST2110StaticAddress; /** * Optional ceiling on how long {@link NorskSystemST2110.nic} waits * for the NIC to become usable (MTL_Start complete + all ports' * IP addresses known). Defaults to 30000 ms. Override upward for * slow-boot deployments where PTP convergence or DHCP takes * longer; downward for tight failover scenarios where you'd rather * fail fast than block. */ initialiseTimeoutMs?: number; /** * Hugetlbfs mount path that this NIC's DPDK primary should use as * `--huge-dir`. Required when more than one DPDK primary runs in the * same norsk instance (e.g. norsk-core's allocator plus multiple ST * 2110 NICs); EAL takes an exclusive flock on the hugetlbfs mount * root during init, so each primary needs its own mount. The path * must be a hugetlbfs mount provided by the operator at host / * container setup time — norsk does not create it. * * Leave undefined when this is the only DPDK primary in the * container, in which case EAL picks a default mount. */ hugeDir?: string; onClose?: (() => Promise) | (() => void), onError?: (error: Error) => void; /** * Called whenever the NIC's bring-up advances to a new stage. Mirrors * the proto `ST2110NicStageChanged` sequence: "initialising" → * "loadingHost" → "waitingMtl" → "ready"; or "failed" with a `reason` * if any phase rejects. The `nic()` promise still resolves on the * legacy `mtl_ready` signal — this callback is purely for progress * reporting (UI status, structured logs). */ onStageChange?: (event: ST2110NicStageEvent) => void; } /** * @public * NIC bring-up lifecycle stage. The sequence is: * * "initialising" — supervisor's gen_server is up; pre-flight checks * passed; heavy work queued via self-message * "loadingHost" — spawning the isox plugin host OS process * "waitingMtl" — MTL_Init / DPDK / NIC ramp running inside the host; * awaiting completion * "ready" — MTL up and accepting per-session create calls; the * legacy `mtl_ready` event also fires on this edge * "failed" — any phase rejected; `reason` carries the message * "dhcpTimedOut" — MTL is up but at least one DHCP-configured port * didn't acquire a lease within the server-side * timeout. Non-terminal — polling continues, and * a later lease transitions back to "ready". The * `reason` field carries the port number and a * human-readable remediation hint. */ export type ST2110NicStageEvent = | { state: "initialising" } | { state: "loadingHost" } | { state: "waitingMtl" } | { state: "ready" } | { state: "failed"; reason: string } | { state: "dhcpTimedOut"; reason: string }; /** @internal */ function mapNicStageEvent( state: ST2110NicStageChanged_Stage, reason: string, ): ST2110NicStageEvent | undefined { switch (state) { case ST2110NicStageChanged_Stage.ST2110_NIC_STAGE_INITIALISING: return { state: "initialising" }; case ST2110NicStageChanged_Stage.ST2110_NIC_STAGE_LOADING_HOST: return { state: "loadingHost" }; case ST2110NicStageChanged_Stage.ST2110_NIC_STAGE_WAITING_MTL: return { state: "waitingMtl" }; case ST2110NicStageChanged_Stage.ST2110_NIC_STAGE_READY: return { state: "ready" }; case ST2110NicStageChanged_Stage.ST2110_NIC_STAGE_FAILED: return { state: "failed", reason }; case ST2110NicStageChanged_Stage.ST2110_NIC_STAGE_DHCP_TIMED_OUT: return { state: "dhcpTimedOut", reason }; default: return undefined; } } /** * @public * see: {@link NorskSystemST2110.nic} */ export class ST2110Nic { /** @internal */ grpcStream: grpc.ClientReadableStream; /** * @public * Resolves once the NIC is *actually usable*: MTL_Init / MTL_Start have * completed inside the isox host and an IP address is known (from * static config or DHCP). Rejects with the failure reason if any * later step in the bring-up errors — including the deep zig-side * preflight, which fires asynchronously after `preflightComplete` * has already resolved. Callers that don't need to await readiness * SHOULD still attach a `.catch` so a late failure isn't logged as * an unhandled rejection by Node; `settings.onError` is the * recommended place to handle that failure for UI surfacing. */ initialised: Promise; /** * @public * Resolves once the server-side gen_server is up and its preflight * validation has passed (i.e. the NIC accepts commands), but BEFORE * MTL_Init / MTL_Start have completed and BEFORE any IP address has * been learned. Use this when downstream resource creation (NMOS * devices, senders, receivers) can proceed without the wire being * live yet — Norsk will queue any operation that requires MTL until * MTL_Start completes. Use `initialised` instead when you intend to * push or receive packets immediately. Rejects if preflight fails. */ preflightComplete: Promise; /** @internal */ nicId: string; /** @internal */ nic: Nic; /** * @internal * Full list of NICs the MTL host is bound to (1 for single-port, 2 for * ST 2022-7). Senders and receivers read this list to populate per- * port `interface_name` arrays for IS-04 interface_bindings. */ nics: Nic[]; /** * @internal * One slot per port. `undefined` until the corresponding port's IP * has been learned (DHCP) or seeded (static). For single-port hosts * the array length is 1; for 2022-7 hosts it's 2. */ _ipAddresses: (string | undefined)[]; pendingIpAddressRequests: ((ipAddress: string) => void)[] = []; /** * @public * Resolves to the primary NIC's assigned IP. For 2022-7 hosts use * `ipAddresses()` to observe both ports. */ public async ipAddress(): Promise { return (new Promise((resolve, _reject) => { const primary = this._ipAddresses[0]; if (primary) { resolve(primary); } else { this.pendingIpAddressRequests.push(resolve); } })); } /** * @public * Per-port IPs in NIC order (primary first). Length is 1 for single- * port hosts and 2 for ST 2022-7 hosts. Resolves only once *every* * port's IP is known (relevant for DHCP NICs that arrive * independently). */ public async ipAddresses(): Promise { await this.initialised; return this._ipAddresses.map(ip => ip ?? ""); } /** @internal */ static async create(settings: ST2110NicSettings, client: MediaClient) { const node = new ST2110Nic(settings, client); await node.initialised; return node; } /** * @internal * Sibling of `create` that returns once the server-side gen_server is * up + preflight passes — without waiting for MTL_Init / MTL_Start. * Use for orchestration paths that want downstream component setup * to proceed in parallel with the NIC bring-up. The returned NIC's * `initialised` Promise still tracks full readiness if you need to * await it separately. */ static async createAfterPreflight(settings: ST2110NicSettings, client: MediaClient) { const node = new ST2110Nic(settings, client); await node.preflightComplete; return node; } /** @internal */ constructor(settings: ST2110NicSettings, client: MediaClient) { this.nic = settings.nic; if ((settings.secondaryNic == null) !== (settings.secondaryAddress == null)) { throw new Error("ST2110 NIC: secondaryNic and secondaryAddress must be set together"); } this.nics = settings.secondaryNic ? [settings.nic, settings.secondaryNic] : [settings.nic]; const toEntry = (nic: Nic, addr: ST2110DhcpAddress | ST2110StaticAddress): ST2110NicEntry => { const sourceAddress: ST2110NicEntry['sourceAddress'] = (() => { switch (addr.type) { case "dhcp": return { case: "dhcp", value: provideFull(EmptySchema, {}) }; case "static": return { case: "static", value: addr.address }; } })(); return provideFull(ST2110NicEntrySchema, { pciBdf: nic.pciBdf, sourceAddress, }); }; const nicEntries: ST2110NicEntry[] = settings.secondaryNic ? [toEntry(settings.nic, settings.address), toEntry(settings.secondaryNic, settings.secondaryAddress!)] : [toEntry(settings.nic, settings.address)]; const scheduling = (() => { switch (settings.scheduling.type) { case "pinned": return { case: "pinned" as const, value: provideFull(ST2110SchedulingPinnedSchema, { mainLCore: settings.scheduling.mainCore, otherCores: settings.scheduling.otherCores, }), }; case "free": return { case: "free" as const, value: provideFull(ST2110SchedulingFreeSchema, { mainLCore: settings.scheduling.mainCore, }), }; } })(); const config = provideFull(ST2110NicConfigurationSchema, { id: settings.id, nics: nicEntries, numTxQueues: settings.numTxQueues, numRxQueues: settings.numRxQueues, dpdkHugeDir: settings.hugeDir, scheduling: scheduling, }); this.grpcStream = client.createSystemST2110Nic(config); // `initialised` resolves only once the NIC is *actually usable*, which // means BOTH: // - mtlReady: zig has finished mtl_init + mtl_start; the DPDK/MTL // pipeline is live (sent by the server after MTL_Start returns). // - an IP is known: from settings (static) or from the server's // ipAddress event (DHCP). // // Resolving on nicId alone is wrong — that fires as soon as the // server-side gen_server is up, which is several seconds before // mtl_init even begins. Code that proceeds at that point can race // ahead and try to use a NIC whose MTL isn't running yet. // // The 5s warning + 30s timeout guard against DHCP failures (no DHCP // server / wrong VLAN / NIC has no carrier) which would otherwise // surface as a silent hang inside createInputDevice / createOutputDevice // when those internally await `nic.ipAddress()`. // Seed per-port slots: static addresses are known up front, DHCP // slots stay undefined until the server reports them via the // ipAddress event. The array length matches the number of NICs the // host is binding (1 or 2). this._ipAddresses = [ settings.address.type === "static" ? settings.address.address : undefined, ...(settings.secondaryNic ? [settings.secondaryAddress!.type === "static" ? settings.secondaryAddress!.address : undefined] : []), ]; // Captured resolvers for the preflight-complete Promise. Resolved // on the `nicId` event (gen_server up; preflight passed); rejected // on `stage_changed: failed` or any gRPC error. The Promise itself // is constructed below in lockstep with `initialised` so that a // single failure path settles both. let resolvePreflight!: () => void; let rejectPreflight!: (reason?: unknown) => void; let preflightSettled = false; this.preflightComplete = new Promise((resolve, reject) => { resolvePreflight = () => { if (preflightSettled) return; preflightSettled = true; resolve(); }; rejectPreflight = (reason?: unknown) => { if (preflightSettled) return; preflightSettled = true; reject(reason); }; }); this.initialised = new Promise((resolve, reject) => { let mtlReady = false; const clearTimers = () => { clearTimeout(slowWarn); clearTimeout(failTimer); }; const allPortsHaveIp = () => this._ipAddresses.every(ip => ip !== undefined); const tryResolve = () => { if (mtlReady && allPortsHaveIp()) { clearTimers(); resolve(); } }; // Wrap reject so a backend-side gRPC error (e.g. preflight rejected // the NIC) doesn't leave our 30s failTimer pending — the timer would // otherwise hold the event loop open until it eventually fires. // Also settles preflightComplete: callers awaiting the earlier // signal should learn about a hard failure too. const rejectAndClear = (reason?: unknown) => { clearTimers(); rejectPreflight(reason); reject(reason); }; const failTimeoutMs = settings.initialiseTimeoutMs ?? 30000; // Warn one-sixth of the way to fail so the user gets actionable // diagnostic well before the hard timeout fires (5s out of 30s // by default). const slowWarnMs = Math.min(5000, Math.floor(failTimeoutMs / 6)); const slowWarn = setTimeout(() => { console.warn( `ST2110 NIC "${settings.id}": still waiting for IP address after ${slowWarnMs}ms. ` + `If using DHCP, verify a DHCP server is reachable on this interface; ` + `otherwise switch to address: { type: "static", address: "..." }.` ); }, slowWarnMs); const failTimer = setTimeout(() => { clearTimeout(slowWarn); reject(new Error( `ST2110 NIC "${settings.id}": no IP address received within ${failTimeoutMs}ms. ` + `DHCP may not be available on this interface — consider using a ` + `static address: { type: "static", address: "..." } ` + `or extend initialiseTimeoutMs in ST2110NicSettings.` )); }, failTimeoutMs); this.grpcStream.on("data", (data: ST2110NicEvent) => { const messageCase = data.message.case; switch (messageCase) { case undefined: break; case "nicId": { this.nicId = data.message.value; // The server-side gen_server is up + preflight has passed. // Settle the early-resolution Promise so callers awaiting // `createAfterPreflight` can proceed. The full // `initialised` Promise still waits on mtlReady + ipAddress. resolvePreflight(); break; } case "mtlReady": { mtlReady = true; tryResolve(); break; } case "portIpAddress": { const { port, ipAddress } = data.message.value; if (port >= this._ipAddresses.length) { console.warn(`ST2110 NIC "${settings.id}": ignoring ipAddress for unknown port ${port}`); break; } this._ipAddresses[port] = ipAddress; if (port === 0) { // Primary IP — wake any callers blocked on ipAddress(). this.pendingIpAddressRequests.forEach((resolve) => resolve(ipAddress)); this.pendingIpAddressRequests = []; } tryResolve(); break; } case "stageChanged": { const { stage, reason } = data.message.value; const event = mapNicStageEvent(stage, reason); if (event) { settings.onStageChange?.(event); if (event.state === "failed") { // Preflight-stage failures need to settle the // early-resolution Promise; a caller awaiting // `createAfterPreflight` would otherwise hang. // `rejectAndClear` covers `initialised` too. rejectAndClear(new Error( `ST2110 NIC "${settings.id}": preflight failed: ${event.reason}` )); } } break; } default: exhaustiveCheck(messageCase); } }); registerNonNodeGrpc(this.grpcStream, "ST2110 NIC", rejectAndClear, settings.onClose, settings.onError); }); } }