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.
755 lines
22 KiB
755 lines
22 KiB
|
2 months ago
|
package serial
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/rand"
|
||
|
|
"encoding/base64"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"regexp"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"sync/atomic"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
DefaultStreamSocketPath = "/run/tera-seriald/serial-stream.sock"
|
||
|
|
MaxCapturedPayloadBytes = 4 << 10
|
||
|
|
CRProbeClientID = "tera-serial-link-probe"
|
||
|
|
)
|
||
|
|
|
||
|
|
var (
|
||
|
|
ErrStreamLeaseBusy = errors.New("serial stream lease is already active")
|
||
|
|
streamClientPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
|
||
|
|
)
|
||
|
|
|
||
|
|
// StreamTarget is a short-lived reservation of one validated mapping entry.
|
||
|
|
// Release is idempotent and must be called if opening the device fails.
|
||
|
|
type StreamTarget struct {
|
||
|
|
Channel string
|
||
|
|
Device string
|
||
|
|
Group string
|
||
|
|
|
||
|
|
releaseOnce sync.Once
|
||
|
|
release func()
|
||
|
|
}
|
||
|
|
|
||
|
|
func newStreamTarget(channel, device, group string, release func()) *StreamTarget {
|
||
|
|
return &StreamTarget{Channel: channel, Device: device, Group: group, release: release}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (t *StreamTarget) Release() {
|
||
|
|
if t == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
t.releaseOnce.Do(func() {
|
||
|
|
if t.release != nil {
|
||
|
|
t.release()
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// StreamResolver prevents a stream lease from bypassing the board-validated
|
||
|
|
// CH mapping. MappingController is the production implementation.
|
||
|
|
type StreamResolver interface {
|
||
|
|
ReserveStream(context.Context, string) (*StreamTarget, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
type StreamOpener interface {
|
||
|
|
OpenStream(path string) (io.ReadWriteCloser, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
type platformStreamOpener struct{}
|
||
|
|
|
||
|
|
func (platformStreamOpener) OpenStream(path string) (io.ReadWriteCloser, error) {
|
||
|
|
return openSerialDevice(path)
|
||
|
|
}
|
||
|
|
|
||
|
|
type StreamEvent struct {
|
||
|
|
Timestamp time.Time `json:"timestamp"`
|
||
|
|
Channel string `json:"channel"`
|
||
|
|
LeaseID string `json:"lease_id"`
|
||
|
|
ClientID string `json:"client_id"`
|
||
|
|
Direction string `json:"direction"`
|
||
|
|
Bytes uint64 `json:"bytes,omitempty"`
|
||
|
|
Payload []byte `json:"payload,omitempty"`
|
||
|
|
LogFormat string `json:"log_format,omitempty"`
|
||
|
|
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"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// StreamEventSink is deliberately non-blocking. Payload ownership transfers
|
||
|
|
// to the sink; BoundedStreamEventSink copies it before queueing so caller
|
||
|
|
// buffers can be reused immediately.
|
||
|
|
type StreamEventSink interface {
|
||
|
|
TryRecord(StreamEvent) bool
|
||
|
|
}
|
||
|
|
|
||
|
|
type BoundedStreamEventSink struct {
|
||
|
|
events chan StreamEvent
|
||
|
|
dropped atomic.Uint64
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewBoundedStreamEventSink(capacity int) *BoundedStreamEventSink {
|
||
|
|
if capacity < 1 {
|
||
|
|
capacity = 1
|
||
|
|
}
|
||
|
|
if capacity > 4096 {
|
||
|
|
capacity = 4096
|
||
|
|
}
|
||
|
|
return &BoundedStreamEventSink{events: make(chan StreamEvent, capacity)}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *BoundedStreamEventSink) TryRecord(event StreamEvent) bool {
|
||
|
|
if s == nil {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
if len(event.Payload) > 0 {
|
||
|
|
event.Payload = append([]byte(nil), event.Payload...)
|
||
|
|
}
|
||
|
|
select {
|
||
|
|
case s.events <- event:
|
||
|
|
return true
|
||
|
|
default:
|
||
|
|
s.dropped.Add(1)
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *BoundedStreamEventSink) Events() <-chan StreamEvent { return s.events }
|
||
|
|
func (s *BoundedStreamEventSink) Dropped() uint64 { return s.dropped.Load() }
|
||
|
|
|
||
|
|
type StreamLeaseOptions struct {
|
||
|
|
Opener StreamOpener
|
||
|
|
Events StreamEventSink
|
||
|
|
}
|
||
|
|
|
||
|
|
type StreamSnapshot struct {
|
||
|
|
ActiveLeases int `json:"active_leases"`
|
||
|
|
RXBytes uint64 `json:"rx_bytes"`
|
||
|
|
TXBytes uint64 `json:"tx_bytes"`
|
||
|
|
DroppedEvents uint64 `json:"dropped_events"`
|
||
|
|
Channels []StreamChannelSnapshot `json:"channels,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type StreamChannelSnapshot struct {
|
||
|
|
Channel string `json:"channel"`
|
||
|
|
Active bool `json:"active"`
|
||
|
|
RXBytes uint64 `json:"rx_bytes"`
|
||
|
|
TXBytes uint64 `json:"tx_bytes"`
|
||
|
|
LastRXAt *time.Time `json:"last_rx_at,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type streamChannelCounters struct {
|
||
|
|
rx atomic.Uint64
|
||
|
|
tx atomic.Uint64
|
||
|
|
lastRXAt atomic.Int64
|
||
|
|
lastExternalActivity atomic.Int64
|
||
|
|
}
|
||
|
|
|
||
|
|
type StreamLeaseManager struct {
|
||
|
|
resolver StreamResolver
|
||
|
|
opener StreamOpener
|
||
|
|
events StreamEventSink
|
||
|
|
capture map[string]CaptureConfig
|
||
|
|
buffers map[string]*captureAccumulator
|
||
|
|
|
||
|
|
mu sync.Mutex
|
||
|
|
captureMu sync.Mutex
|
||
|
|
openMu sync.Mutex
|
||
|
|
openLocks map[string]*sync.Mutex
|
||
|
|
active map[string]*StreamLease
|
||
|
|
counts map[string]*streamChannelCounters
|
||
|
|
rx atomic.Uint64
|
||
|
|
tx atomic.Uint64
|
||
|
|
drops atomic.Uint64
|
||
|
|
}
|
||
|
|
|
||
|
|
type captureAccumulator struct {
|
||
|
|
channel string
|
||
|
|
leaseID string
|
||
|
|
clientID string
|
||
|
|
direction string
|
||
|
|
config CaptureConfig
|
||
|
|
payload []byte
|
||
|
|
timer *time.Timer
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewStreamLeaseManager(resolver StreamResolver, options StreamLeaseOptions) (*StreamLeaseManager, error) {
|
||
|
|
if resolver == nil {
|
||
|
|
return nil, errors.New("serial stream resolver is required")
|
||
|
|
}
|
||
|
|
opener := options.Opener
|
||
|
|
if opener == nil {
|
||
|
|
opener = platformStreamOpener{}
|
||
|
|
}
|
||
|
|
manager := &StreamLeaseManager{
|
||
|
|
resolver: resolver,
|
||
|
|
opener: opener,
|
||
|
|
events: options.Events,
|
||
|
|
active: make(map[string]*StreamLease),
|
||
|
|
capture: defaultCaptureModes(),
|
||
|
|
buffers: make(map[string]*captureAccumulator),
|
||
|
|
counts: make(map[string]*streamChannelCounters, ChannelCount),
|
||
|
|
openLocks: make(map[string]*sync.Mutex),
|
||
|
|
}
|
||
|
|
for index := 1; index <= ChannelCount; index++ {
|
||
|
|
manager.counts[fmt.Sprintf("CH%02d", index)] = &streamChannelCounters{}
|
||
|
|
}
|
||
|
|
return manager, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ApplyCapture atomically changes payload logging without touching an active
|
||
|
|
// UART lease. Unspecified channels keep their current setting.
|
||
|
|
func (m *StreamLeaseManager) ApplyCapture(ctx context.Context, configs []CaptureConfig) error {
|
||
|
|
if err := ctx.Err(); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
normalized, err := normalizeCaptureConfigs(configs)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
m.mu.Lock()
|
||
|
|
for channel, config := range normalized {
|
||
|
|
m.capture[channel] = config
|
||
|
|
}
|
||
|
|
m.mu.Unlock()
|
||
|
|
m.captureMu.Lock()
|
||
|
|
for key, accumulator := range m.buffers {
|
||
|
|
if _, changed := normalized[accumulator.channel]; !changed {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if accumulator.timer != nil {
|
||
|
|
accumulator.timer.Stop()
|
||
|
|
}
|
||
|
|
delete(m.buffers, key)
|
||
|
|
}
|
||
|
|
m.captureMu.Unlock()
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *StreamLeaseManager) Acquire(ctx context.Context, channel, clientID string) (*StreamLease, error) {
|
||
|
|
if err := ctx.Err(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
canonical, err := normalizeChannel(channel)
|
||
|
|
if err != nil || canonical != channel {
|
||
|
|
return nil, errors.New("stream channel must be canonical CH01 through CH48")
|
||
|
|
}
|
||
|
|
if !streamClientPattern.MatchString(clientID) {
|
||
|
|
return nil, errors.New("stream client_id is invalid")
|
||
|
|
}
|
||
|
|
|
||
|
|
m.mu.Lock()
|
||
|
|
if _, exists := m.active[channel]; exists {
|
||
|
|
m.mu.Unlock()
|
||
|
|
return nil, ErrStreamLeaseBusy
|
||
|
|
}
|
||
|
|
// A nil entry reserves the channel while mapping validation and open run.
|
||
|
|
m.active[channel] = nil
|
||
|
|
m.mu.Unlock()
|
||
|
|
|
||
|
|
failed := true
|
||
|
|
defer func() {
|
||
|
|
if failed {
|
||
|
|
m.mu.Lock()
|
||
|
|
if current, exists := m.active[channel]; exists && current == nil {
|
||
|
|
delete(m.active, channel)
|
||
|
|
}
|
||
|
|
m.mu.Unlock()
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
|
||
|
|
target, err := m.resolver.ReserveStream(ctx, channel)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if target == nil || target.Channel != channel || target.Device == "" {
|
||
|
|
if target != nil {
|
||
|
|
target.Release()
|
||
|
|
}
|
||
|
|
return nil, &UnavailableError{Reason: "serial stream resolver returned an invalid target"}
|
||
|
|
}
|
||
|
|
if err := ctx.Err(); err != nil {
|
||
|
|
target.Release()
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
openLock := m.openLock(target.Group, target.Device)
|
||
|
|
openLock.Lock()
|
||
|
|
device, err := m.opener.OpenStream(target.Device)
|
||
|
|
openLock.Unlock()
|
||
|
|
if err != nil {
|
||
|
|
target.Release()
|
||
|
|
return nil, &UnavailableError{Reason: fmt.Sprintf("%s serial stream device cannot be opened", channel)}
|
||
|
|
}
|
||
|
|
leaseID, err := newStreamLeaseID()
|
||
|
|
if err != nil {
|
||
|
|
_ = device.Close()
|
||
|
|
target.Release()
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
lease := &StreamLease{
|
||
|
|
manager: m, target: target, device: device,
|
||
|
|
channel: channel, clientID: clientID, leaseID: leaseID,
|
||
|
|
}
|
||
|
|
m.mu.Lock()
|
||
|
|
m.active[channel] = lease
|
||
|
|
m.mu.Unlock()
|
||
|
|
failed = false
|
||
|
|
if clientID != CRProbeClientID {
|
||
|
|
m.emit(StreamEvent{Channel: channel, LeaseID: leaseID, ClientID: clientID, Direction: "open"})
|
||
|
|
}
|
||
|
|
return lease, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// openLock serializes the short open(2) path for UARTs sharing one PCI
|
||
|
|
// function/IRQ. Active leases remain independent after the device is open.
|
||
|
|
func (m *StreamLeaseManager) openLock(group, device string) *sync.Mutex {
|
||
|
|
key := strings.TrimSpace(group)
|
||
|
|
if key == "" {
|
||
|
|
key = device
|
||
|
|
}
|
||
|
|
m.openMu.Lock()
|
||
|
|
defer m.openMu.Unlock()
|
||
|
|
lock := m.openLocks[key]
|
||
|
|
if lock == nil {
|
||
|
|
lock = &sync.Mutex{}
|
||
|
|
m.openLocks[key] = lock
|
||
|
|
}
|
||
|
|
return lock
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *StreamLeaseManager) Snapshot() StreamSnapshot {
|
||
|
|
m.mu.Lock()
|
||
|
|
active := 0
|
||
|
|
activeChannels := make(map[string]bool, len(m.active))
|
||
|
|
for channel, lease := range m.active {
|
||
|
|
if lease != nil && lease.clientID == CRProbeClientID {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
active++
|
||
|
|
activeChannels[channel] = true
|
||
|
|
}
|
||
|
|
m.mu.Unlock()
|
||
|
|
channels := make([]StreamChannelSnapshot, 0, len(m.counts))
|
||
|
|
for index := 1; index <= ChannelCount; index++ {
|
||
|
|
channel := fmt.Sprintf("CH%02d", index)
|
||
|
|
counters := m.counts[channel]
|
||
|
|
rxBytes := counters.rx.Load()
|
||
|
|
txBytes := counters.tx.Load()
|
||
|
|
lastRXNanoseconds := counters.lastRXAt.Load()
|
||
|
|
if !activeChannels[channel] && rxBytes == 0 && txBytes == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
var lastRXAt *time.Time
|
||
|
|
if lastRXNanoseconds > 0 {
|
||
|
|
value := time.Unix(0, lastRXNanoseconds).UTC()
|
||
|
|
lastRXAt = &value
|
||
|
|
}
|
||
|
|
channels = append(channels, StreamChannelSnapshot{
|
||
|
|
Channel: channel, Active: activeChannels[channel], RXBytes: rxBytes, TXBytes: txBytes, LastRXAt: lastRXAt,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return StreamSnapshot{
|
||
|
|
ActiveLeases: active,
|
||
|
|
RXBytes: m.rx.Load(), TXBytes: m.tx.Load(), DroppedEvents: m.drops.Load(),
|
||
|
|
Channels: channels,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// RecentlyActive reports user/session traffic only. Internal CR probes do not
|
||
|
|
// extend the quiet period and are not exposed as active user sessions.
|
||
|
|
func (m *StreamLeaseManager) RecentlyActive(channel string, quietPeriod time.Duration, now time.Time) bool {
|
||
|
|
if m == nil || quietPeriod <= 0 {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
counters, ok := m.counts[channel]
|
||
|
|
if !ok {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
nanoseconds := counters.lastExternalActivity.Load()
|
||
|
|
if nanoseconds <= 0 {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
return now.Sub(time.Unix(0, nanoseconds)) < quietPeriod
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *StreamLeaseManager) Close() error {
|
||
|
|
m.mu.Lock()
|
||
|
|
leases := make([]*StreamLease, 0, len(m.active))
|
||
|
|
for _, lease := range m.active {
|
||
|
|
if lease != nil {
|
||
|
|
leases = append(leases, lease)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
m.mu.Unlock()
|
||
|
|
var closeErr error
|
||
|
|
for _, lease := range leases {
|
||
|
|
if err := lease.Close(); err != nil {
|
||
|
|
closeErr = errors.Join(closeErr, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return closeErr
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *StreamLeaseManager) release(lease *StreamLease) {
|
||
|
|
m.flushCaptureLease(lease.leaseID)
|
||
|
|
m.mu.Lock()
|
||
|
|
if current, exists := m.active[lease.channel]; exists && current == lease {
|
||
|
|
delete(m.active, lease.channel)
|
||
|
|
}
|
||
|
|
m.mu.Unlock()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *StreamLeaseManager) emit(event StreamEvent) {
|
||
|
|
if event.Timestamp.IsZero() {
|
||
|
|
event.Timestamp = time.Now().UTC()
|
||
|
|
}
|
||
|
|
if m.events != nil && !m.events.TryRecord(event) {
|
||
|
|
m.drops.Add(1)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *StreamLeaseManager) emitPayload(channel, leaseID, clientID, direction string, payload []byte) {
|
||
|
|
config, enabled := m.captureConfig(channel, direction)
|
||
|
|
if len(payload) == 0 || !enabled {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
trigger := config.DataLog.ReadTrigger
|
||
|
|
if direction == CaptureModeTX {
|
||
|
|
trigger = config.DataLog.SendTrigger
|
||
|
|
}
|
||
|
|
if config.DataPackage.Mode == "disabled" {
|
||
|
|
for len(payload) > 0 {
|
||
|
|
size := min(len(payload), MaxCapturedPayloadBytes)
|
||
|
|
m.emitCaptured(channel, leaseID, clientID, direction, config, trigger, payload[:size])
|
||
|
|
payload = payload[size:]
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
key := leaseID + "\x00" + direction
|
||
|
|
var ready [][]byte
|
||
|
|
m.captureMu.Lock()
|
||
|
|
accumulator := m.buffers[key]
|
||
|
|
if accumulator == nil {
|
||
|
|
accumulator = &captureAccumulator{channel: channel, leaseID: leaseID, clientID: clientID, direction: direction, config: config}
|
||
|
|
m.buffers[key] = accumulator
|
||
|
|
}
|
||
|
|
if accumulator.timer != nil {
|
||
|
|
accumulator.timer.Stop()
|
||
|
|
accumulator.timer = nil
|
||
|
|
}
|
||
|
|
accumulator.config = config
|
||
|
|
accumulator.payload = append(accumulator.payload, payload...)
|
||
|
|
for len(accumulator.payload) > 0 {
|
||
|
|
boundary := captureFrameBoundary(accumulator.payload, config, trigger)
|
||
|
|
if boundary == 0 {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
ready = append(ready, append([]byte(nil), accumulator.payload[:boundary]...))
|
||
|
|
accumulator.payload = accumulator.payload[boundary:]
|
||
|
|
}
|
||
|
|
if len(accumulator.payload) > 0 {
|
||
|
|
if timeout := captureFlushTimeout(config, trigger); timeout > 0 {
|
||
|
|
accumulator.timer = time.AfterFunc(timeout, func() { m.flushCaptureKey(key) })
|
||
|
|
} else if captureFrameBoundary(accumulator.payload, config, trigger) == 0 {
|
||
|
|
ready = append(ready, append([]byte(nil), accumulator.payload...))
|
||
|
|
accumulator.payload = nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(accumulator.payload) == 0 {
|
||
|
|
delete(m.buffers, key)
|
||
|
|
}
|
||
|
|
m.captureMu.Unlock()
|
||
|
|
for _, frame := range ready {
|
||
|
|
m.emitCaptured(channel, leaseID, clientID, direction, config, trigger, frame)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *StreamLeaseManager) emitCaptured(channel, leaseID, clientID, direction string, config CaptureConfig, trigger CaptureTriggerConfig, payload []byte) {
|
||
|
|
chunk := append([]byte(nil), payload...)
|
||
|
|
chunk = processCaptureDelimiter(chunk, trigger.Process, trigger.Character1, trigger.Character2)
|
||
|
|
chunk = processCaptureDelimiter(chunk, config.DataPackage.DelimiterProcess, config.DataPackage.Delimiter1, config.DataPackage.Delimiter2)
|
||
|
|
if len(chunk) == 0 {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
m.emit(StreamEvent{
|
||
|
|
Channel: channel, LeaseID: leaseID, ClientID: clientID,
|
||
|
|
Direction: direction, Bytes: uint64(len(chunk)), Payload: chunk,
|
||
|
|
LogFormat: config.DataLog.Format, ShowTimestamp: config.DataLog.ShowTimestamp,
|
||
|
|
ShowDirection: config.DataLog.ShowDirection, ShowUsername: config.DataLog.ShowUsername,
|
||
|
|
ShowIPAddress: config.DataLog.ShowIPAddress, ShowPort: config.DataLog.ShowPort,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *StreamLeaseManager) flushCaptureKey(key string) {
|
||
|
|
m.captureMu.Lock()
|
||
|
|
accumulator := m.buffers[key]
|
||
|
|
if accumulator == nil {
|
||
|
|
m.captureMu.Unlock()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
delete(m.buffers, key)
|
||
|
|
if accumulator.timer != nil {
|
||
|
|
accumulator.timer.Stop()
|
||
|
|
}
|
||
|
|
payload := append([]byte(nil), accumulator.payload...)
|
||
|
|
m.captureMu.Unlock()
|
||
|
|
trigger := accumulator.config.DataLog.ReadTrigger
|
||
|
|
if accumulator.direction == CaptureModeTX {
|
||
|
|
trigger = accumulator.config.DataLog.SendTrigger
|
||
|
|
}
|
||
|
|
m.emitCaptured(accumulator.channel, accumulator.leaseID, accumulator.clientID, accumulator.direction, accumulator.config, trigger, payload)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *StreamLeaseManager) flushCaptureLease(leaseID string) {
|
||
|
|
prefix := leaseID + "\x00"
|
||
|
|
m.captureMu.Lock()
|
||
|
|
keys := make([]string, 0, 2)
|
||
|
|
for key := range m.buffers {
|
||
|
|
if strings.HasPrefix(key, prefix) {
|
||
|
|
keys = append(keys, key)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
m.captureMu.Unlock()
|
||
|
|
for _, key := range keys {
|
||
|
|
m.flushCaptureKey(key)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func captureFrameBoundary(payload []byte, config CaptureConfig, trigger CaptureTriggerConfig) int {
|
||
|
|
boundary := 0
|
||
|
|
consider := func(candidate int) {
|
||
|
|
if candidate > 0 && (boundary == 0 || candidate < boundary) {
|
||
|
|
boundary = candidate
|
||
|
|
}
|
||
|
|
}
|
||
|
|
consider(min(len(payload), MaxCapturedPayloadBytes))
|
||
|
|
if len(payload) < MaxCapturedPayloadBytes {
|
||
|
|
boundary = 0
|
||
|
|
}
|
||
|
|
if (config.DataPackage.Mode == "length" || config.DataPackage.Mode == "interval") && config.DataPackage.Length > 0 && len(payload) >= config.DataPackage.Length {
|
||
|
|
consider(config.DataPackage.Length)
|
||
|
|
}
|
||
|
|
if config.DataPackage.Mode == "delimiter" {
|
||
|
|
consider(captureDelimiterBoundary(payload, config.DataPackage.Delimiter1, config.DataPackage.Delimiter2))
|
||
|
|
}
|
||
|
|
if trigger.Length > 0 && len(payload) >= trigger.Length {
|
||
|
|
consider(trigger.Length)
|
||
|
|
}
|
||
|
|
consider(captureDelimiterBoundary(payload, trigger.Character1, trigger.Character2))
|
||
|
|
return boundary
|
||
|
|
}
|
||
|
|
|
||
|
|
func captureDelimiterBoundary(payload []byte, first, second int) int {
|
||
|
|
if first < 0 && second < 0 {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
if first >= 0 && second >= 0 {
|
||
|
|
for index := 0; index+1 < len(payload); index++ {
|
||
|
|
if int(payload[index]) == first && int(payload[index+1]) == second {
|
||
|
|
return index + 2
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
expected := first
|
||
|
|
if expected < 0 {
|
||
|
|
expected = second
|
||
|
|
}
|
||
|
|
for index, value := range payload {
|
||
|
|
if int(value) == expected {
|
||
|
|
return index + 1
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
|
||
|
|
func captureFlushTimeout(config CaptureConfig, trigger CaptureTriggerConfig) time.Duration {
|
||
|
|
milliseconds := 0
|
||
|
|
consider := func(value int) {
|
||
|
|
if value > 0 && (milliseconds == 0 || value < milliseconds) {
|
||
|
|
milliseconds = value
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if config.DataPackage.Mode == "interval" || config.DataPackage.Mode == "timeout" || config.DataPackage.Mode == "delimiter" {
|
||
|
|
consider(config.DataPackage.TimeoutMillis)
|
||
|
|
}
|
||
|
|
consider(trigger.TimeoutMillis)
|
||
|
|
return time.Duration(milliseconds) * time.Millisecond
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *StreamLeaseManager) captureConfig(channel, direction string) (CaptureConfig, bool) {
|
||
|
|
m.mu.Lock()
|
||
|
|
config := m.capture[channel]
|
||
|
|
m.mu.Unlock()
|
||
|
|
enabled := config.Mode == CaptureModeRXTX || config.Mode == direction
|
||
|
|
if direction == CaptureModeRX {
|
||
|
|
enabled = enabled && config.DataLog.Read
|
||
|
|
} else if direction == CaptureModeTX {
|
||
|
|
enabled = enabled && config.DataLog.Send
|
||
|
|
}
|
||
|
|
return config, enabled
|
||
|
|
}
|
||
|
|
|
||
|
|
func defaultCaptureModes() map[string]CaptureConfig {
|
||
|
|
modes := make(map[string]CaptureConfig, ChannelCount)
|
||
|
|
for index := 1; index <= ChannelCount; index++ {
|
||
|
|
channel := fmt.Sprintf("CH%02d", index)
|
||
|
|
modes[channel] = CaptureConfig{
|
||
|
|
Channel: channel, Mode: CaptureModeRXTX,
|
||
|
|
DataPackage: CapturePackageConfig{Mode: "interval", Length: MaxCapturedPayloadBytes, DelimiterProcess: "keep", Delimiter1: -1, Delimiter2: -1},
|
||
|
|
DataLog: CaptureLogConfig{
|
||
|
|
Read: true, Send: true, ShowTimestamp: true, ShowDirection: true, Format: "ascii",
|
||
|
|
ReadTrigger: CaptureTriggerConfig{Process: "keep", Character1: -1, Character2: -1},
|
||
|
|
SendTrigger: CaptureTriggerConfig{Process: "keep", Character1: -1, Character2: -1},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return modes
|
||
|
|
}
|
||
|
|
|
||
|
|
func processCaptureDelimiter(payload []byte, process string, first, second int) []byte {
|
||
|
|
if strings.ToLower(strings.TrimSpace(process)) != "remove" {
|
||
|
|
return payload
|
||
|
|
}
|
||
|
|
result := payload[:0]
|
||
|
|
for _, value := range payload {
|
||
|
|
if (first >= 0 && int(value) == first) || (second >= 0 && int(value) == second) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
result = append(result, value)
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
type StreamLease struct {
|
||
|
|
manager *StreamLeaseManager
|
||
|
|
target *StreamTarget
|
||
|
|
device io.ReadWriteCloser
|
||
|
|
channel string
|
||
|
|
clientID string
|
||
|
|
leaseID string
|
||
|
|
writeMu sync.Mutex
|
||
|
|
|
||
|
|
closeOnce sync.Once
|
||
|
|
closeErr error
|
||
|
|
}
|
||
|
|
|
||
|
|
func (l *StreamLease) LeaseID() string { return l.leaseID }
|
||
|
|
|
||
|
|
func (l *StreamLease) Read(buffer []byte) (int, error) {
|
||
|
|
n, err := l.device.Read(buffer)
|
||
|
|
if n > 0 && l.clientID != CRProbeClientID {
|
||
|
|
now := time.Now()
|
||
|
|
l.manager.rx.Add(uint64(n))
|
||
|
|
l.manager.counts[l.channel].rx.Add(uint64(n))
|
||
|
|
l.manager.counts[l.channel].lastRXAt.Store(now.UnixNano())
|
||
|
|
l.manager.counts[l.channel].lastExternalActivity.Store(now.UnixNano())
|
||
|
|
l.manager.emitPayload(l.channel, l.leaseID, l.clientID, CaptureModeRX, buffer[:n])
|
||
|
|
}
|
||
|
|
return n, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (l *StreamLease) Write(buffer []byte) (int, error) {
|
||
|
|
l.writeMu.Lock()
|
||
|
|
defer l.writeMu.Unlock()
|
||
|
|
n, err := l.device.Write(buffer)
|
||
|
|
if n > 0 && l.clientID != CRProbeClientID {
|
||
|
|
l.manager.tx.Add(uint64(n))
|
||
|
|
l.manager.counts[l.channel].tx.Add(uint64(n))
|
||
|
|
l.manager.counts[l.channel].lastExternalActivity.Store(time.Now().UnixNano())
|
||
|
|
l.manager.emitPayload(l.channel, l.leaseID, l.clientID, CaptureModeTX, buffer[:n])
|
||
|
|
}
|
||
|
|
return n, err
|
||
|
|
}
|
||
|
|
|
||
|
|
// SendBreak emits a real UART break condition through tera-seriald. It is
|
||
|
|
// serialized with ordinary TX so terminal data cannot overlap the ioctl.
|
||
|
|
func (l *StreamLease) SendBreak(ctx context.Context) error {
|
||
|
|
if err := ctx.Err(); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
l.writeMu.Lock()
|
||
|
|
defer l.writeMu.Unlock()
|
||
|
|
if sender, ok := l.device.(interface{ SendBreak() error }); ok {
|
||
|
|
return sender.SendBreak()
|
||
|
|
}
|
||
|
|
return platformSendSerialBreak(l.device)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *StreamLeaseManager) SendBreak(ctx context.Context, channel, leaseID, clientID string) error {
|
||
|
|
if err := ctx.Err(); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
m.mu.Lock()
|
||
|
|
lease := m.active[channel]
|
||
|
|
m.mu.Unlock()
|
||
|
|
if lease == nil || lease.leaseID != leaseID || lease.clientID != clientID {
|
||
|
|
return errors.New("serial stream break lease is not active")
|
||
|
|
}
|
||
|
|
return lease.SendBreak(ctx)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (l *StreamLease) Close() error {
|
||
|
|
l.closeOnce.Do(func() {
|
||
|
|
l.closeErr = l.device.Close()
|
||
|
|
l.target.Release()
|
||
|
|
l.manager.release(l)
|
||
|
|
if l.clientID != CRProbeClientID {
|
||
|
|
l.manager.emit(StreamEvent{
|
||
|
|
Channel: l.channel, LeaseID: l.leaseID, ClientID: l.clientID, Direction: "close",
|
||
|
|
})
|
||
|
|
}
|
||
|
|
})
|
||
|
|
return l.closeErr
|
||
|
|
}
|
||
|
|
|
||
|
|
func newStreamLeaseID() (string, error) {
|
||
|
|
buffer := make([]byte, 16)
|
||
|
|
if _, err := io.ReadFull(rand.Reader, buffer); err != nil {
|
||
|
|
return "", fmt.Errorf("generate serial stream lease id: %w", err)
|
||
|
|
}
|
||
|
|
return base64.RawURLEncoding.EncodeToString(buffer), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ReserveStream is the only production path from a canonical CH label to a
|
||
|
|
// UART stream device. It also blocks line-setting mutation for the lease.
|
||
|
|
func (c *MappingController) ReserveStream(ctx context.Context, channel string) (*StreamTarget, error) {
|
||
|
|
if err := ctx.Err(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
canonical, err := normalizeChannel(channel)
|
||
|
|
if err != nil || canonical != channel {
|
||
|
|
return nil, errors.New("stream channel must be canonical CH01 through CH48")
|
||
|
|
}
|
||
|
|
c.mu.Lock()
|
||
|
|
defer c.mu.Unlock()
|
||
|
|
if _, exists := c.activeStreams[channel]; exists {
|
||
|
|
return nil, ErrStreamLeaseBusy
|
||
|
|
}
|
||
|
|
entry, ok := c.byChannel[channel]
|
||
|
|
if !ok {
|
||
|
|
return nil, &UnavailableError{Reason: "channel is not in the validated serial mapping"}
|
||
|
|
}
|
||
|
|
state := c.inspectState(entry)
|
||
|
|
if !state.Available {
|
||
|
|
return nil, &UnavailableError{Reason: channel + " " + state.Message}
|
||
|
|
}
|
||
|
|
var target *StreamTarget
|
||
|
|
target = newStreamTarget(channel, entry.Device, entry.PCIBDF, func() {
|
||
|
|
c.mu.Lock()
|
||
|
|
defer c.mu.Unlock()
|
||
|
|
if current, exists := c.activeStreams[channel]; exists && current == target {
|
||
|
|
delete(c.activeStreams, channel)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
c.activeStreams[channel] = target
|
||
|
|
return target, nil
|
||
|
|
}
|