kiss

package
v0.13.13 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 30, 2026 License: GPL-2.0 Imports: 19 Imported by: 0

Documentation

Overview

Package kiss implements the KISS protocol (TNC framing + TCP/serial transports) as specified in Mike Chepponis / Phil Karn's KISS document.

A KISS frame on the wire is:

FEND <type> <data...> FEND

where the type byte's low nibble is the command (0=data, 1=txdelay, ...) and the high nibble is the port number (0..15). Escaping rules:

FEND  (0xC0) -> FESC TFEND (0xDB 0xDC)
FESC  (0xDB) -> FESC TFESC (0xDB 0xDD)

Package kiss — serial transport supervisor.

Serial is NOT a Client clone. Server.ServeTransport already implements the entire KISS data path over an io.ReadWriteCloser (RX decode, TX writer, channel routing, ingress limiting). This file only adds the open → run → backoff → retry supervision, modeled on Client.run / Client.setState / Client.sleepWithWake.

Shaped to absorb bluetooth-RFCOMM later: open an rfcomm device as an io.ReadWriteCloser and hand it to the same ServeTransport.

Index

Constants

View Source
const (
	FEND  byte = 0xC0
	FESC  byte = 0xDB
	TFEND byte = 0xDC
	TFESC byte = 0xDD
)

KISS wire constants.

View Source
const (
	CmdDataFrame   byte = 0x00
	CmdTxDelay     byte = 0x01
	CmdPersistence byte = 0x02
	CmdSlotTime    byte = 0x03
	CmdTxTail      byte = 0x04
	CmdFullDuplex  byte = 0x05
	CmdSetHardware byte = 0x06
	CmdReturn      byte = 0xFF
)

KISS command codes (low nibble of type byte).

View Source
const (
	StateListening    = "listening"
	StateStopped      = "stopped"
	StateConnected    = "connected"
	StateConnecting   = "connecting"
	StateBackoff      = "backoff"
	StateDisconnected = "disconnected"
)

Interface lifecycle states reported by Manager.Status().

Phase 1 only populates StateListening and StateStopped for the existing server-listen interface type. The Connected / Connecting / Backoff / Disconnected states are pre-declared here so Phase 4 (KISS tcp-client) can slot supervisor state in without an InterfaceStatus schema change.

Variables

View Source
var (
	ErrBackendBusy = errors.New("kiss: tx queue full")
	ErrBackendDown = errors.New("kiss: tx writer stopped")
)

ErrBackendBusy mirrors txbackend.ErrBackendBusy but lives in the kiss package so the manager doesn't have to import txbackend. Exported so tests can errors.Is against it.

View Source
var ErrInvalidEscape = errors.New("kiss: invalid escape sequence")

ErrInvalidEscape is returned when a frame contains FESC followed by an invalid escape byte.

Functions

func Encode

func Encode(port uint8, data []byte) []byte

Encode returns the wire bytes for a KISS data frame (command=0x00) on the given port, with proper escaping. Zero-copy is not possible because of the escapes; the returned slice is a fresh allocation.

func EncodeCommand

func EncodeCommand(port uint8, cmd byte, data []byte) []byte

EncodeCommand is like Encode but for any command byte (txdelay etc).

Types

type ChannelStat added in v0.13.5

type ChannelStat struct {
	RxFrames uint64
	TxFrames uint64
}

ChannelStat is a cumulative per-channel frame count for a TNC-mode KISS channel. RxFrames counts AX.25 frames ingested from the TNC (per inbound frame, per interface — genuinely distinct off-air traffic). TxFrames counts frames dispatched to the channel by the TX backend, incremented once per frame regardless of how many KISS-TNC interfaces the channel fans out to, so it stays in lockstep with the aggregate TX tile rather than multiplying by fan-out width; it is a dispatched count, not an on-air confirmation. Bad-FCS is deliberately absent: a hardware TNC validates the FCS itself and never forwards a bad frame over KISS, so the dashboard's "Bad FCS" stays 0 for these channels (issue #132).

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is the outbound-dial KISS interface: a single supervised TCP connection to a remote KISS TNC, with exponential-backoff reconnect and unified Status() telemetry for the UI countdown + Retry-now affordance. One Client per KissInterface row with InterfaceType="tcp-client".

