import { useEffect, useState } from 'react'; import { DSP408 } from '../../../../types'; import { Channel, InputChannel, OutputChannel } from '../../types'; import Button from '../../../../components/Button/Button'; import Card from '../../../../components/Card/Card'; import './LinkChannelsModal.css'; const INPUT_CHANNELS: readonly InputChannel[] = Object.values(InputChannel); const OUTPUT_CHANNELS: readonly OutputChannel[] = Object.values(OutputChannel); type LinkMode = 'input' | 'output'; type LinkGroup = { source: Channel; destinations: Channel[]; }; /** * Maps every channel (by its raw enum string) to the channel it currently * follows. A channel that follows itself is unlinked. Several channels can * follow the same leader (many-to-one), but a channel that is itself * following someone can't also be followed by others - no chains. */ type LeaderMap = Record; const wrapInputChannel = (raw: string): Channel => ({ Input: raw as InputChannel }); const wrapOutputChannel = (raw: string): Channel => ({ Output: raw as OutputChannel }); /** * Gets the raw enum string (InA, Out1, ...) out of a channel entry. * The device state has been observed to store these as plain strings * (e.g. "InA") rather than the wrapped { Input: ... } / { Output: ... } * shape used elsewhere, so this accepts either form defensively. */ function channelRawId(channel: Channel | string): string { if (typeof channel === 'string') { return channel; } return 'Input' in channel ? channel.Input : channel.Output; } /** * Breaks any chains that might be present in a leader map (a channel that * both follows someone and is followed by others) by releasing whoever * follows a follower back to themselves. Keeps the invariant - a channel * can lead or follow, never both - true regardless of what the device * reported. */ function sanitizeLeaderMap(leaderOf: LeaderMap): LeaderMap { const next = { ...leaderOf }; Object.keys(next).forEach((channel) => { const leader = next[channel]; if (leader !== channel && next[leader] !== leader) { // `channel` follows a leader that itself follows someone else - // release `channel` back to itself to break the chain. next[channel] = channel; } }); return next; } /** * Reads the DSP's real link state (per-channel `linked_channels: Channel[]`) * and turns it into a leader map, so the modal opens already reflecting the * device. Every channel defaults to following itself (unlinked); channels * reported as linked follow whichever channel the device reported first. */ function extractLeaderMap( states: Partial> | undefined, orderedChannels: readonly T[] ): LeaderMap { const leaderOf: LeaderMap = {}; orderedChannels.forEach((channel) => { leaderOf[channel] = channel; }); if (!states) { return leaderOf; } orderedChannels.forEach((channel) => { const state = states[channel]; if (!state || state.linked_channels.length === 0) { return; } const leaderId = channelRawId(state.linked_channels[0]); if (leaderId !== channel) { leaderOf[channel] = leaderId; } }); return sanitizeLeaderMap(leaderOf); } /** * Turns a leader map into one linkChannels call per leader that has * followers (source = leader, destinations = its followers), plus one call * per still-unlinked, follower-less channel with no destinations. Channels * that are themselves following someone don't get their own call - they're * already covered by their leader's call. */ function buildLinkCalls( leaderOf: LeaderMap, orderedChannels: readonly string[], wrap: (raw: string) => Channel ): LinkGroup[] { const followersByLeader = new Map(); orderedChannels.forEach((channel) => { const leader = leaderOf[channel]; if (leader === channel) { return; } const existing = followersByLeader.get(leader) ?? []; existing.push(channel); followersByLeader.set(leader, existing); }); const calls: LinkGroup[] = []; orderedChannels.forEach((channel) => { const followers = followersByLeader.get(channel); if (followers) { calls.push({ source: wrap(channel), destinations: followers.map(wrap) }); return; } if (leaderOf[channel] === channel) { calls.push({ source: wrap(channel), destinations: [] }); } }); return calls; } function LinkChannelsModal({ dsp, onClose, linkChannels, }: { dsp: DSP408; onClose: () => void; linkChannels: (source: Channel, destinations: Channel[]) => Promise; }) { const [mode, setMode] = useState('input'); const [inputLeaders, setInputLeaders] = useState(() => extractLeaderMap(dsp.state?.current_config?.input_states, INPUT_CHANNELS) ); const [outputLeaders, setOutputLeaders] = useState(() => extractLeaderMap(dsp.state?.current_config?.output_states, OUTPUT_CHANNELS) ); const [isSaving, setIsSaving] = useState(false); const channels: readonly string[] = mode === 'input' ? INPUT_CHANNELS : OUTPUT_CHANNELS; const leaderOf = mode === 'input' ? inputLeaders : outputLeaders; const setLeaderOf = mode === 'input' ? setInputLeaders : setOutputLeaders; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); const followChannel = (follower: string, leader: string) => { setLeaderOf((prev) => { const next = { ...prev }; // `follower` can no longer lead anyone once it follows `leader` - // release whoever currently follows `follower` back to themselves. Object.keys(next).forEach((channel) => { if (next[channel] === follower) { next[channel] = channel; } }); next[follower] = leader; return next; }); }; const unlinkChannel = (channel: string) => { setLeaderOf((prev) => ({ ...prev, [channel]: channel })); }; const handleCellClick = (row: string, col: string) => { const isSelf = row === col; if (isSelf) { if (leaderOf[row] !== row) { unlinkChannel(row); } return; } if (leaderOf[row] !== row || leaderOf[col] === row) { // Row is locked (it's itself following someone) or this pair is // already the current state - nothing to do. return; } followChannel(col, row); }; const handleValidate = async () => { setIsSaving(true); try { const groups = [ ...buildLinkCalls(inputLeaders, INPUT_CHANNELS, wrapInputChannel), ...buildLinkCalls(outputLeaders, OUTPUT_CHANNELS, wrapOutputChannel), ]; for (const group of groups) { // eslint-disable-next-line no-await-in-loop await linkChannels(group.source, group.destinations); } onClose(); } finally { setIsSaving(false); } }; return (
{ if (e.target === e.currentTarget) { onClose(); } }} >

Link Channels

Link one or more channels to a single channel. A channel not linked to another is linked to itself.

{channels.map((col) => (
{col}
))} {channels.map((row, rowIndex) => { const isRowLocked = leaderOf[row] !== row; return (
{row}
{channels.map((col, colIndex) => { if (colIndex < rowIndex) { return (
); } const isSelf = row === col; const isActive = isSelf ? leaderOf[row] === row : leaderOf[col] === row; const isUnavailable = !isSelf && !isActive && isRowLocked; const label = isSelf ? isActive ? `${row} not linked` : `Unlink ${row}` : isUnavailable ? `${row} is already linked to another channel` : isActive ? `${col} linked to ${row}` : `Link ${col} to ${row}`; return (
); })}
); } export default LinkChannelsModal;