Initial commit: ArtNet packet sniffer and validator
Sniffs ArtNet packets on UDP port 6454 and validates against the Art-Net 4 spec. Currently validates ArtPoll and ArtPollReply packets. Features: - Structured logging with slog - Name filter regex (-name flag) - Verbose mode (-v flag) - Universe display as net:subnet:universe (portaddr) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
artcheck
|
||||
.claude/
|
||||
art-net.pdf
|
||||
132
main.go
Normal file
132
main.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultArtNetPort = 6454 // 0x1936
|
||||
)
|
||||
|
||||
var (
|
||||
verbose bool
|
||||
nameFilter *regexp.Regexp
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.Int("port", DefaultArtNetPort, "UDP port to listen on (default: 6454/0x1936)")
|
||||
bindAddr := flag.String("bind", "0.0.0.0", "IP address to bind to")
|
||||
namePattern := flag.String("name", "", "Filter by node name (regex)")
|
||||
flag.BoolVar(&verbose, "v", false, "Verbose output (show all field details)")
|
||||
flag.Parse()
|
||||
|
||||
if *namePattern != "" {
|
||||
var err error
|
||||
nameFilter, err = regexp.Compile(*namePattern)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Invalid name regex: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
ip := net.ParseIP(*bindAddr)
|
||||
if ip == nil {
|
||||
fmt.Fprintf(os.Stderr, "Invalid bind address: %s\n", *bindAddr)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
addr := net.UDPAddr{
|
||||
Port: *port,
|
||||
IP: ip,
|
||||
}
|
||||
|
||||
conn, err := net.ListenUDP("udp4", &addr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to listen on UDP port %d: %v\n", *port, err)
|
||||
fmt.Fprintf(os.Stderr, "You may need to run with sudo or use setcap cap_net_bind_service=+ep\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
slog.Info("listening", "addr", fmt.Sprintf("%s:%d", *bindAddr, *port))
|
||||
if *port != DefaultArtNetPort {
|
||||
slog.Warn("non-standard port", "expected", DefaultArtNetPort)
|
||||
}
|
||||
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
n, remoteAddr, err := conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
slog.Error("read failed", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
packet := make([]byte, n)
|
||||
copy(packet, buf[:n])
|
||||
|
||||
processPacket(packet, remoteAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func processPacket(data []byte, src *net.UDPAddr) {
|
||||
result := ValidatePacket(data, src)
|
||||
|
||||
// Apply name filter
|
||||
if nameFilter != nil {
|
||||
name := result.getField("PortName")
|
||||
if !nameFilter.MatchString(name) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Build log attributes
|
||||
attrs := []any{
|
||||
"src", src.IP.String(),
|
||||
"size", len(data),
|
||||
"type", result.PacketType,
|
||||
}
|
||||
|
||||
// Add summary fields based on packet type
|
||||
for _, kv := range result.SummaryAttrs() {
|
||||
attrs = append(attrs, kv.Key, kv.Value)
|
||||
}
|
||||
|
||||
// Add error/warning counts if any
|
||||
if len(result.Errors) > 0 {
|
||||
attrs = append(attrs, "errors", len(result.Errors))
|
||||
}
|
||||
if len(result.Warnings) > 0 {
|
||||
attrs = append(attrs, "warnings", len(result.Warnings))
|
||||
}
|
||||
|
||||
// Log the packet
|
||||
if len(result.Errors) > 0 {
|
||||
slog.Error("packet", attrs...)
|
||||
} else if len(result.Warnings) > 0 {
|
||||
slog.Warn("packet", attrs...)
|
||||
} else {
|
||||
slog.Info("packet", attrs...)
|
||||
}
|
||||
|
||||
// Always log individual warnings
|
||||
for _, w := range result.Warnings {
|
||||
slog.Warn("validation", "src", src.IP.String(), "warning", w)
|
||||
}
|
||||
|
||||
// Always log individual errors
|
||||
for _, e := range result.Errors {
|
||||
slog.Error("validation", "src", src.IP.String(), "error", e)
|
||||
}
|
||||
|
||||
// Verbose: log all fields
|
||||
if verbose && len(result.Fields) > 0 {
|
||||
for _, f := range result.Fields {
|
||||
slog.Debug("field", "src", src.IP.String(), "name", f.Name, "value", f.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
225
protocol.go
Normal file
225
protocol.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ArtNet Protocol Constants from Art-Net 4 Specification
|
||||
|
||||
// ArtNetID is the magic identifier at the start of every Art-Net packet
|
||||
var ArtNetID = [8]byte{'A', 'r', 't', '-', 'N', 'e', 't', 0x00}
|
||||
|
||||
// Protocol Version - Current is 14
|
||||
const (
|
||||
ProtocolVersionHi = 0
|
||||
ProtocolVersionLo = 14
|
||||
)
|
||||
|
||||
// OpCodes - Table 1 from the spec
|
||||
type OpCode uint16
|
||||
|
||||
const (
|
||||
OpPoll OpCode = 0x2000
|
||||
OpPollReply OpCode = 0x2100
|
||||
OpDiagData OpCode = 0x2300
|
||||
OpCommand OpCode = 0x2400
|
||||
OpDataRequest OpCode = 0x2700
|
||||
OpDataReply OpCode = 0x2800
|
||||
OpDmx OpCode = 0x5000 // Also called OpOutput
|
||||
OpNzs OpCode = 0x5100
|
||||
OpSync OpCode = 0x5200
|
||||
OpAddress OpCode = 0x6000
|
||||
OpInput OpCode = 0x7000
|
||||
OpTodRequest OpCode = 0x8000
|
||||
OpTodData OpCode = 0x8100
|
||||
OpTodControl OpCode = 0x8200
|
||||
OpRdm OpCode = 0x8300
|
||||
OpRdmSub OpCode = 0x8400
|
||||
OpVideoSetup OpCode = 0xa010
|
||||
OpVideoPalette OpCode = 0xa020
|
||||
OpVideoData OpCode = 0xa040
|
||||
OpMacMaster OpCode = 0xf000 // Deprecated
|
||||
OpMacSlave OpCode = 0xf100 // Deprecated
|
||||
OpFirmwareMaster OpCode = 0xf200
|
||||
OpFirmwareReply OpCode = 0xf300
|
||||
OpFileTnMaster OpCode = 0xf400
|
||||
OpFileFnMaster OpCode = 0xf500
|
||||
OpFileFnReply OpCode = 0xf600
|
||||
OpIpProg OpCode = 0xf800
|
||||
OpIpProgReply OpCode = 0xf900
|
||||
OpMedia OpCode = 0x9000
|
||||
OpMediaPatch OpCode = 0x9100
|
||||
OpMediaControl OpCode = 0x9200
|
||||
OpMediaCtrlReply OpCode = 0x9300
|
||||
OpTimeCode OpCode = 0x9700
|
||||
OpTimeSync OpCode = 0x9800
|
||||
OpTrigger OpCode = 0x9900
|
||||
OpDirectory OpCode = 0x9a00
|
||||
OpDirectoryReply OpCode = 0x9b00
|
||||
)
|
||||
|
||||
func (o OpCode) String() string {
|
||||
names := map[OpCode]string{
|
||||
OpPoll: "OpPoll",
|
||||
OpPollReply: "OpPollReply",
|
||||
OpDiagData: "OpDiagData",
|
||||
OpCommand: "OpCommand",
|
||||
OpDataRequest: "OpDataRequest",
|
||||
OpDataReply: "OpDataReply",
|
||||
OpDmx: "OpDmx/OpOutput",
|
||||
OpNzs: "OpNzs",
|
||||
OpSync: "OpSync",
|
||||
OpAddress: "OpAddress",
|
||||
OpInput: "OpInput",
|
||||
OpTodRequest: "OpTodRequest",
|
||||
OpTodData: "OpTodData",
|
||||
OpTodControl: "OpTodControl",
|
||||
OpRdm: "OpRdm",
|
||||
OpRdmSub: "OpRdmSub",
|
||||
OpVideoSetup: "OpVideoSetup",
|
||||
OpVideoPalette: "OpVideoPalette",
|
||||
OpVideoData: "OpVideoData",
|
||||
OpMacMaster: "OpMacMaster (Deprecated)",
|
||||
OpMacSlave: "OpMacSlave (Deprecated)",
|
||||
OpFirmwareMaster: "OpFirmwareMaster",
|
||||
OpFirmwareReply: "OpFirmwareReply",
|
||||
OpFileTnMaster: "OpFileTnMaster",
|
||||
OpFileFnMaster: "OpFileFnMaster",
|
||||
OpFileFnReply: "OpFileFnReply",
|
||||
OpIpProg: "OpIpProg",
|
||||
OpIpProgReply: "OpIpProgReply",
|
||||
OpMedia: "OpMedia",
|
||||
OpMediaPatch: "OpMediaPatch",
|
||||
OpMediaControl: "OpMediaControl",
|
||||
OpMediaCtrlReply: "OpMediaCtrlReply",
|
||||
OpTimeCode: "OpTimeCode",
|
||||
OpTimeSync: "OpTimeSync",
|
||||
OpTrigger: "OpTrigger",
|
||||
OpDirectory: "OpDirectory",
|
||||
OpDirectoryReply: "OpDirectoryReply",
|
||||
}
|
||||
if name, ok := names[o]; ok {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf("Unknown(0x%04X)", uint16(o))
|
||||
}
|
||||
|
||||
// Minimum packet lengths per spec
|
||||
const (
|
||||
MinArtPollLength = 14 // Spec: "accept as valid a packet of length 14 bytes or larger"
|
||||
MinArtPollReplyLength = 207 // Spec: "accept as valid a packet of length 207 bytes or larger"
|
||||
)
|
||||
|
||||
// NodeReport Codes - Table 3
|
||||
type NodeReportCode uint16
|
||||
|
||||
const (
|
||||
RcDebug NodeReportCode = 0x0000
|
||||
RcPowerOk NodeReportCode = 0x0001
|
||||
RcPowerFail NodeReportCode = 0x0002
|
||||
RcSocketWr1 NodeReportCode = 0x0003
|
||||
RcParseFail NodeReportCode = 0x0004
|
||||
RcUdpFail NodeReportCode = 0x0005
|
||||
RcShNameOk NodeReportCode = 0x0006
|
||||
RcLoNameOk NodeReportCode = 0x0007
|
||||
RcDmxError NodeReportCode = 0x0008
|
||||
RcDmxUdpFull NodeReportCode = 0x0009
|
||||
RcDmxRxFull NodeReportCode = 0x000a
|
||||
RcSwitchErr NodeReportCode = 0x000b
|
||||
RcConfigErr NodeReportCode = 0x000c
|
||||
RcDmxShort NodeReportCode = 0x000d
|
||||
RcFirmwareFail NodeReportCode = 0x000e
|
||||
RcUserFail NodeReportCode = 0x000f
|
||||
RcFactoryRes NodeReportCode = 0x0010
|
||||
)
|
||||
|
||||
func (n NodeReportCode) String() string {
|
||||
names := map[NodeReportCode]string{
|
||||
RcDebug: "RcDebug - Booted in debug mode",
|
||||
RcPowerOk: "RcPowerOk - Power On Tests successful",
|
||||
RcPowerFail: "RcPowerFail - Hardware tests failed at Power On",
|
||||
RcSocketWr1: "RcSocketWr1 - Last UDP truncated (collision?)",
|
||||
RcParseFail: "RcParseFail - Unable to identify last UDP",
|
||||
RcUdpFail: "RcUdpFail - Unable to open UDP Socket",
|
||||
RcShNameOk: "RcShNameOk - Port Name programming successful",
|
||||
RcLoNameOk: "RcLoNameOk - Long Name programming successful",
|
||||
RcDmxError: "RcDmxError - DMX512 receive errors detected",
|
||||
RcDmxUdpFull: "RcDmxUdpFull - Ran out of DMX transmit buffers",
|
||||
RcDmxRxFull: "RcDmxRxFull - Ran out of DMX Rx buffers",
|
||||
RcSwitchErr: "RcSwitchErr - Rx Universe switches conflict",
|
||||
RcConfigErr: "RcConfigErr - Config does not match firmware",
|
||||
RcDmxShort: "RcDmxShort - DMX output short detected",
|
||||
RcFirmwareFail: "RcFirmwareFail - Firmware upload failed",
|
||||
RcUserFail: "RcUserFail - User changes ignored (locked)",
|
||||
RcFactoryRes: "RcFactoryRes - Factory reset occurred",
|
||||
}
|
||||
if name, ok := names[n]; ok {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf("Unknown(0x%04X)", uint16(n))
|
||||
}
|
||||
|
||||
// Style Codes - Table 4
|
||||
type StyleCode uint8
|
||||
|
||||
const (
|
||||
StNode StyleCode = 0x00
|
||||
StController StyleCode = 0x01
|
||||
StMedia StyleCode = 0x02
|
||||
StRoute StyleCode = 0x03
|
||||
StBackup StyleCode = 0x04
|
||||
StConfig StyleCode = 0x05
|
||||
StVisual StyleCode = 0x06
|
||||
)
|
||||
|
||||
func (s StyleCode) String() string {
|
||||
names := map[StyleCode]string{
|
||||
StNode: "StNode - DMX to/from Art-Net device",
|
||||
StController: "StController - Lighting console",
|
||||
StMedia: "StMedia - Media Server",
|
||||
StRoute: "StRoute - Network routing device",
|
||||
StBackup: "StBackup - Backup device",
|
||||
StConfig: "StConfig - Configuration/diagnostic tool",
|
||||
StVisual: "StVisual - Visualiser",
|
||||
}
|
||||
if name, ok := names[s]; ok {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf("Unknown(0x%02X)", uint8(s))
|
||||
}
|
||||
|
||||
// Priority Codes - Table 5
|
||||
type PriorityCode uint8
|
||||
|
||||
const (
|
||||
DpLow PriorityCode = 0x10
|
||||
DpMed PriorityCode = 0x40
|
||||
DpHigh PriorityCode = 0x80
|
||||
DpCritical PriorityCode = 0xe0
|
||||
DpVolatile PriorityCode = 0xf0
|
||||
)
|
||||
|
||||
func (p PriorityCode) String() string {
|
||||
names := map[PriorityCode]string{
|
||||
DpLow: "DpLow - Low priority",
|
||||
DpMed: "DpMed - Medium priority",
|
||||
DpHigh: "DpHigh - High priority",
|
||||
DpCritical: "DpCritical - Critical priority",
|
||||
DpVolatile: "DpVolatile - Volatile (single line display)",
|
||||
}
|
||||
if name, ok := names[p]; ok {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf("Unknown(0x%02X)", uint8(p))
|
||||
}
|
||||
|
||||
// Port Types bit definitions
|
||||
const (
|
||||
PortTypeOutput = 0x80 // Bit 7: Can output data from Art-Net
|
||||
PortTypeInput = 0x40 // Bit 6: Can input onto Art-Net
|
||||
PortTypeDMX512 = 0x00 // Bits 5-0: Protocol
|
||||
PortTypeMIDI = 0x01
|
||||
PortTypeAvab = 0x02
|
||||
PortTypeCMX = 0x03
|
||||
PortTypeADB = 0x04
|
||||
PortTypeArtNet = 0x05
|
||||
PortTypeDALI = 0x06
|
||||
)
|
||||
838
validate.go
Normal file
838
validate.go
Normal file
@@ -0,0 +1,838 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidationResult holds the results of packet validation
|
||||
type ValidationResult struct {
|
||||
PacketType string
|
||||
Fields []FieldInfo
|
||||
Errors []string
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// FieldInfo represents a parsed field and its value
|
||||
type FieldInfo struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
func (r *ValidationResult) addField(name, value string) {
|
||||
r.Fields = append(r.Fields, FieldInfo{Name: name, Value: value})
|
||||
}
|
||||
|
||||
func (r *ValidationResult) addError(format string, args ...interface{}) {
|
||||
r.Errors = append(r.Errors, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (r *ValidationResult) addWarning(format string, args ...interface{}) {
|
||||
r.Warnings = append(r.Warnings, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (r *ValidationResult) getField(name string) string {
|
||||
for _, f := range r.Fields {
|
||||
if f.Name == name {
|
||||
return f.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// KV is a key-value pair for logging
|
||||
type KV struct {
|
||||
Key string
|
||||
Value any
|
||||
}
|
||||
|
||||
func (r *ValidationResult) SummaryAttrs() []KV {
|
||||
switch r.PacketType {
|
||||
case "ArtPoll":
|
||||
attrs := []KV{{"flags", r.getField("Flags")}}
|
||||
if universes := r.getField("TargetUniverses"); universes != "" {
|
||||
attrs = append(attrs, KV{"universes", universes})
|
||||
}
|
||||
return attrs
|
||||
case "ArtPollReply":
|
||||
return []KV{
|
||||
{"name", r.getField("PortName")},
|
||||
{"universes", r.getField("Universes")},
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePacket validates an Art-Net packet against the specification
|
||||
func ValidatePacket(data []byte, src *net.UDPAddr) *ValidationResult {
|
||||
result := &ValidationResult{}
|
||||
|
||||
// Minimum size check - need at least ID (8) + OpCode (2) = 10 bytes
|
||||
if len(data) < 10 {
|
||||
result.addError("Packet too short: %d bytes (minimum 10 for ID + OpCode)", len(data))
|
||||
return result
|
||||
}
|
||||
|
||||
// Validate Art-Net ID (Field 1)
|
||||
// Spec: "Array of 8 characters, the final character is a null termination.
|
||||
// Value = 'A' 'r' 't' '-' 'N' 'e' 't' 0x00"
|
||||
var packetID [8]byte
|
||||
copy(packetID[:], data[0:8])
|
||||
if packetID != ArtNetID {
|
||||
result.addError("Invalid Art-Net ID: got %q, expected %q", string(packetID[:]), string(ArtNetID[:]))
|
||||
return result
|
||||
}
|
||||
result.addField("ID", fmt.Sprintf("%q (valid)", string(packetID[:7]))) // Don't print null
|
||||
|
||||
// OpCode (Field 2) - "Transmitted low byte first"
|
||||
opcode := OpCode(binary.LittleEndian.Uint16(data[8:10]))
|
||||
result.addField("OpCode", fmt.Sprintf("0x%04X (%s)", uint16(opcode), opcode))
|
||||
|
||||
// Dispatch based on OpCode
|
||||
switch opcode {
|
||||
case OpPoll:
|
||||
result.PacketType = "ArtPoll"
|
||||
validateArtPoll(data, src, result)
|
||||
case OpPollReply:
|
||||
result.PacketType = "ArtPollReply"
|
||||
validateArtPollReply(data, src, result)
|
||||
default:
|
||||
result.PacketType = opcode.String()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// validateArtPoll validates an ArtPoll packet
|
||||
// Spec: "Consumers of ArtPoll shall accept as valid a packet of length 14 bytes or larger"
|
||||
func validateArtPoll(data []byte, src *net.UDPAddr, result *ValidationResult) {
|
||||
// Minimum length check
|
||||
if len(data) < MinArtPollLength {
|
||||
result.addError("ArtPoll too short: %d bytes (minimum %d)", len(data), MinArtPollLength)
|
||||
return
|
||||
}
|
||||
|
||||
// Field 3: ProtVerHi - "High byte of the Art-Net protocol revision number"
|
||||
protVerHi := data[10]
|
||||
result.addField("ProtVerHi", fmt.Sprintf("%d", protVerHi))
|
||||
if protVerHi != 0 {
|
||||
result.addWarning("ProtVerHi is %d, expected 0", protVerHi)
|
||||
}
|
||||
|
||||
// Field 4: ProtVerLo - "Low byte of the Art-Net protocol revision number. Current value 14.
|
||||
// Controllers should ignore communication with nodes using a protocol version lower than 14."
|
||||
protVerLo := data[11]
|
||||
result.addField("ProtVerLo", fmt.Sprintf("%d", protVerLo))
|
||||
if protVerLo < 14 {
|
||||
result.addError("ProtVerLo is %d, minimum required is 14", protVerLo)
|
||||
}
|
||||
|
||||
// Field 5: Flags
|
||||
flags := data[12]
|
||||
result.addField("Flags", fmt.Sprintf("0x%02X", flags))
|
||||
validateArtPollFlags(flags, result)
|
||||
|
||||
// Field 6: DiagPriority - "The lowest priority of diagnostics message that should be sent"
|
||||
diagPriority := data[13]
|
||||
result.addField("DiagPriority", fmt.Sprintf("0x%02X (%s)", diagPriority, PriorityCode(diagPriority)))
|
||||
// Value 0x00 is deprecated per spec
|
||||
if diagPriority == 0x00 {
|
||||
result.addWarning("DiagPriority 0x00 is deprecated")
|
||||
}
|
||||
|
||||
// Optional fields (packet may be longer)
|
||||
if len(data) >= 18 {
|
||||
// Fields 7-10: Target Port Address range (if Targeted Mode enabled)
|
||||
targetTopHi := data[14]
|
||||
targetTopLo := data[15]
|
||||
targetBottomHi := data[16]
|
||||
targetBottomLo := data[17]
|
||||
|
||||
targetTop := uint16(targetTopHi)<<8 | uint16(targetTopLo)
|
||||
targetBottom := uint16(targetBottomHi)<<8 | uint16(targetBottomLo)
|
||||
|
||||
result.addField("TargetPortAddressTop", fmt.Sprintf("%d (0x%04X)", targetTop, targetTop))
|
||||
result.addField("TargetPortAddressBottom", fmt.Sprintf("%d (0x%04X)", targetBottom, targetBottom))
|
||||
|
||||
// Validate target range
|
||||
if flags&0x20 != 0 { // Targeted mode enabled
|
||||
if targetBottom == targetTop {
|
||||
result.addField("TargetUniverses", formatPortAddress(targetBottom))
|
||||
} else {
|
||||
result.addField("TargetUniverses", fmt.Sprintf("%s to %s", formatPortAddress(targetBottom), formatPortAddress(targetTop)))
|
||||
}
|
||||
if targetBottom > targetTop {
|
||||
result.addError("TargetPortAddressBottom (%d) > TargetPortAddressTop (%d)", targetBottom, targetTop)
|
||||
}
|
||||
// Port-Address 0 is deprecated
|
||||
if targetBottom == 0 {
|
||||
result.addWarning("TargetPortAddressBottom is 0 (deprecated)")
|
||||
}
|
||||
// Max Port-Address is 32767 (15 bits)
|
||||
if targetTop > 32767 {
|
||||
result.addError("TargetPortAddressTop (%d) exceeds maximum 32767", targetTop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(data) >= 20 {
|
||||
// Fields 11-12: EstaMan
|
||||
estaManHi := data[18]
|
||||
estaManLo := data[19]
|
||||
estaMan := uint16(estaManHi)<<8 | uint16(estaManLo)
|
||||
result.addField("EstaMan", fmt.Sprintf("0x%04X", estaMan))
|
||||
}
|
||||
|
||||
if len(data) >= 22 {
|
||||
// Fields 13-14: OemCode
|
||||
oemHi := data[20]
|
||||
oemLo := data[21]
|
||||
oem := uint16(oemHi)<<8 | uint16(oemLo)
|
||||
result.addField("OemCode", fmt.Sprintf("0x%04X", oem))
|
||||
}
|
||||
|
||||
// Report actual packet length
|
||||
result.addField("PacketLength", fmt.Sprintf("%d bytes", len(data)))
|
||||
}
|
||||
|
||||
// validateArtPollFlags validates the Flags field of ArtPoll
|
||||
func validateArtPollFlags(flags byte, result *ValidationResult) {
|
||||
// Bits 7-6: "Unused, transmit as zero, do not test upon receipt"
|
||||
if flags&0xC0 != 0 {
|
||||
result.addWarning("Flags bits 7-6 are set (should be zero, but spec says do not test)")
|
||||
}
|
||||
|
||||
// Bit 5: Targeted Mode
|
||||
if flags&0x20 != 0 {
|
||||
result.addField(" Flags.TargetedMode", "Enabled")
|
||||
} else {
|
||||
result.addField(" Flags.TargetedMode", "Disabled")
|
||||
}
|
||||
|
||||
// Bit 4: VLC transmission
|
||||
if flags&0x10 != 0 {
|
||||
result.addField(" Flags.VLC", "Disabled")
|
||||
} else {
|
||||
result.addField(" Flags.VLC", "Enabled")
|
||||
}
|
||||
|
||||
// Bit 3: Diagnostics unicast/broadcast
|
||||
if flags&0x08 != 0 {
|
||||
result.addField(" Flags.DiagUnicast", "Unicast (if bit 2 set)")
|
||||
} else {
|
||||
result.addField(" Flags.DiagUnicast", "Broadcast (if bit 2 set)")
|
||||
}
|
||||
|
||||
// Bit 2: Send diagnostics
|
||||
if flags&0x04 != 0 {
|
||||
result.addField(" Flags.SendDiag", "Yes, send diagnostics")
|
||||
} else {
|
||||
result.addField(" Flags.SendDiag", "No diagnostics")
|
||||
}
|
||||
|
||||
// Bit 1: ArtPollReply on change
|
||||
if flags&0x02 != 0 {
|
||||
result.addField(" Flags.ReplyOnChange", "Send ArtPollReply on Node condition change")
|
||||
} else {
|
||||
result.addField(" Flags.ReplyOnChange", "Only reply to ArtPoll/ArtAddress")
|
||||
}
|
||||
|
||||
// Bit 0: Deprecated
|
||||
if flags&0x01 != 0 {
|
||||
result.addWarning("Flags bit 0 is set (deprecated)")
|
||||
}
|
||||
}
|
||||
|
||||
// validateArtPollReply validates an ArtPollReply packet
|
||||
// Spec: "Consumers of ArtPollReply shall accept as valid a packet of length 207 bytes or larger"
|
||||
func validateArtPollReply(data []byte, src *net.UDPAddr, result *ValidationResult) {
|
||||
// Minimum length check
|
||||
if len(data) < MinArtPollReplyLength {
|
||||
result.addError("ArtPollReply too short: %d bytes (minimum %d)", len(data), MinArtPollReplyLength)
|
||||
return
|
||||
}
|
||||
|
||||
// Field 3: IP Address[4] - "First array entry is most significant byte of address"
|
||||
ipAddr := net.IPv4(data[10], data[11], data[12], data[13])
|
||||
result.addField("IPAddress", ipAddr.String())
|
||||
|
||||
// Validate IP matches source (or is bound node)
|
||||
if !ipAddr.Equal(src.IP) {
|
||||
result.addWarning("Reported IP %s differs from source IP %s (may be bound node)", ipAddr, src.IP)
|
||||
}
|
||||
|
||||
// Field 4: Port - "The Port is always 0x1936. Transmitted low byte first."
|
||||
port := binary.LittleEndian.Uint16(data[14:16])
|
||||
result.addField("Port", fmt.Sprintf("0x%04X (%d)", port, port))
|
||||
if port != 0x1936 {
|
||||
result.addError("Port must be 0x1936, got 0x%04X", port)
|
||||
}
|
||||
|
||||
// Field 5-6: VersInfo - Firmware revision
|
||||
versInfoH := data[16]
|
||||
versInfoL := data[17]
|
||||
result.addField("FirmwareVersion", fmt.Sprintf("%d.%d", versInfoH, versInfoL))
|
||||
|
||||
// Field 7: NetSwitch - "Bits 14-8 of the 15 bit Port-Address"
|
||||
netSwitch := data[18] & 0x7F // Bottom 7 bits
|
||||
result.addField("NetSwitch", fmt.Sprintf("%d (0x%02X)", netSwitch, netSwitch))
|
||||
|
||||
// Field 8: SubSwitch - "Bits 7-4 of the 15 bit Port-Address"
|
||||
subSwitch := data[19] & 0x0F // Bottom 4 bits
|
||||
result.addField("SubSwitch", fmt.Sprintf("%d (0x%02X)", subSwitch, subSwitch))
|
||||
|
||||
// Field 9-10: Oem code
|
||||
oemHi := data[20]
|
||||
oemLo := data[21]
|
||||
oem := uint16(oemHi)<<8 | uint16(oemLo)
|
||||
result.addField("OemCode", fmt.Sprintf("0x%04X", oem))
|
||||
|
||||
// Field 11: UBEA Version
|
||||
ubeaVersion := data[22]
|
||||
result.addField("UbeaVersion", fmt.Sprintf("%d", ubeaVersion))
|
||||
|
||||
// Field 12: Status1
|
||||
status1 := data[23]
|
||||
result.addField("Status1", fmt.Sprintf("0x%02X", status1))
|
||||
validateStatus1(status1, result)
|
||||
|
||||
// Field 13-14: ESTA Manufacturer Code (Lo byte first in struct, but Hi byte at offset 24)
|
||||
estaManLo := data[24]
|
||||
estaManHi := data[25]
|
||||
estaMan := uint16(estaManHi)<<8 | uint16(estaManLo)
|
||||
result.addField("EstaMan", fmt.Sprintf("0x%04X", estaMan))
|
||||
|
||||
// Field 15: PortName[18] - Null terminated, max 17 chars + null
|
||||
portName := extractNullTerminatedString(data[26:44], 18)
|
||||
result.addField("PortName", portName)
|
||||
if len(portName) > 17 {
|
||||
result.addError("PortName exceeds 17 characters")
|
||||
}
|
||||
|
||||
// Field 16: LongName[64] - Null terminated, max 63 chars + null
|
||||
longName := extractNullTerminatedString(data[44:108], 64)
|
||||
result.addField("LongName", longName)
|
||||
if len(longName) > 63 {
|
||||
result.addError("LongName exceeds 63 characters")
|
||||
}
|
||||
|
||||
// Field 17: NodeReport[64] - Format: "#xxxx [yyyy] zzzzz..."
|
||||
nodeReport := extractNullTerminatedString(data[108:172], 64)
|
||||
result.addField("NodeReport", fmt.Sprintf("%q", nodeReport))
|
||||
validateNodeReport(nodeReport, result)
|
||||
|
||||
// Field 18-19: NumPorts
|
||||
numPortsHi := data[172]
|
||||
numPortsLo := data[173]
|
||||
result.addField("NumPortsHi", fmt.Sprintf("%d", numPortsHi))
|
||||
result.addField("NumPortsLo", fmt.Sprintf("%d", numPortsLo))
|
||||
if numPortsHi != 0 {
|
||||
result.addWarning("NumPortsHi is %d (reserved for future, expected 0)", numPortsHi)
|
||||
}
|
||||
if numPortsLo > 4 {
|
||||
result.addError("NumPortsLo is %d (maximum is 4)", numPortsLo)
|
||||
}
|
||||
|
||||
// Field 20: PortTypes[4]
|
||||
result.addField("PortTypes", fmt.Sprintf("[0x%02X, 0x%02X, 0x%02X, 0x%02X]",
|
||||
data[174], data[175], data[176], data[177]))
|
||||
for i := 0; i < 4; i++ {
|
||||
validatePortType(data[174+i], i, result)
|
||||
}
|
||||
|
||||
// Field 21: GoodInput[4]
|
||||
result.addField("GoodInput", fmt.Sprintf("[0x%02X, 0x%02X, 0x%02X, 0x%02X]",
|
||||
data[178], data[179], data[180], data[181]))
|
||||
for i := 0; i < 4; i++ {
|
||||
validateGoodInput(data[178+i], i, result)
|
||||
}
|
||||
|
||||
// Field 22: GoodOutputA[4]
|
||||
result.addField("GoodOutputA", fmt.Sprintf("[0x%02X, 0x%02X, 0x%02X, 0x%02X]",
|
||||
data[182], data[183], data[184], data[185]))
|
||||
for i := 0; i < 4; i++ {
|
||||
validateGoodOutputA(data[182+i], i, result)
|
||||
}
|
||||
|
||||
// Field 23: SwIn[4] - Input universe addresses
|
||||
result.addField("SwIn", fmt.Sprintf("[%d, %d, %d, %d]",
|
||||
data[186]&0x0F, data[187]&0x0F, data[188]&0x0F, data[189]&0x0F))
|
||||
|
||||
// Field 24: SwOut[4] - Output universe addresses
|
||||
result.addField("SwOut", fmt.Sprintf("[%d, %d, %d, %d]",
|
||||
data[190]&0x0F, data[191]&0x0F, data[192]&0x0F, data[193]&0x0F))
|
||||
|
||||
// Calculate full Port-Addresses and collect universes
|
||||
var universes []string
|
||||
for i := 0; i < int(numPortsLo); i++ {
|
||||
swIn := data[186+i] & 0x0F
|
||||
swOut := data[190+i] & 0x0F
|
||||
inAddr := uint16(netSwitch)<<8 | uint16(subSwitch)<<4 | uint16(swIn)
|
||||
outAddr := uint16(netSwitch)<<8 | uint16(subSwitch)<<4 | uint16(swOut)
|
||||
result.addField(fmt.Sprintf(" Port %d Input Address", i), fmt.Sprintf("%d", inAddr))
|
||||
result.addField(fmt.Sprintf(" Port %d Output Address", i), fmt.Sprintf("%d", outAddr))
|
||||
|
||||
// Collect active universes for summary - format: "net:subnet:universe (portaddr)"
|
||||
if data[174+i]&PortTypeInput != 0 {
|
||||
universes = append(universes, fmt.Sprintf("in %d:%d:%d (%d)", netSwitch, subSwitch, swIn, inAddr))
|
||||
}
|
||||
if data[174+i]&PortTypeOutput != 0 {
|
||||
universes = append(universes, fmt.Sprintf("out %d:%d:%d (%d)", netSwitch, subSwitch, swOut, outAddr))
|
||||
}
|
||||
|
||||
// Port-Address 0 is deprecated
|
||||
if inAddr == 0 && data[174+i]&PortTypeInput != 0 {
|
||||
result.addWarning("Port %d Input Address is 0 (deprecated)", i)
|
||||
}
|
||||
if outAddr == 0 && data[174+i]&PortTypeOutput != 0 {
|
||||
result.addWarning("Port %d Output Address is 0 (deprecated)", i)
|
||||
}
|
||||
}
|
||||
if len(universes) > 0 {
|
||||
result.addField("Universes", strings.Join(universes, ", "))
|
||||
} else {
|
||||
result.addField("Universes", "none")
|
||||
}
|
||||
|
||||
// Field 25: AcnPriority
|
||||
acnPriority := data[194]
|
||||
result.addField("AcnPriority", fmt.Sprintf("%d", acnPriority))
|
||||
if acnPriority > 200 {
|
||||
result.addWarning("AcnPriority %d exceeds recommended max of 200", acnPriority)
|
||||
}
|
||||
|
||||
// Field 26: SwMacro
|
||||
swMacro := data[195]
|
||||
result.addField("SwMacro", fmt.Sprintf("0x%02X", swMacro))
|
||||
|
||||
// Field 27: SwRemote
|
||||
swRemote := data[196]
|
||||
result.addField("SwRemote", fmt.Sprintf("0x%02X", swRemote))
|
||||
|
||||
// Fields 28-30: Spare (should be zero)
|
||||
for i := 0; i < 3; i++ {
|
||||
if data[197+i] != 0 {
|
||||
result.addWarning("Spare byte at offset %d is 0x%02X (should be 0)", 197+i, data[197+i])
|
||||
}
|
||||
}
|
||||
|
||||
// Field 31: Style
|
||||
style := StyleCode(data[200])
|
||||
result.addField("Style", fmt.Sprintf("0x%02X (%s)", uint8(style), style))
|
||||
if style > StVisual {
|
||||
result.addWarning("Unknown Style code 0x%02X", uint8(style))
|
||||
}
|
||||
|
||||
// Field 32-37: MAC Address
|
||||
mac := net.HardwareAddr(data[201:207])
|
||||
result.addField("MAC", mac.String())
|
||||
// Check if MAC is all zeros (not able to supply)
|
||||
allZero := true
|
||||
for _, b := range data[201:207] {
|
||||
if b != 0 {
|
||||
allZero = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allZero {
|
||||
result.addField(" MAC Note", "All zeros (node cannot supply MAC)")
|
||||
}
|
||||
|
||||
// Optional extended fields (if packet is longer than minimum 207)
|
||||
if len(data) >= 211 {
|
||||
// Field 38: BindIp[4]
|
||||
bindIP := net.IPv4(data[207], data[208], data[209], data[210])
|
||||
result.addField("BindIP", bindIP.String())
|
||||
}
|
||||
|
||||
if len(data) >= 212 {
|
||||
// Field 39: BindIndex
|
||||
bindIndex := data[211]
|
||||
result.addField("BindIndex", fmt.Sprintf("%d", bindIndex))
|
||||
if bindIndex == 0 {
|
||||
result.addField(" BindIndex Note", "0 or 1 means root device")
|
||||
}
|
||||
}
|
||||
|
||||
if len(data) >= 213 {
|
||||
// Field 40: Status2
|
||||
status2 := data[212]
|
||||
result.addField("Status2", fmt.Sprintf("0x%02X", status2))
|
||||
validateStatus2(status2, result)
|
||||
}
|
||||
|
||||
if len(data) >= 217 {
|
||||
// Field 41: GoodOutputB[4]
|
||||
result.addField("GoodOutputB", fmt.Sprintf("[0x%02X, 0x%02X, 0x%02X, 0x%02X]",
|
||||
data[213], data[214], data[215], data[216]))
|
||||
for i := 0; i < 4; i++ {
|
||||
validateGoodOutputB(data[213+i], i, result)
|
||||
}
|
||||
}
|
||||
|
||||
if len(data) >= 218 {
|
||||
// Field 42: Status3
|
||||
status3 := data[217]
|
||||
result.addField("Status3", fmt.Sprintf("0x%02X", status3))
|
||||
validateStatus3(status3, result)
|
||||
}
|
||||
|
||||
if len(data) >= 224 {
|
||||
// Field 43-48: DefaultResponderUID[6]
|
||||
uid := data[218:224]
|
||||
result.addField("DefaultResponderUID", fmt.Sprintf("%02X:%02X:%02X:%02X:%02X:%02X",
|
||||
uid[0], uid[1], uid[2], uid[3], uid[4], uid[5]))
|
||||
}
|
||||
|
||||
if len(data) >= 226 {
|
||||
// Field 49-50: User
|
||||
userHi := data[224]
|
||||
userLo := data[225]
|
||||
result.addField("User", fmt.Sprintf("0x%02X%02X", userHi, userLo))
|
||||
}
|
||||
|
||||
if len(data) >= 228 {
|
||||
// Field 51-52: RefreshRate
|
||||
refreshRateHi := data[226]
|
||||
refreshRateLo := data[227]
|
||||
refreshRate := uint16(refreshRateHi)<<8 | uint16(refreshRateLo)
|
||||
result.addField("RefreshRate", fmt.Sprintf("%d Hz", refreshRate))
|
||||
if refreshRate > 0 && refreshRate < 44 {
|
||||
result.addField(" RefreshRate Note", "0-44 means max DMX512 rate of 44Hz")
|
||||
}
|
||||
}
|
||||
|
||||
if len(data) >= 229 {
|
||||
// Field 53: BackgroundQueuePolicy
|
||||
bqPolicy := data[228]
|
||||
result.addField("BackgroundQueuePolicy", fmt.Sprintf("%d", bqPolicy))
|
||||
validateBackgroundQueuePolicy(bqPolicy, result)
|
||||
}
|
||||
|
||||
// Report actual packet length
|
||||
result.addField("PacketLength", fmt.Sprintf("%d bytes", len(data)))
|
||||
}
|
||||
|
||||
func validateStatus1(status byte, result *ValidationResult) {
|
||||
// Bits 7-6: Indicator state
|
||||
indState := (status >> 6) & 0x03
|
||||
indStates := []string{"Unknown", "Locate/Identify Mode", "Mute Mode", "Normal Mode"}
|
||||
result.addField(" Status1.Indicator", indStates[indState])
|
||||
|
||||
// Bits 5-4: Port-Address Programming Authority
|
||||
progAuth := (status >> 4) & 0x03
|
||||
progAuths := []string{"Unknown", "Front panel controls", "Network/Web programmed", "Not used"}
|
||||
result.addField(" Status1.ProgAuthority", progAuths[progAuth])
|
||||
if progAuth == 3 {
|
||||
result.addWarning("Status1 ProgAuthority value 11 is 'Not used'")
|
||||
}
|
||||
|
||||
// Bit 3: Not implemented
|
||||
if status&0x08 != 0 {
|
||||
result.addWarning("Status1 bit 3 is set (should be zero)")
|
||||
}
|
||||
|
||||
// Bit 2: Boot mode
|
||||
if status&0x04 != 0 {
|
||||
result.addField(" Status1.BootMode", "Booted from ROM")
|
||||
} else {
|
||||
result.addField(" Status1.BootMode", "Normal firmware boot (flash)")
|
||||
}
|
||||
|
||||
// Bit 1: RDM capable
|
||||
if status&0x02 != 0 {
|
||||
result.addField(" Status1.RDM", "Capable")
|
||||
} else {
|
||||
result.addField(" Status1.RDM", "Not capable")
|
||||
}
|
||||
|
||||
// Bit 0: UBEA present
|
||||
if status&0x01 != 0 {
|
||||
result.addField(" Status1.UBEA", "Present")
|
||||
} else {
|
||||
result.addField(" Status1.UBEA", "Not present or corrupt")
|
||||
}
|
||||
}
|
||||
|
||||
func validateStatus2(status byte, result *ValidationResult) {
|
||||
// Bit 7: RDM control via ArtAddress
|
||||
if status&0x80 != 0 {
|
||||
result.addField(" Status2.RDMControl", "Supports RDM control via ArtAddress")
|
||||
}
|
||||
|
||||
// Bit 6: Output style switching
|
||||
if status&0x40 != 0 {
|
||||
result.addField(" Status2.OutputStyle", "Supports output style switching")
|
||||
}
|
||||
|
||||
// Bit 5: Squawking
|
||||
if status&0x20 != 0 {
|
||||
result.addField(" Status2.Squawking", "Yes")
|
||||
}
|
||||
|
||||
// Bit 4: Art-Net/sACN switching
|
||||
if status&0x10 != 0 {
|
||||
result.addField(" Status2.ArtNetSacn", "Can switch between Art-Net and sACN")
|
||||
}
|
||||
|
||||
// Bit 3: 15-bit Port-Address
|
||||
if status&0x08 != 0 {
|
||||
result.addField(" Status2.PortAddress", "Supports 15-bit (Art-Net 3/4)")
|
||||
} else {
|
||||
result.addField(" Status2.PortAddress", "Supports 8-bit only (Art-Net II)")
|
||||
}
|
||||
|
||||
// Bit 2: DHCP capable
|
||||
if status&0x04 != 0 {
|
||||
result.addField(" Status2.DHCP", "Capable")
|
||||
} else {
|
||||
result.addField(" Status2.DHCP", "Not capable")
|
||||
}
|
||||
|
||||
// Bit 1: DHCP configured
|
||||
if status&0x02 != 0 {
|
||||
result.addField(" Status2.DHCPConfig", "IP is DHCP configured")
|
||||
} else {
|
||||
result.addField(" Status2.DHCPConfig", "IP is manually configured")
|
||||
}
|
||||
|
||||
// Bit 0: Web browser config
|
||||
if status&0x01 != 0 {
|
||||
result.addField(" Status2.WebConfig", "Supports web browser configuration")
|
||||
}
|
||||
}
|
||||
|
||||
func validateStatus3(status byte, result *ValidationResult) {
|
||||
// Bits 7-6: Failsafe state
|
||||
failsafe := (status >> 6) & 0x03
|
||||
failsafes := []string{"Hold last state", "All outputs to zero", "All outputs to full", "Playback failsafe scene"}
|
||||
result.addField(" Status3.Failsafe", failsafes[failsafe])
|
||||
|
||||
// Bit 5: Programmable failsafe
|
||||
if status&0x20 != 0 {
|
||||
result.addField(" Status3.ProgFailsafe", "Supported")
|
||||
}
|
||||
|
||||
// Bit 4: LLRP support
|
||||
if status&0x10 != 0 {
|
||||
result.addField(" Status3.LLRP", "Supported")
|
||||
}
|
||||
|
||||
// Bit 3: Port direction switching
|
||||
if status&0x08 != 0 {
|
||||
result.addField(" Status3.PortSwitch", "Supports port direction switching")
|
||||
}
|
||||
|
||||
// Bit 2: RDMnet support
|
||||
if status&0x04 != 0 {
|
||||
result.addField(" Status3.RDMnet", "Supported")
|
||||
}
|
||||
|
||||
// Bit 1: BackgroundQueue supported
|
||||
if status&0x02 != 0 {
|
||||
result.addField(" Status3.BackgroundQueue", "Supported")
|
||||
}
|
||||
|
||||
// Bit 0: Background discovery control
|
||||
if status&0x01 != 0 {
|
||||
result.addField(" Status3.BgDiscoveryCtrl", "Can be disabled via ArtAddress")
|
||||
}
|
||||
}
|
||||
|
||||
func validatePortType(pt byte, portNum int, result *ValidationResult) {
|
||||
prefix := fmt.Sprintf(" PortType[%d]", portNum)
|
||||
|
||||
if pt&PortTypeOutput != 0 {
|
||||
result.addField(prefix+".Output", "Can output from Art-Net")
|
||||
}
|
||||
if pt&PortTypeInput != 0 {
|
||||
result.addField(prefix+".Input", "Can input to Art-Net")
|
||||
}
|
||||
|
||||
protocol := pt & 0x3F
|
||||
protocols := map[byte]string{
|
||||
0x00: "DMX512",
|
||||
0x01: "MIDI",
|
||||
0x02: "Avab",
|
||||
0x03: "Colortran CMX",
|
||||
0x04: "ADB 62.5",
|
||||
0x05: "Art-Net",
|
||||
0x06: "DALI",
|
||||
}
|
||||
if name, ok := protocols[protocol]; ok {
|
||||
result.addField(prefix+".Protocol", name)
|
||||
} else {
|
||||
result.addField(prefix+".Protocol", fmt.Sprintf("Unknown (0x%02X)", protocol))
|
||||
}
|
||||
}
|
||||
|
||||
func validateGoodInput(gi byte, portNum int, result *ValidationResult) {
|
||||
prefix := fmt.Sprintf(" GoodInput[%d]", portNum)
|
||||
|
||||
if gi&0x80 != 0 {
|
||||
result.addField(prefix+".DataReceived", "Yes")
|
||||
}
|
||||
if gi&0x40 != 0 {
|
||||
result.addField(prefix+".TestPackets", "Includes DMX512 test packets")
|
||||
}
|
||||
if gi&0x20 != 0 {
|
||||
result.addField(prefix+".SIPs", "Includes DMX512 SIPs")
|
||||
}
|
||||
if gi&0x10 != 0 {
|
||||
result.addField(prefix+".TextPackets", "Includes DMX512 text packets")
|
||||
}
|
||||
if gi&0x08 != 0 {
|
||||
result.addField(prefix+".Disabled", "Yes")
|
||||
}
|
||||
if gi&0x04 != 0 {
|
||||
result.addField(prefix+".Errors", "Receive errors detected")
|
||||
}
|
||||
// Bit 1 unused
|
||||
if gi&0x02 != 0 {
|
||||
result.addWarning("GoodInput[%d] bit 1 is set (should be zero)", portNum)
|
||||
}
|
||||
if gi&0x01 != 0 {
|
||||
result.addField(prefix+".ConvertTo", "sACN")
|
||||
} else {
|
||||
result.addField(prefix+".ConvertTo", "Art-Net")
|
||||
}
|
||||
}
|
||||
|
||||
func validateGoodOutputA(go_ byte, portNum int, result *ValidationResult) {
|
||||
prefix := fmt.Sprintf(" GoodOutputA[%d]", portNum)
|
||||
|
||||
if go_&0x80 != 0 {
|
||||
result.addField(prefix+".DataOutput", "ArtDmx or sACN being output as DMX512")
|
||||
}
|
||||
if go_&0x40 != 0 {
|
||||
result.addField(prefix+".TestPackets", "Includes DMX512 test packets")
|
||||
}
|
||||
if go_&0x20 != 0 {
|
||||
result.addField(prefix+".SIPs", "Includes DMX512 SIPs")
|
||||
}
|
||||
if go_&0x10 != 0 {
|
||||
result.addField(prefix+".TextPackets", "Includes DMX512 text packets")
|
||||
}
|
||||
if go_&0x08 != 0 {
|
||||
result.addField(prefix+".Merging", "Yes")
|
||||
}
|
||||
if go_&0x04 != 0 {
|
||||
result.addField(prefix+".ShortDetected", "DMX output short on power up")
|
||||
}
|
||||
if go_&0x02 != 0 {
|
||||
result.addField(prefix+".MergeMode", "LTP")
|
||||
} else {
|
||||
result.addField(prefix+".MergeMode", "HTP")
|
||||
}
|
||||
if go_&0x01 != 0 {
|
||||
result.addField(prefix+".ConvertFrom", "sACN")
|
||||
} else {
|
||||
result.addField(prefix+".ConvertFrom", "Art-Net")
|
||||
}
|
||||
}
|
||||
|
||||
func validateGoodOutputB(gob byte, portNum int, result *ValidationResult) {
|
||||
prefix := fmt.Sprintf(" GoodOutputB[%d]", portNum)
|
||||
|
||||
if gob&0x80 != 0 {
|
||||
result.addField(prefix+".RDM", "Disabled")
|
||||
} else {
|
||||
result.addField(prefix+".RDM", "Enabled")
|
||||
}
|
||||
|
||||
if gob&0x40 != 0 {
|
||||
result.addField(prefix+".OutputStyle", "Continuous")
|
||||
} else {
|
||||
result.addField(prefix+".OutputStyle", "Delta")
|
||||
}
|
||||
|
||||
if gob&0x20 != 0 {
|
||||
result.addField(prefix+".Discovery", "Not running")
|
||||
} else {
|
||||
result.addField(prefix+".Discovery", "Running")
|
||||
}
|
||||
|
||||
if gob&0x10 != 0 {
|
||||
result.addField(prefix+".BgDiscovery", "Disabled")
|
||||
} else {
|
||||
result.addField(prefix+".BgDiscovery", "Enabled")
|
||||
}
|
||||
|
||||
// Bits 3-0 should be zero
|
||||
if gob&0x0F != 0 {
|
||||
result.addWarning("GoodOutputB[%d] bits 3-0 are 0x%X (should be zero)", portNum, gob&0x0F)
|
||||
}
|
||||
}
|
||||
|
||||
func validateNodeReport(report string, result *ValidationResult) {
|
||||
// Format should be: "#xxxx [yyyy] zzzzz..."
|
||||
if report == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(report, "#") {
|
||||
result.addWarning("NodeReport should start with '#'")
|
||||
return
|
||||
}
|
||||
|
||||
// Try to parse format
|
||||
if len(report) < 7 {
|
||||
result.addWarning("NodeReport too short for expected format")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract status code (xxxx)
|
||||
if len(report) >= 5 {
|
||||
codeStr := report[1:5]
|
||||
var code uint16
|
||||
if _, err := fmt.Sscanf(codeStr, "%04x", &code); err == nil {
|
||||
result.addField(" NodeReport.Code", fmt.Sprintf("0x%04X (%s)", code, NodeReportCode(code)))
|
||||
}
|
||||
}
|
||||
|
||||
// Look for counter [yyyy]
|
||||
if bracketStart := strings.Index(report, "["); bracketStart != -1 {
|
||||
if bracketEnd := strings.Index(report[bracketStart:], "]"); bracketEnd != -1 {
|
||||
counter := report[bracketStart+1 : bracketStart+bracketEnd]
|
||||
result.addField(" NodeReport.Counter", counter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateBackgroundQueuePolicy(policy byte, result *ValidationResult) {
|
||||
policies := map[byte]string{
|
||||
0: "Collect using STATUS_NONE",
|
||||
1: "Collect using STATUS_ADVISORY",
|
||||
2: "Collect using STATUS_WARNING",
|
||||
3: "Collect using STATUS_ERROR",
|
||||
4: "Collection disabled",
|
||||
}
|
||||
|
||||
if name, ok := policies[policy]; ok {
|
||||
result.addField(" BQPolicy", name)
|
||||
} else if policy >= 5 && policy <= 250 {
|
||||
result.addField(" BQPolicy", fmt.Sprintf("Manufacturer defined (%d)", policy))
|
||||
} else {
|
||||
result.addField(" BQPolicy", fmt.Sprintf("Reserved (%d)", policy))
|
||||
}
|
||||
}
|
||||
|
||||
func extractNullTerminatedString(data []byte, maxLen int) string {
|
||||
for i := 0; i < len(data) && i < maxLen; i++ {
|
||||
if data[i] == 0 {
|
||||
return string(data[:i])
|
||||
}
|
||||
}
|
||||
return string(data[:maxLen])
|
||||
}
|
||||
|
||||
// formatPortAddress formats a 15-bit port address as "net:subnet:universe (n)"
|
||||
func formatPortAddress(addr uint16) string {
|
||||
net := (addr >> 8) & 0x7F
|
||||
subnet := (addr >> 4) & 0x0F
|
||||
universe := addr & 0x0F
|
||||
return fmt.Sprintf("%d:%d:%d (%d)", net, subnet, universe, addr)
|
||||
}
|
||||
Reference in New Issue
Block a user