Design (D2 + D20):

  • Supervisor owns a single net.Conn at a time. On connect, installs a live tx queue used by Manager.TransmitOnChannel; on disconnect, the queue goes back to returning ErrBackendDown.
  • Supervisor reads KISS frames via a shared frameLoop helper (same decoder the server path uses) and dispatches through the same dispatchDataFrame branch (modem mode submits to governor; TNC mode forwards to RxIngress).
  • Backoff: pkg/internal/backoff (Initial/Max from config, JitterFrac=0.25). Reset on successful connection. Reconnect() cancels the current backoff wait and dials immediately.

func (*Client) Reconnect

func (c *Client) Reconnect()

Reconnect cancels the current backoff wait (if any) so the supervisor dials immediately. Safe to call concurrently; the wakeBackoff channel is buffered size 1 and the send is non-blocking, so repeated calls coalesce.

func (*Client) SetTxDropObserver

func (c *Client) SetTxDropObserver(fn func(reason string))

SetTxDropObserver wires a per-interface metric hook that fires on writer-goroutine drop reasons ("write-failed", "no-conn"). Called by Manager.StartClient alongside the queue-depth / queue-drop hooks so the Phase 4 metric surface is unified.

func (*Client) Status

func (c *Client) Status() InterfaceStatus

Status returns a snapshot of the supervisor's current state. Safe to call concurrently.

type ClientConfig

type ClientConfig struct {
	// InterfaceID is the KissInterface DB row ID. Used to tag ingress
	// frames (TNC mode) with ingress.KissTnc(InterfaceID).
	InterfaceID uint32
	// Name identifies the interface in logs and metrics.
	Name string
	// RemoteHost / RemotePort are the dial target.
	RemoteHost string
	RemotePort uint16
	// ChannelMap mirrors the server side — the per-port → channel
	// translation applied to inbound frames. Outbound TX uses the
	// reverse mapping (first port whose value equals the target
	// channel, default 0).
	ChannelMap map[uint8]uint32
	// ReconnectInitMs / ReconnectMaxMs size the backoff schedule. Both
	// must be > 0; the manager normalizes zero to sensible defaults
	// before calling.
	ReconnectInitMs uint32
	ReconnectMaxMs  uint32
	// Dialer is the net.Dialer used for every dial. nil → default
	// Dialer with a 10s connect timeout. Tests inject a custom dialer
	// to point at net.Pipe endpoints.
	Dialer *net.Dialer
	// DialFunc, when non-nil, replaces the net.Dialer. Used by tests
	// that don't have a real listener (e.g. net.Pipe harnesses).
	DialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
	// Sink receives parsed AX.25 frames for TX (modem mode). Typically
	// *txgovernor.Governor.
	Sink txgovernor.TxSink
	// Logger is optional.
	Logger *slog.Logger
	// Mode selects RX routing — identical semantics to the server
	// path. Modem-mode Clients submit inbound frames to Sink; TNC-mode
	// Clients forward via RxIngress.
	Mode Mode
	// AllowTxFromGovernor mirrors the server-side knob: when true AND
	// Mode==ModeTnc, the manager wires a per-instance tx queue.
	AllowTxFromGovernor bool
	// TncIngressRateHz / TncIngressBurst size the TNC-mode rate
	// limiter on inbound frames. Zero disables rate limiting.
	TncIngressRateHz uint32
	TncIngressBurst  uint32
	// RxIngress is the TNC-mode inbound sink. Mirrors server.go.
	RxIngress func(rf *pb.ReceivedFrame, src ingress.Source)
	// GateTxToIs: when true and Mode == ModeModem, the dispatcher fires
	// OnClientTxAccepted after every KISS frame Sink.Submit accepted.
	// Meaningless when Mode == ModeTnc (the RX fanout there already
	// feeds the iGate). Default false.
	GateTxToIs bool
	// OnClientTxAccepted, when non-nil and GateTxToIs is true, is
	// invoked from the ModeModem branch of dispatchDataFrame AFTER
	// Sink.Submit returns nil. Wiring uses it to offer the parsed
	// APRS packet to the iGate's RF→IS gate. The hook MUST be
	// non-blocking — it runs on the client's read goroutine.
	OnClientTxAccepted func(ctx context.Context, channel uint32, f *ax25.Frame)
	// Clock is the rate-limiter's time source. nil → wall time.
	Clock Clock
	// OnFrameIngress is invoked for every successfully-decoded data
	// frame (observation hook).
	OnFrameIngress func(mode Mode)
	// OnDecodeError is invoked per ax.25 decode failure.
	OnDecodeError func()
	// OnReload is invoked on every state transition so the wiring
	// layer can nudge the txbackend dispatcher to rebuild. Optional.
	OnReload func()
}

