From 92040c832b6c245240945cc86fca12255a962fa5 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Fri, 23 Jan 2026 23:01:35 -0800 Subject: [PATCH] fix 0x141a record parsing and add dante channel type detection Co-Authored-By: Claude Opus 4.5 --- dante_control.go | 99 +++++++++++++++++++++++++++------ notes/dantepacket.md | 127 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 195 insertions(+), 31 deletions(-) diff --git a/dante_control.go b/dante_control.go index 24b762d..f0ec532 100644 --- a/dante_control.go +++ b/dante_control.go @@ -171,10 +171,11 @@ func (d *DanteFlows) LogAll() { }) type channelFlow struct { - sourceName string - txCh string - rxName string - rxCh string + sourceName string + txCh string + rxName string + rxCh string + channelType string } var allChannelFlows []channelFlow var allNoChannelFlows []string @@ -196,11 +197,18 @@ func (d *DanteFlows) LogAll() { for _, ch := range sub.Channels { parts := strings.Split(ch, "->") if len(parts) == 2 { + rxPart := parts[1] + chType := "" + if idx := strings.LastIndex(rxPart, ":"); idx != -1 { + chType = rxPart[idx+1:] + rxPart = rxPart[:idx] + } allChannelFlows = append(allChannelFlows, channelFlow{ - sourceName: sourceName, - txCh: parts[0], - rxName: subName, - rxCh: parts[1], + sourceName: sourceName, + txCh: parts[0], + rxName: subName, + rxCh: rxPart, + channelType: chType, }) } else { allNoChannelFlows = append(allNoChannelFlows, fmt.Sprintf("%s -> %s[%s]", sourceName, subName, ch)) @@ -225,7 +233,11 @@ func (d *DanteFlows) LogAll() { sort.Strings(allNoChannelFlows) for _, cf := range allChannelFlows { - log.Printf("[sigusr1] %s[%s] -> %s[%s]", cf.sourceName, cf.txCh, cf.rxName, cf.rxCh) + if cf.channelType != "" { + log.Printf("[sigusr1] %s[%s] -> %s[%s] (%s)", cf.sourceName, cf.txCh, cf.rxName, cf.rxCh, cf.channelType) + } else { + log.Printf("[sigusr1] %s[%s] -> %s[%s]", cf.sourceName, cf.txCh, cf.rxName, cf.rxCh) + } } for _, flow := range allNoChannelFlows { log.Printf("[sigusr1] %s", flow) @@ -266,8 +278,11 @@ func (t *Tendrils) queryDanteDeviceWithPort(ip net.IP, port int) *DanteDeviceInf if t.DebugDante { log.Printf("[dante] %s: 0x3000 returned %d subscriptions, hasMulticast=%v", ip, len(info.Subscriptions), info.HasMulticast) } - if len(info.Subscriptions) == 0 && info.RxChannelCount > 0 { - info.Subscriptions = t.queryDanteSubscriptions3400(conn, ip, info.RxChannelCount) + if info.RxChannelCount > 0 && (len(info.Subscriptions) == 0 || info.HasMulticast) { + subs3400 := t.queryDanteSubscriptions3400(conn, ip, info.RxChannelCount) + if len(subs3400) > 0 { + info.Subscriptions = subs3400 + } } } @@ -288,10 +303,30 @@ type DanteDeviceInfo struct { HasMulticast bool } +type DanteChannelType uint16 + +const ( + DanteChannelUnknown DanteChannelType = 0 + DanteChannelAudio DanteChannelType = 0x000f + DanteChannelVideo DanteChannelType = 0x000e +) + +func (t DanteChannelType) String() string { + switch t { + case DanteChannelAudio: + return "audio" + case DanteChannelVideo: + return "video" + default: + return "" + } +} + type DanteSubscription struct { RxChannel int TxDeviceName string TxChannelName string + ChannelType DanteChannelType } func buildDantePacket(cmd uint16, args []byte) []byte { @@ -610,12 +645,34 @@ func (t *Tendrils) queryDanteSubscriptions3400(conn *net.UDPConn, ip net.IP, rxC break } rawOffset := int(binary.BigEndian.Uint16(resp[offsetPos : offsetPos+2])) - if rawOffset+48 > len(resp) { + if rawOffset+28 > len(resp) { continue } - txChOffset := int(binary.BigEndian.Uint16(resp[rawOffset+44 : rawOffset+46])) - txDevOffset := int(binary.BigEndian.Uint16(resp[rawOffset+46 : rawOffset+48])) + var channelType DanteChannelType + var txChOffset, txDevOffset int + + marker := binary.BigEndian.Uint16(resp[rawOffset : rawOffset+2]) + if marker == 0x141c { + if rawOffset+48 > len(resp) { + log.Printf("[ERROR] [dante] %s: 0x3400 record %d at 0x%04x: 0x141c record truncated (need %d, have %d)", ip, i, rawOffset, rawOffset+48, len(resp)) + continue + } + channelType = DanteChannelType(binary.BigEndian.Uint16(resp[rawOffset+14 : rawOffset+16])) + txChOffset = int(binary.BigEndian.Uint16(resp[rawOffset+44 : rawOffset+46])) + txDevOffset = int(binary.BigEndian.Uint16(resp[rawOffset+46 : rawOffset+48])) + } else if marker == 0x141a { + if rawOffset+48 > len(resp) { + log.Printf("[ERROR] [dante] %s: 0x3400 record %d at 0x%04x: 0x141a record truncated", ip, i, rawOffset) + continue + } + channelType = DanteChannelVideo + txChOffset = int(binary.BigEndian.Uint16(resp[rawOffset+44 : rawOffset+46])) + txDevOffset = int(binary.BigEndian.Uint16(resp[rawOffset+46 : rawOffset+48])) + } else { + log.Printf("[ERROR] [dante] %s: 0x3400 record %d at 0x%04x: unknown marker 0x%04x (bytes: %x)", ip, i, rawOffset, marker, resp[rawOffset:rawOffset+8]) + continue + } if txChOffset == 0 && txDevOffset == 0 { continue @@ -635,13 +692,14 @@ func (t *Tendrils) queryDanteSubscriptions3400(conn *net.UDPConn, ip net.IP, rxC rxChannel := startChannel + i if t.DebugDante { - log.Printf("[dante] %s: 0x3400 sub: rx=%d txDev=%q txCh=%q", ip, rxChannel, txDeviceName, txChannelName) + log.Printf("[dante] %s: 0x3400 sub: rx=%d txDev=%q txCh=%q type=%s", ip, rxChannel, txDeviceName, txChannelName, channelType) } subscriptions = append(subscriptions, DanteSubscription{ RxChannel: rxChannel, TxDeviceName: txDeviceName, TxChannelName: txChannelName, + ChannelType: channelType, }) } @@ -674,8 +732,8 @@ func (t *Tendrils) probeDanteDeviceWithPort(ip net.IP, port int) { needIGMPFallback := info.HasMulticast && info.Name != "" for _, sub := range info.Subscriptions { if t.DebugDante { - log.Printf("[dante] %s: subscription rx=%d -> %s@%s", - ip, sub.RxChannel, sub.TxChannelName, sub.TxDeviceName) + log.Printf("[dante] %s: subscription rx=%d -> %s@%s type=%s", + ip, sub.RxChannel, sub.TxChannelName, sub.TxDeviceName, sub.ChannelType) } if sub.TxDeviceName != "" && info.Name != "" { txDeviceName := sub.TxDeviceName @@ -684,7 +742,12 @@ func (t *Tendrils) probeDanteDeviceWithPort(ip net.IP, port int) { } channelInfo := "" if sub.TxChannelName != "" { - channelInfo = fmt.Sprintf("%s->%02d", sub.TxChannelName, sub.RxChannel) + typeStr := sub.ChannelType.String() + if typeStr != "" { + channelInfo = fmt.Sprintf("%s->%02d:%s", sub.TxChannelName, sub.RxChannel, typeStr) + } else { + channelInfo = fmt.Sprintf("%s->%02d", sub.TxChannelName, sub.RxChannel) + } } sourceNode := t.nodes.GetOrCreateByName(txDeviceName) subscriberNode := t.nodes.GetOrCreateByName(info.Name) diff --git a/notes/dantepacket.md b/notes/dantepacket.md index 109c597..66f1e9e 100644 --- a/notes/dantepacket.md +++ b/notes/dantepacket.md @@ -72,19 +72,44 @@ Zero offset = no more records. ### Subscription Record -Each record is 56+ bytes. The offset table points to record start. +Two record formats exist, distinguished by marker position. + +#### Format 1: 0x141c records (numbered audio/video channels) ``` -record+0x00: [40] record_header -record+0x28: u16 0x0608 # marker -record+0x2a: u16 0x0000 +record+0x00: u16 0x141c # record marker +record+0x02: u16 channel_num # RX channel number +record+0x04: u16 0x0000 +record+0x06: u16 0x0003 +record+0x08: u16 channel_num # repeated +record+0x0a: u16 0x0000 +record+0x0c: u16 0x0000 +record+0x0e: u16 channel_type # 0x000f=audio, 0x000e=video +... record+0x2c: u16 tx_ch_offset # absolute offset to TX channel name string record+0x2e: u16 tx_dev_offset # absolute offset to TX device name string -record+0x30: [4] flags -record+0x34: u32 0x02020000 # string marker -record+0x38: ... inline_strings # (unreliable, use offsets above) ``` +**Channel type (at record+0x0e):** +- `0x000f` = audio channel +- `0x000e` = video channel + +#### Format 2: 0x141a records (special channels: Video, Serial, USB) + +``` +record+0x00: u16 0x141a # record marker +record+0x02: u16 channel_num # e.g., 0x0009 = channel 9 +record+0x04: u16 ?? +record+0x06: u16 ?? +... +record+0x2c: u16 tx_ch_offset # same position as 0x141c +record+0x2e: u16 tx_dev_offset # same position as 0x141c +``` + +These are video channels (Dante AV "Video" aggregate channel). + +Both 0x141c and 0x141a records use the same offsets for tx_ch and tx_dev (+44 and +46). + **Subscription status:** - Both offsets non-zero = subscribed - Both offsets zero = unsubscribed @@ -126,9 +151,85 @@ Record at 0x0258 (RX ch 25): 1. Check magic=0x28, command=0x3400, status=0x8112 2. Read 16 offsets from 0x12-0x31 -3. For each non-zero offset: - - Read u16 at offset+0x2c (tx_ch_offset) - - Read u16 at offset+0x2e (tx_dev_offset) - - If both zero: skip (unsubscribed) - - Else: read null-terminated strings at those offsets - - RX channel = page_start + record_index +3. For each non-zero offset, detect record format: + - If offset+0x00 == 0x141c: Format 1 (numbered channel) + - channel_type at offset+0x0e (0x000f=audio, 0x000e=video) + - tx_ch_offset at offset+0x2c + - tx_dev_offset at offset+0x2e + - If offset+0x00 == 0x141a: Format 2 (special channel) + - Assume video type + - tx_ch_offset at offset+0x2c (same as 0x141c) + - tx_dev_offset at offset+0x2e (same as 0x141c) +4. If tx_ch_offset and tx_dev_offset are both zero: skip (unsubscribed) +5. Read null-terminated strings at those offsets +6. RX channel = page_start + record_index + +--- + +## Additional Commands + +### 0x2000 - TX Channel Info + +Request args (6 bytes): +``` +0x00: u16 0x0001 +0x02: u16 page_num +0x04: u16 0x0000 +``` + +Response contains TX channel entries: +``` +0x00: u16 channel_num +0x02: u16 channel_type # see below +0x04: u16 name_offset +0x06: u16 unknown +``` + +Observed channel_type values: +- `0x0107` = audio channel (seen on MICS, SQ-7, speaker devices) +- `0x0007` = possibly video or different encoding? + +Sample rate `0xbb80` (48000) appears in responses. + +### 0x3600 - TX Flow Info + +Uses magic=0x28. Returns info about outgoing flows. + +Response includes IP addresses of flow destinations. + +--- + +## Multicast Group Ranges + +Audio and video use different multicast IP ranges: +- `239.69.x.x` - `239.71.x.x` = Dante audio multicast +- `239.253.x.x` = Dante AV (video) multicast + +--- + +## Channel Type Detection + +**SOLVED**: Audio vs video channels are distinguished by the channel_type field at record+0x0e in 0x3400 responses: +- `0x000f` = audio channel +- `0x000e` = video channel + +Dante AV devices (TX-*, RX-* naming convention) have both audio and video channels. The type must be checked per-channel, not per-device. + +--- + +## Open Questions + +### Other Channel Types + +Video devices have additional channel types with marker 0x141a instead of 0x141c: +- "Video" channels (the actual video stream) +- "Serial" channels +- "USB" channels + +These use a different record structure. Need to decode the 0x141a record format. + +### 0x2000 Channel Type Field + +The 0x2000 response has a channel_type field at entry+0x02. Observed values: +- `0x0007` seen on Ultimo X (audio devices) +- Need to compare with video device 0x2000 responses