You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

893 lines
31 KiB

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
}