ClientConfig configures a tcp-client Client instance.

type Clock

type Clock interface {
	Now() time.Time
}

Clock abstracts time for deterministic tests. Only Now is required; the rate limiter refills lazily based on wall-time deltas rather than goroutine-driven tickers, so no After/Timer surface is needed.

type Decoder

type Decoder struct {

	// Max payload size; frames larger than this return an error.
	MaxFrame int
	// contains filtered or unexported fields
}

A Decoder consumes a stream of KISS-framed bytes and yields Frames. It handles leading/trailing FENDs and resynchronises on a stream error by discarding until the next FEND.

func NewDecoder

func NewDecoder(r io.Reader) *Decoder

NewDecoder wraps r in a buffered KISS decoder. MaxFrame defaults to 4096.

func (*Decoder) Next

func (d *Decoder) Next() (*Frame, error)

Next reads the next complete frame. Returns io.EOF when the underlying reader is exhausted cleanly.

type Frame

type Frame struct {
	Port    uint8  // 0..15
	Command byte   // low nibble of the type byte
	Data    []byte // unescaped payload
}

A Frame is one decoded KISS frame.

type InstanceQueue

type InstanceQueue interface {
	Enqueue(frame []byte, frameID uint64) error
	Depth() int32
}

InstanceQueue is the public face of the per-instance tx queue. Purely an interface so the txbackend package can hold references without depending on the unexported queue type.

type InterfaceStatus

type InterfaceStatus struct {
	// State is the lifecycle state. One of the State* constants above.
	State string
	// LastError is the most recent non-nil error from the transport, if
	// any. Cleared on successful (re)connection. Empty in Phase 1.
	LastError string
	// RetryAtUnixMs is the wall-clock time (Unix milliseconds) at which
	// the supervisor plans to retry a failed dial. Zero when not in
	// backoff or not applicable. Phase 4 fills this in.
	RetryAtUnixMs int64
	// PeerAddr is the dialed or accepted peer address, if known. Phase 4
	// populates this for tcp-client. Empty in Phase 1.
	PeerAddr string
	// ConnectedSince is the Unix millisecond timestamp when the current
	// session became live. Zero when not connected. Phase 4.
	ConnectedSince int64
	// ReconnectCount is the cumulative number of reconnect attempts the
	// supervisor has made since process start. Phase 4.
	ReconnectCount uint64
	// BackoffSeconds is the current backoff duration in seconds, used
	// by the UI to render a countdown. Zero when not in backoff. Phase 4.
	BackoffSeconds uint32
}

InterfaceStatus is the unified status snapshot returned by Manager.Status() for every managed KISS interface — server-listen today, tcp-client once Phase 4 lands. Fields that don't apply to a given interface type carry zero values.

State, LastError, and RetryAtUnixMs describe the current lifecycle state. PeerAddr, ConnectedSince, ReconnectCount, and BackoffSeconds are Phase 4 placeholders (tcp-client supervisor telemetry) — Phase 1 reports them as zero for every interface. The fields are declared now so the /api/channels backing DTO and the Kiss page don't need schema changes when Phase 4 fills them in.

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager tracks running KISS TCP servers and supports hot start/stop.

func NewManager

func NewManager(cfg ManagerConfig) *Manager

NewManager creates a Manager. Call Start to launch individual servers.

func (*Manager) ActiveClients

func (m *Manager) ActiveClients(id uint32) int

ActiveClients returns the current count of connected KISS clients on the running server under the given DB ID. Returns 0 if the ID is not running or is a tcp-client. Primarily consumed by tests that need to block until a client is registered before exercising a broadcast path.

func (*Manager) BroadcastFromChannel

func (m *Manager) BroadcastFromChannel(channel uint32, axBytes []byte, skipID uint32, skip bool)

BroadcastFromChannel fans out a received frame to all running servers. When skip is true, the server registered under skipID is excluded — used to suppress echo back to a KISS-TNC interface that just injected the frame. skipID is ignored when skip is false.

tcp-client interfaces are NOT broadcast targets: the RX-fanout of a locally-decoded frame is an RF-originated event that only belongs on server-listen interfaces (where connected KISS apps expect to see over-the-air traffic). A tcp-client's remote peer is already the authoritative source of its channel's traffic.

