package serial import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path" "path/filepath" "regexp" "strings" "sync" ) const ChannelCount = 48 var ( ErrSerialUnavailable = errors.New("serial controller unavailable") channelPattern = regexp.MustCompile(`^CH([0-9]{2})$`) devicePattern = regexp.MustCompile(`^/dev/ttyWCH[0-9]+$`) pciBDFPattern = regexp.MustCompile(`^[0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.[0-7]$`) ) // UnavailableError means the serial resolver or its target device cannot be // safely used. It is deliberately used instead of guessing a ttyWCH order. type UnavailableError struct { Reason string } func (e *UnavailableError) Error() string { if e == nil || strings.TrimSpace(e.Reason) == "" { return ErrSerialUnavailable.Error() } return ErrSerialUnavailable.Error() + ": " + strings.TrimSpace(e.Reason) } func (e *UnavailableError) Unwrap() error { return ErrSerialUnavailable } func IsUnavailable(err error) bool { return errors.Is(err, ErrSerialUnavailable) } // ChannelConfig is the product serial-line control contract shared by tera-api // and tera-seriald. Values are normalized and restricted before termios is // changed; arbitrary termios flags are never accepted from the API. type ChannelConfig struct { Channel string `json:"channel"` BaudRate int `json:"baud_rate"` DataBits int `json:"data_bits,omitempty"` Parity string `json:"parity,omitempty"` StopBits int `json:"stop_bits,omitempty"` StopBitsMode string `json:"stop_bits_mode,omitempty"` FlowControl string `json:"flow_control,omitempty"` RTSControl string `json:"rts_control,omitempty"` DTRControl string `json:"dtr_control,omitempty"` IgnoreJammed bool `json:"ignore_jammed,omitempty"` } const ( CaptureModeOff = "off" CaptureModeRX = "rx" CaptureModeTX = "tx" CaptureModeRXTX = "rx_tx" ) // CaptureConfig controls bounded payload logging independently from termios. // Keeping this outside ChannelConfig allows logging policy changes while a // channel is actively leased without reopening or mutating the UART. type CaptureConfig struct { Channel string `json:"channel"` Mode string `json:"mode"` DataPackage CapturePackageConfig `json:"data_package"` DataLog CaptureLogConfig `json:"data_log"` } type CapturePackageConfig struct { Mode string `json:"mode"` Length int `json:"length"` TimeoutMillis int `json:"timeout_ms"` DelimiterProcess string `json:"delimiter_process"` Delimiter1 int `json:"delimiter_1"` Delimiter2 int `json:"delimiter_2"` } type CaptureTriggerConfig struct { Length int `json:"length"` TimeoutMillis int `json:"timeout_ms"` Process string `json:"process"` Character1 int `json:"character_1"` Character2 int `json:"character_2"` } type CaptureLogConfig struct { Read bool `json:"read"` Send bool `json:"send"` ShowTimestamp bool `json:"show_timestamp"` ShowDirection bool `json:"show_direction"` ShowUsername bool `json:"show_username"` ShowIPAddress bool `json:"show_ip_address"` ShowPort bool `json:"show_port"` Format string `json:"format"` ReadTrigger CaptureTriggerConfig `json:"read_trigger"` SendTrigger CaptureTriggerConfig `json:"send_trigger"` } type CaptureController interface { ApplyCapture(context.Context, []CaptureConfig) error } type LineSettings struct { BaudRate int `json:"baud_rate"` DataBits int `json:"data_bits"` Parity string `json:"parity"` StopBits int `json:"stop_bits"` StopBitsMode string `json:"stop_bits_mode"` FlowControl string `json:"flow_control"` RTSControl string `json:"rts_control"` DTRControl string `json:"dtr_control"` IgnoreJammed bool `json:"ignore_jammed"` } type State struct { Channel string `json:"channel"` Device string `json:"device,omitempty"` PCIBDF string `json:"pci_bdf,omitempty"` HardwareProfile string `json:"hardware_profile,omitempty"` MappingVersion string `json:"mapping_version,omitempty"` Available bool `json:"available"` Message string `json:"message,omitempty"` ProfileApplied bool `json:"profile_applied"` LineSettings *LineSettings `json:"line_settings,omitempty"` } type ProbeResult struct { Channel string `json:"channel"` Device string `json:"device"` Previous LineSettings `json:"previous"` Applied LineSettings `json:"applied"` Restored bool `json:"restored"` } type LinkState struct { Channel string `json:"channel"` State string `json:"state"` Signal string `json:"signal"` Available bool `json:"available"` EverUp bool `json:"ever_up"` Message string `json:"message,omitempty"` } type LinkStateController interface { LinkStates(context.Context) ([]LinkState, error) } // Controller is owned by tera-seriald. tera-api only receives this contract // through a local Unix socket and must never open a serial device itself. type Controller interface { States(ctx context.Context) ([]State, error) Apply(ctx context.Context, configs []ChannelConfig) ([]State, error) } type Mapping struct { FormatVersion int `json:"format_version"` HardwareProfile string `json:"hardware_profile"` MappingVersion string `json:"mapping_version"` Evidence string `json:"evidence"` Validated bool `json:"validated"` Channels []MappingChannel `json:"channels"` } type MappingChannel struct { Channel string `json:"channel"` Device string `json:"device"` PCIBDF string `json:"pci_bdf"` LinkSignal string `json:"link_signal,omitempty"` LinkSignals []string `json:"link_signals,omitempty"` } type MappingController struct { mapping Mapping byChannel map[string]MappingChannel ordered []MappingChannel lineOps lineOperations stateProbe func(MappingChannel) State modemProbe func(MappingChannel) LinkState activeStreams map[string]*StreamTarget heldDevices map[string]lineDevice applied map[string]LineSettings linkSeenUp map[string]bool linkWorkers int mu sync.Mutex } type lineDevice interface { Fd() uintptr Close() error } type lineOperations interface { Open(path string) (lineDevice, error) Snapshot(device lineDevice) (any, error) Apply(device lineDevice, config LineConfig) error Read(device lineDevice, path string) (LineConfig, error) Restore(device lineDevice, snapshot any) error } type resolvedLineConfig struct { entry MappingChannel line LineConfig } type transactionDevice struct { config resolvedLineConfig device lineDevice snapshot any } func LoadMappingController(path, expectedProfile string) (*MappingController, error) { file, err := os.Open(path) if err != nil { return nil, fmt.Errorf("read serial mapping: %w", err) } defer file.Close() decoder := json.NewDecoder(io.LimitReader(file, 128*1024)) decoder.DisallowUnknownFields() var mapping Mapping if err := decoder.Decode(&mapping); err != nil { return nil, fmt.Errorf("decode serial mapping: %w", err) } var extra any if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { return nil, errors.New("serial mapping must contain a single JSON value") } if expectedProfile != "" && mapping.HardwareProfile != expectedProfile { return nil, fmt.Errorf("serial mapping hardware profile does not match %s", expectedProfile) } return NewMappingController(mapping) } func NewMappingController(mapping Mapping) (*MappingController, error) { if mapping.FormatVersion != 1 { return nil, errors.New("serial mapping format_version must be 1") } if strings.TrimSpace(mapping.HardwareProfile) == "" { return nil, errors.New("serial mapping hardware_profile is required") } if strings.TrimSpace(mapping.MappingVersion) == "" { return nil, errors.New("serial mapping mapping_version is required") } if !mapping.Validated || strings.TrimSpace(mapping.Evidence) == "" { return nil, errors.New("serial mapping must be board-validated with evidence") } if len(mapping.Channels) != ChannelCount { return nil, fmt.Errorf("serial mapping must contain exactly %d channels", ChannelCount) } byChannel := make(map[string]MappingChannel, ChannelCount) devices := make(map[string]struct{}, ChannelCount) for _, item := range mapping.Channels { channel, err := normalizeChannel(item.Channel) if err != nil { return nil, err } item.Channel = channel item.Device = path.Clean(strings.TrimSpace(item.Device)) item.PCIBDF = strings.ToLower(strings.TrimSpace(item.PCIBDF)) item.LinkSignal = strings.ToLower(strings.TrimSpace(item.LinkSignal)) if len(item.LinkSignals) == 0 { if item.LinkSignal == "" { item.LinkSignal = "unknown" } item.LinkSignals = []string{item.LinkSignal} } seenSignals := make(map[string]bool, len(item.LinkSignals)) normalizedSignals := make([]string, 0, len(item.LinkSignals)) for _, configuredSignal := range item.LinkSignals { signal := strings.ToLower(strings.TrimSpace(configuredSignal)) if signal != "unknown" && signal != "dcd" && signal != "dsr" && signal != "cts" { return nil, fmt.Errorf("serial mapping %s link signals must be dcd, dsr, cts, or unknown", channel) } if seenSignals[signal] { return nil, fmt.Errorf("serial mapping %s contains duplicate link signal", channel) } seenSignals[signal] = true normalizedSignals = append(normalizedSignals, signal) } if len(normalizedSignals) > 3 || len(normalizedSignals) > 1 && seenSignals["unknown"] { return nil, fmt.Errorf("serial mapping %s link signals must contain one or more of dcd, dsr, and cts", channel) } item.LinkSignals = normalizedSignals item.LinkSignal = normalizedSignals[0] if !devicePattern.MatchString(item.Device) { return nil, fmt.Errorf("serial mapping %s device must be a /dev/ttyWCH node", channel) } if !pciBDFPattern.MatchString(item.PCIBDF) { return nil, fmt.Errorf("serial mapping %s pci_bdf is invalid", channel) } if _, exists := byChannel[channel]; exists { return nil, fmt.Errorf("serial mapping contains duplicate %s", channel) } if _, exists := devices[item.Device]; exists { return nil, fmt.Errorf("serial mapping contains duplicate device") } byChannel[channel] = item devices[item.Device] = struct{}{} } ordered := make([]MappingChannel, 0, ChannelCount) for index := 1; index <= ChannelCount; index++ { channel := fmt.Sprintf("CH%02d", index) item, ok := byChannel[channel] if !ok { return nil, fmt.Errorf("serial mapping is missing %s", channel) } if mapping.HardwareProfile == "fet3568j-c" { expectedBDF := "0000:01:00.0" expectedTTY := index + 3 if index > 24 { expectedBDF = "0002:21:00.0" expectedTTY = index + 7 } if item.PCIBDF != expectedBDF { return nil, fmt.Errorf("serial mapping %s must remain on its designated WCH board", channel) } expectedDevice := fmt.Sprintf("/dev/ttyWCH%d", expectedTTY) if item.Device != expectedDevice { return nil, fmt.Errorf("serial mapping %s must resolve to %s", channel, expectedDevice) } } ordered = append(ordered, item) } controller := &MappingController{ mapping: mapping, byChannel: byChannel, ordered: ordered, lineOps: platformLineOperations{}, activeStreams: make(map[string]*StreamTarget), heldDevices: make(map[string]lineDevice, ChannelCount), applied: make(map[string]LineSettings, ChannelCount), linkSeenUp: make(map[string]bool, ChannelCount*3), linkWorkers: 8, } controller.stateProbe = controller.stateFor controller.modemProbe = platformModemLinkState return controller, nil } func (c *MappingController) LinkStates(ctx context.Context) ([]LinkState, error) { if err := ctx.Err(); err != nil { return nil, err } c.mu.Lock() defer c.mu.Unlock() workers := c.linkWorkers if workers < 1 { workers = 1 } if workers > len(c.ordered) { workers = len(c.ordered) } type linkJob struct { index int entry MappingChannel } jobs := make(chan linkJob, len(c.ordered)) results := make([][]LinkState, len(c.ordered)) var wait sync.WaitGroup for worker := 0; worker < workers; worker++ { wait.Add(1) go func() { defer wait.Done() for job := range jobs { if ctx.Err() != nil { continue } states := make([]LinkState, 0, len(job.entry.LinkSignals)) for _, signal := range job.entry.LinkSignals { probe := job.entry probe.LinkSignal = signal states = append(states, c.modemProbe(probe)) } results[job.index] = states } }() } for index, entry := range c.ordered { jobs <- linkJob{index: index, entry: entry} } close(jobs) wait.Wait() if err := ctx.Err(); err != nil { return nil, err } states := make([]LinkState, 0, len(c.ordered)*2) for _, channelStates := range results { for _, state := range channelStates { key := state.Channel + "\x00" + state.Signal if state.State == "up" { c.linkSeenUp[key] = true } state.EverUp = c.linkSeenUp[key] states = append(states, state) } } return states, nil } func (c *MappingController) SetLinkWorkerCount(workers int) { if c == nil { return } if workers < 1 { workers = 1 } if workers > 16 { workers = 16 } c.mu.Lock() c.linkWorkers = workers c.mu.Unlock() } // CRProbeGroups returns the validated channel topology grouped by PCI // function. A probe scheduler can safely run separate PCI functions in // parallel while keeping channels sharing one IRQ strictly serial. func (c *MappingController) CRProbeGroups() []CRProbeGroup { if c == nil { return nil } c.mu.Lock() defer c.mu.Unlock() groupIndex := make(map[string]int) groups := make([]CRProbeGroup, 0, 2) for _, entry := range c.ordered { index, exists := groupIndex[entry.PCIBDF] if !exists { index = len(groups) groupIndex[entry.PCIBDF] = index groups = append(groups, CRProbeGroup{ID: entry.PCIBDF}) } groups[index].Channels = append(groups[index].Channels, entry.Channel) } return groups } func (c *MappingController) States(ctx context.Context) ([]State, error) { if err := ctx.Err(); err != nil { return nil, err } c.mu.Lock() defer c.mu.Unlock() states := make([]State, 0, len(c.ordered)) for _, item := range c.ordered { if err := ctx.Err(); err != nil { return nil, err } state := c.inspectState(item) if settings, ok := c.applied[item.Channel]; ok { settingsCopy := settings state.ProfileApplied = true state.LineSettings = &settingsCopy } states = append(states, state) } return states, nil } func (c *MappingController) Apply(ctx context.Context, configs []ChannelConfig) ([]State, error) { if err := ctx.Err(); err != nil { return nil, err } if len(configs) == 0 { return []State{}, nil } c.mu.Lock() defer c.mu.Unlock() resolved := make([]resolvedLineConfig, 0, len(configs)) seen := make(map[string]struct{}, len(configs)) for _, config := range configs { channel, err := normalizeChannel(config.Channel) if err != nil { return nil, err } if _, exists := seen[channel]; exists { return nil, fmt.Errorf("serial configuration contains duplicate %s", channel) } entry, ok := c.byChannel[channel] if !ok { return nil, &UnavailableError{Reason: "channel is not in the validated serial mapping"} } if _, leased := c.activeStreams[channel]; leased { return nil, ErrStreamLeaseBusy } line, err := NormalizeLineConfig(LineConfig{ Device: entry.Device, BaudRate: config.BaudRate, DataBits: config.DataBits, Parity: config.Parity, StopBits: config.StopBits, StopBitsMode: config.StopBitsMode, FlowControl: config.FlowControl, RTSControl: config.RTSControl, DTRControl: config.DTRControl, IgnoreJammed: config.IgnoreJammed, }) if err != nil { return nil, err } state := c.inspectState(entry) if !state.Available { return nil, &UnavailableError{Reason: channel + " " + state.Message} } seen[channel] = struct{}{} resolved = append(resolved, resolvedLineConfig{entry: entry, line: line}) } opened := make([]transactionDevice, 0, len(resolved)) defer func() { for index := len(opened) - 1; index >= 0; index-- { if opened[index].device != nil { _ = opened[index].device.Close() } } }() for index, config := range resolved { device, err := c.lineOps.Open(config.entry.Device) if err != nil { if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) { return nil, &UnavailableError{Reason: config.entry.Channel + " serial device cannot be opened"} } return nil, fmt.Errorf("serial configuration preparation failed at channel %d: %w", index+1, err) } snapshot, err := c.lineOps.Snapshot(device) if err != nil { _ = device.Close() return nil, fmt.Errorf("serial configuration snapshot failed at %s: %w", config.entry.Channel, err) } opened = append(opened, transactionDevice{config: config, device: device, snapshot: snapshot}) } states := make([]State, 0, len(opened)) for index := range opened { if err := ctx.Err(); err != nil { return nil, c.rollbackError(opened, err) } current := &opened[index] if err := c.lineOps.Apply(current.device, current.config.line); err != nil { return nil, c.rollbackError(opened, fmt.Errorf("apply %s: %w", current.config.entry.Channel, err)) } readback, err := c.lineOps.Read(current.device, current.config.entry.Device) if err != nil { return nil, c.rollbackError(opened, fmt.Errorf("readback %s: %w", current.config.entry.Channel, err)) } // Termios cannot report whether an asserted output was requested as // manual "on" or resulted from driver-managed "auto". ApplyLineConfig // already verifies manual output levels, so retain the requested policy. readback.RTSControl = current.config.line.RTSControl readback.DTRControl = current.config.line.DTRControl readback.IgnoreJammed = current.config.line.IgnoreJammed if !sameLineConfig(current.config.line, readback) { return nil, c.rollbackError(opened, fmt.Errorf("readback %s does not match requested line settings", current.config.entry.Channel)) } state := c.inspectState(current.config.entry) settings := lineSettings(readback) state.ProfileApplied = true state.LineSettings = &settings states = append(states, state) } for _, state := range states { if state.LineSettings != nil { c.applied[state.Channel] = *state.LineSettings } } // Keep one descriptor open per configured channel. This prevents the tty // driver from treating a Direct Mode stream close as a modem hang-up and // dropping manually asserted DTR/RTS outputs. for index := range opened { channel := opened[index].config.entry.Channel previous := c.heldDevices[channel] c.heldDevices[channel] = opened[index].device opened[index].device = nil if previous != nil { _ = previous.Close() } } return states, nil } func (c *MappingController) Probe(ctx context.Context, config ChannelConfig) (ProbeResult, error) { if err := ctx.Err(); err != nil { return ProbeResult{}, err } channel, err := normalizeChannel(config.Channel) if err != nil { return ProbeResult{}, err } if channel != "CH01" { return ProbeResult{}, errors.New("live serial line probe is restricted to CH01") } c.mu.Lock() defer c.mu.Unlock() entry, ok := c.byChannel[channel] if !ok { return ProbeResult{}, &UnavailableError{Reason: "CH01 is not in the validated serial mapping"} } if _, leased := c.activeStreams[channel]; leased { return ProbeResult{}, ErrStreamLeaseBusy } state := c.inspectState(entry) if !state.Available { return ProbeResult{}, &UnavailableError{Reason: channel + " " + state.Message} } requested, err := NormalizeLineConfig(LineConfig{ Device: entry.Device, BaudRate: config.BaudRate, DataBits: config.DataBits, Parity: config.Parity, StopBits: config.StopBits, StopBitsMode: config.StopBitsMode, FlowControl: config.FlowControl, RTSControl: config.RTSControl, DTRControl: config.DTRControl, IgnoreJammed: config.IgnoreJammed, }) if err != nil { return ProbeResult{}, err } device, err := c.lineOps.Open(entry.Device) if err != nil { return ProbeResult{}, fmt.Errorf("open CH01 for line probe: %w", err) } defer device.Close() snapshot, err := c.lineOps.Snapshot(device) if err != nil { return ProbeResult{}, fmt.Errorf("snapshot CH01 line settings: %w", err) } previous, err := c.lineOps.Read(device, entry.Device) if err != nil { return ProbeResult{}, fmt.Errorf("read CH01 line settings: %w", err) } result := ProbeResult{Channel: channel, Device: entry.Device, Previous: lineSettings(previous)} applyErr := c.lineOps.Apply(device, requested) if applyErr == nil { var applied LineConfig applied, applyErr = c.lineOps.Read(device, entry.Device) applied.RTSControl = requested.RTSControl applied.DTRControl = requested.DTRControl applied.IgnoreJammed = requested.IgnoreJammed if applyErr == nil && !sameLineConfig(requested, applied) { applyErr = errors.New("CH01 line probe readback does not match requested line settings") } if applyErr == nil { result.Applied = lineSettings(applied) } } restoreErr := c.lineOps.Restore(device, snapshot) if restoreErr == nil { restored, readErr := c.lineOps.Read(device, entry.Device) if readErr != nil { restoreErr = readErr } else if !sameLineConfig(previous, restored) { restoreErr = errors.New("CH01 restored line settings do not match the prior state") } } result.Restored = restoreErr == nil if restoreErr != nil { return result, fmt.Errorf("CH01 line probe restore failed: %w", restoreErr) } if applyErr != nil { return result, fmt.Errorf("CH01 line probe failed and restored prior settings: %w", applyErr) } return result, nil } // Close releases descriptors retained to keep manual modem outputs asserted. // Active stream descriptors are owned and closed by StreamLeaseManager. func (c *MappingController) Close() error { if c == nil { return nil } c.mu.Lock() defer c.mu.Unlock() errorsByChannel := make([]error, 0) for channel, device := range c.heldDevices { if device != nil { if err := device.Close(); err != nil { errorsByChannel = append(errorsByChannel, fmt.Errorf("close %s serial hold: %w", channel, err)) } } delete(c.heldDevices, channel) } return errors.Join(errorsByChannel...) } func (c *MappingController) rollbackError(opened []transactionDevice, cause error) error { rollbackFailures := make([]string, 0) for index := len(opened) - 1; index >= 0; index-- { if err := c.lineOps.Restore(opened[index].device, opened[index].snapshot); err != nil { rollbackFailures = append(rollbackFailures, opened[index].config.entry.Channel+": "+err.Error()) } } if len(rollbackFailures) != 0 { return fmt.Errorf("%w; rollback failed: %s", cause, strings.Join(rollbackFailures, "; ")) } return fmt.Errorf("%w; rolled back %d channel(s)", cause, len(opened)) } func (c *MappingController) inspectState(entry MappingChannel) State { if c.stateProbe != nil { return c.stateProbe(entry) } return c.stateFor(entry) } func lineSettings(config LineConfig) LineSettings { return LineSettings{ BaudRate: config.BaudRate, DataBits: config.DataBits, Parity: config.Parity, StopBits: config.StopBits, StopBitsMode: config.StopBitsMode, FlowControl: config.FlowControl, RTSControl: config.RTSControl, DTRControl: config.DTRControl, IgnoreJammed: config.IgnoreJammed, } } func sameLineConfig(expected, actual LineConfig) bool { return expected.Device == actual.Device && expected.BaudRate == actual.BaudRate && expected.DataBits == actual.DataBits && expected.Parity == actual.Parity && expected.StopBitsMode == actual.StopBitsMode && expected.FlowControl == actual.FlowControl } func (c *MappingController) stateFor(entry MappingChannel) State { state := State{ Channel: entry.Channel, Device: entry.Device, PCIBDF: entry.PCIBDF, HardwareProfile: c.mapping.HardwareProfile, MappingVersion: c.mapping.MappingVersion, } info, err := os.Stat(entry.Device) if err != nil { state.Message = "serial device is unavailable" return state } if info.Mode()&os.ModeCharDevice == 0 { state.Message = "serial mapping device is not a character device" return state } ttyPath := filepath.Join("/sys/class/tty", filepath.Base(entry.Device), "device") resolvedPath, err := filepath.EvalSymlinks(ttyPath) if err != nil { state.Message = "serial device PCI ancestry is unavailable" return state } if !strings.Contains(strings.ToLower(filepath.ToSlash(resolvedPath)), entry.PCIBDF) { state.Message = "serial device PCI ancestry does not match validated mapping" return state } state.Available = true return state } type UnavailableController struct { Reason string } func (c UnavailableController) States(context.Context) ([]State, error) { states := make([]State, 0, ChannelCount) for index := 1; index <= ChannelCount; index++ { states = append(states, State{Channel: fmt.Sprintf("CH%02d", index), Message: c.reason()}) } return states, nil } func (c UnavailableController) Apply(context.Context, []ChannelConfig) ([]State, error) { return nil, &UnavailableError{Reason: c.reason()} } func (c UnavailableController) ApplyCapture(context.Context, []CaptureConfig) error { return &UnavailableError{Reason: c.reason()} } func (c UnavailableController) ReserveStream(context.Context, string) (*StreamTarget, error) { return nil, &UnavailableError{Reason: c.reason()} } func (c UnavailableController) reason() string { if strings.TrimSpace(c.Reason) != "" { return strings.TrimSpace(c.Reason) } return "tera-seriald is not connected" } // MockController is safe for in-memory API tests only. Production main always // injects the tera-seriald socket client and therefore never reaches this path. type MockController struct{} func (MockController) States(ctx context.Context) ([]State, error) { return UnavailableController{Reason: "serial hardware adapter is not configured in mock mode"}.States(ctx) } func (MockController) Apply(_ context.Context, configs []ChannelConfig) ([]State, error) { states := make([]State, 0, len(configs)) for _, config := range configs { channel, err := normalizeChannel(config.Channel) if err != nil { return nil, err } if _, err := NormalizeLineConfig(LineConfig{ Device: "/dev/ttyWCH0", BaudRate: config.BaudRate, DataBits: config.DataBits, Parity: config.Parity, StopBits: config.StopBits, StopBitsMode: config.StopBitsMode, FlowControl: config.FlowControl, RTSControl: config.RTSControl, DTRControl: config.DTRControl, IgnoreJammed: config.IgnoreJammed, }); err != nil { return nil, err } states = append(states, State{Channel: channel, Message: "mock serial configuration accepted"}) } return states, nil } func (MockController) ApplyCapture(_ context.Context, configs []CaptureConfig) error { _, err := normalizeCaptureConfigs(configs) return err } func normalizeCaptureConfigs(configs []CaptureConfig) (map[string]CaptureConfig, error) { normalized := make(map[string]CaptureConfig, len(configs)) for _, config := range configs { channel, err := normalizeChannel(config.Channel) if err != nil || channel != strings.ToUpper(strings.TrimSpace(config.Channel)) { return nil, errors.New("capture channel must be canonical CH01 through CH48") } if _, exists := normalized[channel]; exists { return nil, fmt.Errorf("capture configuration contains duplicate channel %s", channel) } mode := strings.ToLower(strings.TrimSpace(config.Mode)) switch mode { case CaptureModeOff, CaptureModeRX, CaptureModeTX, CaptureModeRXTX: default: return nil, errors.New("capture mode must be off, rx, tx, or rx_tx") } config.Channel = channel config.Mode = mode if strings.TrimSpace(config.DataPackage.Mode) == "" { config.DataPackage = CapturePackageConfig{Mode: "interval", Length: MaxCapturedPayloadBytes, DelimiterProcess: "keep", Delimiter1: -1, Delimiter2: -1} } config.DataPackage.Mode = strings.ToLower(strings.TrimSpace(config.DataPackage.Mode)) config.DataPackage.DelimiterProcess = strings.ToLower(strings.TrimSpace(config.DataPackage.DelimiterProcess)) if config.DataPackage.Mode != "disabled" && config.DataPackage.Mode != "interval" && config.DataPackage.Mode != "length" && config.DataPackage.Mode != "timeout" && config.DataPackage.Mode != "delimiter" { return nil, errors.New("capture data_package mode is invalid") } if config.DataPackage.Length < 1 || config.DataPackage.Length > 65535 || config.DataPackage.TimeoutMillis < 0 || config.DataPackage.TimeoutMillis > 60000 { return nil, errors.New("capture data_package length or timeout is out of range") } if config.DataPackage.DelimiterProcess != "keep" && config.DataPackage.DelimiterProcess != "remove" { return nil, errors.New("capture delimiter process must be keep or remove") } if !captureByteValue(config.DataPackage.Delimiter1) || !captureByteValue(config.DataPackage.Delimiter2) { return nil, errors.New("capture delimiters must be -1 or 0 through 255") } if strings.TrimSpace(config.DataLog.Format) == "" { config.DataLog = CaptureLogConfig{ Read: mode == CaptureModeRX || mode == CaptureModeRXTX, Send: mode == CaptureModeTX || mode == CaptureModeRXTX, ShowTimestamp: true, ShowDirection: true, Format: "ascii", ReadTrigger: CaptureTriggerConfig{Process: "keep", Character1: -1, Character2: -1}, SendTrigger: CaptureTriggerConfig{Process: "keep", Character1: -1, Character2: -1}, } } config.DataLog.Format = strings.ToLower(strings.TrimSpace(config.DataLog.Format)) if config.DataLog.Format != "ascii" && config.DataLog.Format != "hex" && config.DataLog.Format != "ascii_hex" { return nil, errors.New("capture data_log format is invalid") } for _, trigger := range []CaptureTriggerConfig{config.DataLog.ReadTrigger, config.DataLog.SendTrigger} { if trigger.Length < 0 || trigger.Length > 65535 || trigger.TimeoutMillis < 0 || trigger.TimeoutMillis > 60000 || (strings.ToLower(strings.TrimSpace(trigger.Process)) != "keep" && strings.ToLower(strings.TrimSpace(trigger.Process)) != "remove") || !captureByteValue(trigger.Character1) || !captureByteValue(trigger.Character2) { return nil, errors.New("capture data_log trigger is invalid") } } config.DataLog.ReadTrigger.Process = strings.ToLower(strings.TrimSpace(config.DataLog.ReadTrigger.Process)) config.DataLog.SendTrigger.Process = strings.ToLower(strings.TrimSpace(config.DataLog.SendTrigger.Process)) normalized[channel] = config } return normalized, nil } func captureByteValue(value int) bool { return value >= -1 && value <= 255 } func normalizeChannel(value string) (string, error) { match := channelPattern.FindStringSubmatch(strings.ToUpper(strings.TrimSpace(value))) if len(match) != 2 { return "", errors.New("channel must be CH01 through CH48") } var index int if _, err := fmt.Sscanf(match[1], "%d", &index); err != nil || index < 1 || index > ChannelCount { return "", errors.New("channel must be CH01 through CH48") } return fmt.Sprintf("CH%02d", index), nil }