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
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
|
|
}
|
|
|