func (*Manager) ChannelStats added in v0.13.5

func (m *Manager) ChannelStats(ch uint32) (ChannelStat, bool)

ChannelStats returns a snapshot of the cumulative RX/TX counts for a single channel. ok is false when no TNC-mode frame has yet been seen on that channel, letting callers (webapi) prefer modembridge's cache when the channel is modem-backed instead.

func (*Manager) Dropped

func (m *Manager) Dropped(id uint32) uint64

Dropped returns the cumulative rate-limit drop count for the running server under the given DB ID. Returns 0 if the ID is not running or is a tcp-client (clients have no server-side rate limiter). Phase 5 wires this into a Prometheus counter.

func (*Manager) InstanceQueueFor

func (m *Manager) InstanceQueueFor(id uint32) InstanceQueue

InstanceQueueFor returns the per-instance tx queue for the running interface under the given DB row ID, or nil if no interface is running or the interface was started without TX-from-governor enabled. Exposed so the txbackend layer can construct a KissTncBackend wrapper for each eligible interface; the returned queue's Enqueue method is the backend's non-blocking submit path.

func (*Manager) QueueOverflow

func (m *Manager) QueueOverflow(id uint32) uint64

QueueOverflow returns the cumulative per-interface ingress-queue overflow count for the running server under the given DB ID. Returns 0 if the ID is not running or is a tcp-client. Phase 5 wires this into a Prometheus counter.

func (*Manager) Reconnect

func (m *Manager) Reconnect(id uint32) error

Reconnect short-circuits the current backoff wait on a tcp-client or serial supervisor under id. Returns nil on success; returns a non-nil error when id is not registered or is not reconnectable.

func (*Manager) RecordChannelTx added in v0.13.5

func (m *Manager) RecordChannelTx(ch uint32)

RecordChannelTx records one dispatched TX frame on ch. Called by the TX backend dispatcher (once per frame, fan-out-safe) rather than from the per-instance tx queue, so the count matches the aggregate TX tile on channels with multiple KISS-TNC interfaces (issue #132).

func (*Manager) SetRxIngress

func (m *Manager) SetRxIngress(fn func(rf *pb.ReceivedFrame, src ingress.Source))

SetRxIngress replaces the RX-ingress callback for future Server starts. Running servers keep their previously-bound callback; since every config update tears the server down via Start's stop-if-exists branch, the next UI-driven reconfigure picks up the new callback. Safe to call before any Start.

func (*Manager) Start

func (m *Manager) Start(parent context.Context, id uint32, cfg ServerConfig)

Start launches a KISS TCP server for the given DB row. If a server with that ID is already running it is stopped first.

func (*Manager) StartClient

func (m *Manager) StartClient(parent context.Context, id uint32, cfg ClientConfig)

StartClient launches a tcp-client KISS supervisor for the given DB row. Mirrors Start (server-listen) — any previously-running interface under id is stopped first. Exactly one of Start / StartClient runs per row depending on InterfaceType.

The supervisor goroutine runs until ctx is cancelled (either at process shutdown or by a subsequent Start / StartClient / Stop call that replaces this row). Dial attempts, backoff, and reconnect telemetry are all surfaced via Manager.Status(). Reconnect(id) short-circuits the current backoff wait.

func (*Manager) StartSerial added in v0.13.5

func (m *Manager) StartSerial(parent context.Context, id uint32, cfg SerialConfig)

StartSerial launches a serial KISS supervisor for the given DB row. Any previously-running interface under id is stopped first. The owned *Server is finalized exactly as Start does (Sink/RxIngress/ InterfaceID/...) then handed to NewSerial. The per-instance tx queue is built exactly as Start does (Mode==tnc && AllowTxFromGovernor) so governor TX reaches the radio.

func (*Manager) Status

func (m *Manager) Status() map[uint32]InterfaceStatus

Status returns a snapshot of the current lifecycle state for every managed KISS interface keyed by DB row ID. Server-listen interfaces report StateListening with zero-valued supervisor telemetry; tcp-client interfaces report their supervisor's full state (connected / connecting / backoff / disconnected / stopped, plus LastError, RetryAtUnixMs, PeerAddr, ConnectedSince, ReconnectCount, BackoffSeconds).

func (*Manager) Stop

func (m *Manager) Stop(id uint32)

Stop shuts down the server for the given DB row ID.

func (*Manager) StopAll

func (m *Manager) StopAll()

StopAll shuts down every running server and waits for their goroutines to exit. Called by the wiring layer's shutdown orchestrator (D15) so kiss-owned resources are released in the correct sequence relative to the dispatcher + modembridge. Safe to call multiple times.

func (*Manager) TransmitOnChannel

func (m *Manager) TransmitOnChannel(ctx context.Context, ch uint32, frame []byte, frameID uint64) (int, error)

TransmitOnChannel enqueues frame on every TNC-mode server interface attached to channel ch with AllowTxFromGovernor=true. Returns (acceptedCount, errors.Join(perInstanceFailures...)): accepted is the number of queues that accepted the frame; err is nil when every enqueue succeeded, a joined error when one or more returned ErrBackendBusy / ErrBackendDown / transport failures.

Non-blocking — a full queue or stopped writer returns immediately with the corresponding sentinel. Governor callers treat accepted>0 as success for the fan-out semantics (see txbackend.Dispatcher).

type ManagerConfig

type ManagerConfig struct {
	Sink   txgovernor.TxSink
	Logger *slog.Logger
	// OnDecodeError, if non-nil, is installed on every Server the
	// Manager starts. A shared counter across all KISS interfaces is
	// intentional: the metric is about "kiss frames that failed
	// ax25 decoding" at the system level, not per-interface.
	OnDecodeError func()
	// OnFrameIngress, if non-nil, is invoked for every KISS data frame
	// that successfully AX.25-decodes at any managed server, with the
	// server's interface ID and its configured Mode. Observation hook
	// used by Phase 5 of the KISS modem/TNC plan to drive the
	// graywolf_kiss_ingress_frames_total counter.
	OnFrameIngress func(ifaceID uint32, mode Mode)
	// OnBroadcastSuppressed, if non-nil, is invoked once per recipient
	// skipped by BroadcastFromChannel's self-loop guard (the
	// originating TNC-mode interface). Phase 5 uses this to drive the
	// graywolf_kiss_broadcast_suppressed_total counter.
	OnBroadcastSuppressed func(recipientID uint32)
	// RxIngress, if non-nil, is installed on every Server started in
	// ModeTnc. The wiring layer wraps this with a non-blocking send
	// into the shared modem-RX fanout channel. Callers may set it
	// later via SetRxIngress; configs started before it is set run
	// without TNC routing (frames are dropped with a warning).
	RxIngress func(rf *pb.ReceivedFrame, src ingress.Source)
	// OnClientTxAccepted, if non-nil, is installed on every Server /
	// Client / Serial launched by this Manager. The Manager wraps it
	// with the per-interface DB row ID so the wiring layer can route
	// the gated submission through one shared App method without each
	// transport having to know its own ID. The wrapped hook only
	// fires inside the Server/Client dispatch path when the
	// per-interface GateTxToIs flag is set.
	OnClientTxAccepted func(ctx context.Context, ifaceID, channel uint32, f *ax25.Frame)
	// Clock is the rate-limiter time source installed on every Server.
	// nil selects wall time; tests inject a fake clock.
	Clock Clock
	// OnTxQueueDepth is an optional per-instance gauge reporter.
	// Invoked on every enqueue / drain with the updated depth so the
	// wiring layer can surface graywolf_kiss_instance_tx_queue_depth.
	OnTxQueueDepth func(ifaceID uint32, depth int32)
	// OnTxQueueDrop is an optional counter incrementer invoked when a
	// per-instance tx enqueue fails ("busy" | "down").
	OnTxQueueDrop func(ifaceID uint32, reason string)
	// OnClientStateChange, if non-nil, is invoked on every state
	// transition of a tcp-client supervisor. The wiring layer uses
	// this to surface the Phase 4 kiss_client metrics (connected
	// gauge, backoff_seconds gauge) without having to wire a
	// dedicated per-start callback from every notifyKissManager
	// path. name is the interface display name; st is the live
	// status snapshot.
	OnClientStateChange func(ifaceID uint32, name string, st InterfaceStatus)
	// OnClientReconnect fires exactly once per successful dial on a
	// tcp-client supervisor. Wired to the Phase 4
	// graywolf_kiss_client_reconnects_total counter.
	OnClientReconnect func(ifaceID uint32)
	// OnSerialStateChange / OnSerialReconnect mirror the client hooks
	// for serial supervisors. Parallel hooks (D7 option A); a
	// follow-up may collapse both behind transport-neutral
	// OnSupervisor* (tracked Out-of-scope in the spec).
	OnSerialStateChange func(ifaceID uint32, name string, st InterfaceStatus)
	OnSerialReconnect   func(ifaceID uint32)
}

ManagerConfig configures a Manager.

type Mode

type Mode string

Mode selects the per-interface routing policy for inbound KISS data frames. Values are stored lowercase and matched exactly by the configstore layer; see configstore.ValidKissMode.

const (
	// ModeModem is the default: the peer is an APRS app and inbound
	// frames are submitted to the TX governor for RF transmission.
	ModeModem Mode = "modem"
	// ModeTnc marks the peer as a hardware TNC supplying off-air RX.
	// Inbound frames are fanned out to digi/igate/messages/station cache
	// and never auto-submitted to TX. Phase 3 wires this branch; in
	// Phase 2 the field is stored and surfaced but both modes dispatch
	// through the existing TX path.
	ModeTnc Mode = "tnc"
)

type OpenFunc added in v0.13.6

type OpenFunc = func(device string, baud uint32) (io.ReadWriteCloser, error)

OpenFunc is the type of SerialConfig.OpenFunc. Exported so build-tagged factories in other packages can return it without duplicating the signature.

type RateLimiter

type RateLimiter struct {
	// contains filtered or unexported fields
}

RateLimiter is a token-bucket limiter sized for per-interface KISS-TNC ingress caps. It refills lazily on each Allow call so no goroutines or tickers are required — a dormant interface costs nothing.

A rate of zero OR a burst of zero disables limiting entirely: Allow always returns true and Dropped stays at zero. This matches the configstore convention where zero means "use defaults" higher up and an explicitly-unlimited path through the server for unit tests.

func NewRateLimiter

func NewRateLimiter(rateHz, burst uint32, clock Clock) *RateLimiter

NewRateLimiter builds a RateLimiter with the given steady-state rate (tokens/sec) and burst (bucket capacity). If either is zero the limiter runs in unlimited mode. clock defaults to wall time if nil.

func (*RateLimiter) Allow

func (r *RateLimiter) Allow() bool

Allow consumes one token. Returns true on success, false on drop. A dropped call increments the counter exposed by Dropped. Unlimited limiters (zero rate or zero burst) always return true and never increment the counter.

func (*RateLimiter) Dropped

func (r *RateLimiter) Dropped() uint64

Dropped returns the total number of Allow calls that have been denied by this limiter since construction. Atomic; safe from any goroutine.

type SerialConfig added in v0.13.5

type SerialConfig struct {
	Name                string
	Device              string
	BaudRate            uint32
	Mode                Mode
	ChannelMap          map[uint8]uint32
	ReconnectInitMs     uint32
	ReconnectMaxMs      uint32
	Logger              *slog.Logger
	TncIngressRateHz    uint32
	TncIngressBurst     uint32
	AllowTxFromGovernor bool
	// GateTxToIs mirrors ServerConfig.GateTxToIs for the wrapping
	// Server constructed in StartSerial. Only meaningful when Mode ==
	// ModeModem; the field is read unconditionally and the server's
	// dispatch path enforces the mode gate.
	GateTxToIs bool
	// OnReload fires on every state transition so the wiring layer can
	// rebuild the tx backend. Mirrors ClientConfig.OnReload.
	OnReload func()
	// OpenFunc, when non-nil, replaces the go.bug.st/serial open. Tests
	// inject a fake returning an in-memory pipe. Mirrors
	// ClientConfig.DialFunc (client.go:131).
	OpenFunc func(device string, baud uint32) (io.ReadWriteCloser, error)
}

SerialConfig mirrors the ClientConfig supervisor-knob surface for a serial KISS transport. Sink / RxIngress / InterfaceID / OnDecodeError / OnFrameIngress / Clock are deliberately NOT here: they are Manager-owned and injected into the owned *Server by Manager.StartSerial exactly as Manager.Start does at manager.go:231-262 (Correction A in the source spec).

type SerialSupervisor added in v0.13.5

type SerialSupervisor struct {
	// contains filtered or unexported fields
}

SerialSupervisor owns a finalized *Server and runs an open→serve→backoff→retry loop over a serial port. Lifecycle (stop) is owned by Manager, identical to Client: Manager cancels the ctx and calls close(). There is intentionally no public Stop/Enqueue/ Submit — that surface does not exist on the existing supervisors.

func NewSerial added in v0.13.5

func NewSerial(cfg SerialConfig, srv *Server) *SerialSupervisor

NewSerial builds a supervisor around an already-finalized *Server. Manager.StartSerial MUST finalize the ServerConfig (Sink/RxIngress/ InterfaceID/...) before calling NewServer, or RX silently drops (server logs "no RxIngress wired"). The supervisor never sees an unfinalized ServerConfig.

func (*SerialSupervisor) Reconnect added in v0.13.5

func (s *SerialSupervisor) Reconnect()

Reconnect short-circuits the current backoff wait. Safe to call concurrently; coalesces (buffered size-1, non-blocking send).

func (*SerialSupervisor) Status added in v0.13.5

func (s *SerialSupervisor) Status() InterfaceStatus

Status returns a snapshot. Same shape Client.Status returns so the Manager union and the UI render identically across transports.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is a multi-client KISS TCP server. A single Server instance corresponds to one row in the kiss_interfaces table.

func NewServer

func NewServer(cfg ServerConfig) *Server

NewServer builds a Server. It does not start listening until ListenAndServe.

func (*Server) ActiveClients

func (s *Server) ActiveClients() int

ActiveClients returns the current number of connected KISS clients.

func (*Server) Broadcast

func (s *Server) Broadcast(port uint8, axBytes []byte)

Broadcast sends a received AX.25 frame to every connected KISS client (KISSCOPY equivalent). Errors on individual clients are logged but do not stop the broadcast. Does not consult the Broadcast flag; callers that want per-interface honoring should use BroadcastFromChannel instead.

func (*Server) BroadcastFromChannel

func (s *Server) BroadcastFromChannel(channel uint32, axBytes []byte)

BroadcastFromChannel honors the interface's Broadcast flag and the ChannelMap: the received frame is only forwarded if Broadcast is true and at least one mapped port exists for channel. The KISS port byte in the outgoing frame is the first port whose ChannelMap entry equals channel (falling back to 0 if the map is empty).

func (*Server) Dropped

func (s *Server) Dropped() uint64

Dropped returns the number of ModeTnc inbound frames rejected by this server's rate limiter since construction. Zero when the limiter is in unlimited mode (rate or burst == 0) or in ModeModem.

func (*Server) ListenAndServe

func (s *Server) ListenAndServe(ctx context.Context) error

ListenAndServe binds the configured TCP address and serves clients until the context is cancelled. It blocks. When it returns, the listener is closed and the bound port is free — callers may immediately rebind.

func (*Server) LocalAddr

func (s *Server) LocalAddr() net.Addr

LocalAddr returns the actual bound listener address. Returns nil until ListenAndServe has successfully bound. Useful for tests that pass ":0" and want the OS-assigned port.

func (*Server) QueueOverflow

func (s *Server) QueueOverflow() uint64

QueueOverflow returns the number of ModeTnc frames that passed the rate limiter but were dropped because the per-interface ingress queue was full when the frame arrived. Non-zero indicates the downstream fanout consumer is slower than sustained ingest on this interface.

func (*Server) ServeTransport

func (s *Server) ServeTransport(ctx context.Context, rwc io.ReadWriteCloser) error

ServeTransport runs a single-client KISS session over any io.ReadWriteCloser — e.g. a serial port opened via go.bug.st/serial, or a bluetooth rfcomm device opened via os.OpenFile. Used for kiss_interfaces.interface_type = "serial" | "bluetooth".

The transport is closed on return or context cancellation.

func (*Server) TxBroadcast

func (s *Server) TxBroadcast(channel uint32, axBytes []byte, deadline time.Duration) int

TxBroadcast is the TX-from-governor write path. Unlike the RX-fanout Broadcast methods above, this does NOT consult cfg.Broadcast (that flag controls RX echo back to KISS clients, not governor-originated TX). It writes the supplied AX.25 frame wrapped in KISS data framing to every connected client. The port byte is derived from the ChannelMap entry matching channel, falling back to port 0.

Per-connection writes use a socket deadline of deadline when the underlying writer is a net.Conn, purely as a hung-peer guard so one stuck client cannot stall the per-instance queue's writer goroutine. Slow-but-working links are not punished: the deadline is generous (10s by default — see instanceTxSocketDeadline).

Returns the number of successful writes. Zero means no client accepted the frame; the per-instance queue's writer goroutine uses this in the Phase 4 tcp-client supervisor to decide when to transition into "down" state. Server-listen mode treats zero-writes as non-fatal (clients may reconnect).

type ServerConfig

type ServerConfig struct {
	// InterfaceID is the KissInterface DB row ID. Used to tag inbound
	// frames with ingress.KissTnc(InterfaceID) when Mode == ModeTnc and
	// to suppress self-echo on the broadcast fanout. Zero is acceptable
	// for unit tests that don't exercise the RX fanout; production
	// startup and hot reload both populate it.
	InterfaceID uint32
	// Name identifies the interface in logs and metrics.
	Name string
	// ListenAddr is a "host:port" TCP address. For serial/bluetooth use a
	// different constructor (ServeTransport).
	ListenAddr string
	// ChannelMap translates KISS port numbers (0..15) to graywolf radio
	// channels. A missing entry defaults to channel 1.
	ChannelMap map[uint8]uint32
	// Sink receives parsed AX.25 frames for transmission. Typically
	// *txgovernor.Governor in production.
	Sink txgovernor.TxSink
	// Logger is optional.
	Logger *slog.Logger
	// OnClientChange is invoked with the new active-client count whenever
	// a client connects or disconnects. Optional.
	OnClientChange func(active int)
	// OnDecodeError is invoked for every KISS data frame whose payload
	// failed AX.25 decoding. Optional; nil is a no-op. A single counter
	// with no labels is used on purpose: per-client address would
	// explode cardinality on a server with churning clients.
	OnDecodeError func()
	// OnFrameIngress is invoked for every KISS data frame that
	// successfully AX.25-decodes, in both Modem and TNC modes, before
	// dispatch. Observation hook; nil is a no-op. The Mode argument
	// mirrors the server's configured routing so the subscriber can
	// label a single counter covering both modes.
	OnFrameIngress func(mode Mode)
	// Broadcast, when false, disables BroadcastFromChannel fan-out (the
	// interface is TX-only from the KISS client's perspective). Default
	// true — kiss_interfaces.broadcast in the configstore drives this.
	Broadcast bool
	// Mode selects the inbound-frame routing policy. Empty is treated as
	// ModeModem for backwards compatibility with callers that predate
	// the field.
	Mode Mode
	// TncIngressRateHz and TncIngressBurst configure the per-interface
	// token-bucket ingress cap applied in ModeTnc. Zero (either field)
	// disables rate limiting — every frame is allowed.
	TncIngressRateHz uint32
	TncIngressBurst  uint32
	// RxIngress, when non-nil and Mode == ModeTnc, receives every inbound
	// KISS data frame that survives the rate limiter and the per-interface
	// queue. Phase 3 wires this to the shared modem-RX fanout in
	// pkg/app/wiring.go. nil in ModeModem — that path submits to Sink
	// instead, preserving byte-for-byte existing behavior.
	RxIngress func(rf *pb.ReceivedFrame, src ingress.Source)
	// Clock is the rate-limiter's time source. nil selects wall time.
	// Tests inject a fake clock to exercise burst/refill determinism.
	Clock Clock
	// AllowTxFromGovernor mirrors KissInterface.AllowTxFromGovernor. When
	// true AND Mode == ModeTnc, the manager wires a per-instance tx
	// queue used by Manager.TransmitOnChannel to fan governor-scheduled
	// frames out to this interface. Informational on the server itself
	// (the server does not consult it directly); the manager reads it
	// to decide whether to construct the queue.
	AllowTxFromGovernor bool
	// GateTxToIs: when true and Mode == ModeModem, the dispatcher fires
	// OnClientTxAccepted after every KISS frame Sink.Submit accepted.
	// Meaningless when Mode == ModeTnc (the RX fanout there already
	// feeds the iGate). Default false.
	GateTxToIs bool
	// OnClientTxAccepted, when non-nil and GateTxToIs is true, is
	// invoked from the ModeModem branch of dispatchDataFrame AFTER
	// Sink.Submit returns nil. Wiring uses it to offer the parsed
	// APRS packet to the iGate's RF→IS gate. The hook MUST be
	// non-blocking — it runs on the per-connection read goroutine.
	OnClientTxAccepted func(ctx context.Context, channel uint32, f *ax25.Frame)
}

ServerConfig configures a KISS TCP server instance.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL