stack

package
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Sep 2, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

Documentation

Overview

Package stack wires the protocol layers into a working pipeline.

Every other package in this repository implements one standard well and leaves composition to the caller. That is the right shape for a protocol library, but it means a working downlink is about forty lines of setup: a channel configuration, a physical channel, a master channel, a virtual channel and a packet service for each stream, a shared frame counter, a packet sizer on every service, and then the CADU wrapping. The ground side is the same forty lines again.

The second forty lines are the problem. The two ends have to agree on the frame length, on whether there is a frame error control field, on whether the frames are randomized, and on which virtual channels exist. Nothing checks that they do. A ground station configured with a frame length two octets different from the spacecraft's decodes nothing, and the failure looks like a bad link rather than a typo.

So this package takes one configuration value and builds both ends from it:

cfg := stack.Downlink{
    SpacecraftID: 42,
    FrameLength:  1115,
    FECF:         true,
    Channels: []stack.VC{
        {ID: 0, Priority: 3}, // housekeeping
        {ID: 1, Priority: 1}, // science
    },
}

sender, err := stack.NewSender(cfg)
...
sender.Send(0, packet)
sender.Flush()
for cadu, err := range sender.CADUs() { ... }

receiver, err := stack.NewReceiver(cfg) // the same cfg
...
receiver.Accept(cadu)
packet, ok, err := receiver.Next(0)

The layers are still there and still separately usable. This is a convenience over them, not a replacement: anything the composer cannot express is a reason to drop to the packages underneath, not a reason to grow this one until it can express everything.

What it composes

Downlink is packets to CADUs and back: Space Packets (CCSDS 133.0-B-2) through TM Transfer Frames (132.0-B-3) into Channel Access Data Units (131.0-B-5), with the frame error control field and pseudo-randomization as configured.

Uplink is packets to CLTUs and back: Space Packets through TC Transfer Frames (232.0-B-4) into Command Link Transmission Units (231.0-B-4), with COP-1 (232.1-B-2) providing sequence-controlled delivery.

The uplink is deliberately not the downlink backwards. Commanding is a conversation: FOP-1 on the ground will not send past its sliding window until a CLCW comes back on the telemetry link saying what the spacecraft accepted. So a Commander sends packets and accepts CLCWs, while an Onboard accepts frames and produces them, asymmetric in a way the two downlink ends are not. See uplink.go for why that shapes the API.

Joining the two

That CLCW travels in the operational control field of a telemetry frame, which makes the two directions one system rather than two. A downlink setting Downlink.OCF takes a WithOCF supplier for the field, and the ground reads what arrives with Receiver.NextOCF:

sender, err := stack.NewSender(downlink, stack.WithOCF(func() []byte {
    field, err := onboard.CLCW(0)
    if err != nil {
        return nil
    }
    return field
}))

for field := range receiver.OCFs() {
    if err := commander.AcceptCLCW(field); err != nil {
        return err
    }
}

The supplier is required rather than defaulted, because four zero octets decode as a valid CLCW reporting V(R)=0 and a ground station believing that would never advance its window. See Downlink.OCF.

Reed-Solomon is deliberately absent. CCSDS 131.0 puts the codeblock between the frame and the sync marker, and a caller who wants it can run pkg/tmsc over the encoded frame before handing the octets on, but the interleaving depth and the shortened-codeblock choices are real decisions that a composer guessing at them would get wrong.

Example

The composer is meant to replace about forty lines of setup with a handful. This is that claim as runnable code.

package main

import (
	"fmt"

	"github.com/ravisuhag/astro/pkg/spp"
	"github.com/ravisuhag/astro/pkg/stack"
)

func main() {
	config := stack.Downlink{
		SpacecraftID: 42,
		FrameLength:  64,
		FECF:         true,
		Channels:     []stack.VC{{ID: 0}},
	}

	sender, err := stack.NewSender(config)
	if err != nil {
		panic(err)
	}

	telemetry, err := spp.NewTMPacket(100, []byte("battery ok"))
	if err != nil {
		panic(err)
	}
	if err := sender.SendPacket(0, telemetry); err != nil {
		panic(err)
	}
	if err := sender.Flush(); err != nil {
		panic(err)
	}

	// The ground station is built from the same configuration, so the two
	// ends cannot disagree about the frame layout.
	receiver, err := stack.NewReceiver(config)
	if err != nil {
		panic(err)
	}
	for cadu, err := range sender.CADUs() {
		if err != nil {
			panic(err)
		}
		if err := receiver.Accept(cadu); err != nil {
			panic(err)
		}
	}

	received, ok, err := receiver.NextPacket(0)
	if err != nil || !ok {
		panic(fmt.Sprintf("no packet: %v", err))
	}
	fmt.Printf("APID %d: %s\n", received.PrimaryHeader.APID, received.UserData)

}
Output:
APID 100: battery ok

Index

Examples

Constants

View Source
const DefaultBuffer = 32

DefaultBuffer is the per-channel frame buffer when a VC does not set one.

View Source
const DefaultWindow = 10

DefaultWindow is the COP-1 sliding window width when a channel does not set one.

CCSDS 232.1-B-2 makes the width a managed parameter with no default, so this is a working value rather than a standard one. It is well under the 127 the eight-bit sequence number allows, which is what keeps a lost CLCW from looking like a wrap.

View Source
const MaxUplinkVCID = 63

MaxUplinkVCID is the largest virtual channel identifier a TC frame can carry. The field is six bits (CCSDS 232.0-B-4 clause 4.1.2.4).

View Source
const MaxVCID = 7

MaxVCID is the largest virtual channel identifier a TM frame can carry. The field is three bits (CCSDS 132.0-B-3 clause 4.1.2.3).

Variables

View Source
var (
	// ErrInvalidConfig indicates a configuration that cannot be built: a
	// frame length that is not positive, no virtual channels, a duplicate or
	// out-of-range channel identifier, or a value the underlying channel
	// profile rejects.
	ErrInvalidConfig = errors.New("downlink configuration is not valid")

	// ErrUnknownChannel indicates a virtual channel the configuration does
	// not name. It is reported rather than ignored: sending to a channel that
	// does not exist would otherwise lose the packet silently.
	ErrUnknownChannel = errors.New("virtual channel is not configured")

	// ErrMissingOCF indicates a downlink carrying an operational control
	// field with nothing to put in it. Four zero octets decode as a valid
	// CLCW reporting V(R)=0, so a sender that invented them would have the
	// ground believe a spacecraft was acknowledging nothing, and FOP-1 would
	// never advance its window. Pass WithOCF instead.
	ErrMissingOCF = errors.New("downlink carries an operational control field but no supplier")
)

Functions

This section is empty.

Types

type Commander

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

Commander is the ground side of an uplink: packets in, CLTUs out, CLCWs back in.

It is not safe for concurrent use. COP-1 is a state machine per virtual channel, and the order commands are offered in is the order they will be delivered, so serialising access is the caller's job.

Example

The uplink half of the "few lines" claim, as runnable code.

package main

import (
	"fmt"

	"github.com/ravisuhag/astro/pkg/spp"
	"github.com/ravisuhag/astro/pkg/stack"
)

func main() {
	config := stack.Uplink{
		SpacecraftID: 42,
		Channels:     []stack.UplinkVC{{ID: 0}},
	}

	commander, err := stack.NewCommander(config)
	if err != nil {
		panic(err)
	}
	onboard, err := stack.NewOnboard(config)
	if err != nil {
		panic(err)
	}

	telecommand, err := spp.NewTCPacket(100, []byte("SET MODE 3"))
	if err != nil {
		panic(err)
	}
	if err := commander.SendPacket(0, telecommand); err != nil {
		panic(err)
	}

	for cltu, err := range commander.CLTUs() {
		if err != nil {
			panic(err)
		}
		if _, err := onboard.Accept(cltu); err != nil {
			panic(err)
		}
	}

	received, ok, err := onboard.Next(0)
	if err != nil || !ok {
		panic(fmt.Sprintf("no command: %v", err))
	}

	packet, err := spp.Decode(received)
	if err != nil {
		panic(err)
	}
	fmt.Printf("APID %d: %s\n", packet.PrimaryHeader.APID, packet.UserData)

}
Output:
APID 100: SET MODE 3

func NewCommander

func NewCommander(config Uplink) (*Commander, error)

NewCommander builds the ground side of an uplink.

Every channel's FOP-1 starts initialised at sequence number zero, which is the state after a successful unlock. A mission resuming a pass mid-sequence should set it with SetV(S).

func (*Commander) AcceptCLCW

func (c *Commander) AcceptCLCW(encoded []byte) error

AcceptCLCW feeds back one Communications Link Control Word from the telemetry link.

This is what closes the loop. Without it a sequence-controlled channel stops at its sliding window and stays there.

func (*Commander) CLTUs

func (c *Commander) CLTUs() func(yield func([]byte, error) bool)

CLTUs iterates the CLTUs ready to transmit.

func (*Commander) NextCLTU

func (c *Commander) NextCLTU() ([]byte, bool, error)

NextCLTU returns the next CLTU to transmit, or false when nothing is ready.

Nothing ready does not mean nothing queued: FOP-1 holds frames back when the sliding window is full or the channel is waiting, and only a CLCW will release them. That is sequence control working, not a fault.

func (*Commander) Pending

func (c *Commander) Pending(vcid uint8) (int, error)

Pending reports how many frames a channel is holding: those FOP-1 has sent and is waiting to have acknowledged, plus those still queued behind the sliding window.

func (*Commander) Send

func (c *Commander) Send(vcid uint8, packet []byte) error

Send offers one encoded Space Packet for sequence-controlled delivery.

The packet is segmented into TC frames and each frame handed to FOP-1, which will not release it past the sliding window until a CLCW says the spacecraft has room. So Send succeeding means the command is queued, not that it is on its way.

func (*Commander) SendExpedited

func (c *Commander) SendExpedited(vcid uint8, packet []byte) error

SendExpedited offers a packet for expedited delivery, bypassing the sequence check.

Type BD frames are not counted, not retransmitted and not acknowledged. They are what you use when the sequence machinery is the thing that is broken, and they arrive whatever state FOP-1 is in.

func (*Commander) SendPacket

func (c *Commander) SendPacket(vcid uint8, packet *spp.SpacePacket) error

SendPacket encodes a Space Packet and sends it for sequence-controlled delivery.

func (*Commander) State

func (c *Commander) State(vcid uint8) (cop.FOPState, error)

State reports what COP-1 is doing on a channel, which is the thing to look at when commands stop going out.

type Downlink struct {
	// SpacecraftID is the 10-bit identifier both ends expect
	// (CCSDS 132.0-B-3 clause 4.1.2.2).
	SpacecraftID uint16

	// FrameLength is the total octets in a frame, including the header and
	// any error control field. It is fixed for the whole physical channel
	// (clause 2.1.3), which is why it lives here and not on a frame.
	FrameLength int

	// FECF appends the two-octet frame error control field (clause 4.1.6).
	FECF bool

	// OCF carries the four-octet operational control field, which is where
	// the CLCW travels on a mission that uses COP-1 (clause 4.1.5).
	//
	// Setting it obliges the sender to say what goes in the field, so
	// NewSender wants a WithOCF supplier alongside it. The field content is
	// the OCF service user's (clause 4.1.5) and this package has none of its
	// own; emitting the zeros it would otherwise have to invent is worse than
	// refusing, because a receiver reads four zero octets as a valid CLCW
	// reporting V(R)=0 rather than as an empty field.
	OCF bool

	// Randomize applies the CCSDS pseudo-randomizer to the frame before the
	// sync marker (131.0-B-5 clause 7). Both ends must agree, and they do here.
	Randomize bool

	// ASM overrides the attached sync marker. Nil takes the standard
	// 0x1ACFFC1D of 131.0-B-5 clause 9.
	ASM []byte

	// Channels are the virtual channels, in any order. At least one is
	// required, and their IDs must be distinct.
	Channels []VC
}

Downlink is the configuration of one telemetry downlink.

The same value configures both ends. That is the point: a sender and a receiver built from one Downlink cannot disagree about the frame length or the error control field, which is the failure this package exists to prevent.

func (Downlink) Validate

func (d Downlink) Validate() error

Validate reports whether the configuration can be built.

It is called by NewSender and NewReceiver, so a caller does not have to, but it is exported because catching a bad configuration at startup beats catching it when the first frame fails to decode.

type Onboard

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

Onboard is the spacecraft side of an uplink: CLTUs in, packets out, CLCWs to send back.

Not safe for concurrent use, for the same reason as Commander.

func NewOnboard

func NewOnboard(config Uplink) (*Onboard, error)

NewOnboard builds the spacecraft side of an uplink.

Give it the same Uplink value the commander was built from.

func (*Onboard) Accept

func (o *Onboard) Accept(cltu []byte) (accepted bool, err error)

Accept takes one received CLTU: it unwraps the codeblocks, decodes the frame, runs it past FARM-1, and hands the accepted data to the channel.

A frame FARM-1 rejects is not an error. A retransmission of something already accepted, or a frame outside the window, is exactly what the procedure exists to filter, and reporting it as a failure would make ordinary operation look broken. accepted says which happened.

func (*Onboard) CLCW

func (o *Onboard) CLCW(vcid uint8) ([]byte, error)

CLCW returns the control word to send back on the telemetry link, encoded and ready for a frame's Operational Control Field.

This is the other half of the loop: without it reaching the commander, a sequence-controlled channel stops at its sliding window.

func (*Onboard) Next

func (o *Onboard) Next(vcid uint8) ([]byte, bool, error)

Next returns the next whole Space Packet from a virtual channel.

func (*Onboard) Packets

func (o *Onboard) Packets(vcid uint8) func(yield func([]byte, error) bool)

Packets iterates the whole Space Packets waiting on a virtual channel.

func (*Onboard) State

func (o *Onboard) State(vcid uint8) (cop.FARMState, error)

State reports what FARM-1 is doing on a channel.

type Receiver

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

Receiver turns CADUs back into Space Packets, the ground side of a downlink.

Like Sender it is not safe for concurrent use, and for the same reason: the frames arrive in an order that means something.

func NewReceiver

func NewReceiver(config Downlink) (*Receiver, error)

NewReceiver builds the ground side of a downlink.

Give it the same Downlink value the sender was built from. That is what makes the two ends agree.

func (*Receiver) Accept

func (r *Receiver) Accept(cadu []byte) error

Accept takes one received CADU: it strips the sync marker, decodes the frame, and routes it to its virtual channel.

A CADU that does not decode is an error rather than something to swallow, because the caller is the only one who can tell a corrupt frame from a misconfigured channel. A station that would rather keep going should log it and carry on to the next CADU.

func (*Receiver) Next

func (r *Receiver) Next(vcid uint8) ([]byte, bool, error)

Next returns the next whole Space Packet from a virtual channel, or false when the channel has none ready.

A packet split across frames does not appear until its last frame has been accepted, which is why this returns false rather than a partial packet.

func (*Receiver) NextOCF

func (r *Receiver) NextOCF() ([]byte, bool)

NextOCF returns the operational control field of the next accepted frame that carried one, oldest first, or false when none is waiting.

They are queued rather than reduced to a latest value because the flags in a CLCW are transient: a Retransmit that sets and clears between two reads is exactly the report FOP-1 needed to see. The queue holds DefaultBuffer fields and then drops the oldest, so a caller that never reads it leaks nothing.

On a mission running COP-1 the four octets go straight to the commander:

for field, ok := receiver.NextOCF(); ok; field, ok = receiver.NextOCF() {
    if err := commander.AcceptCLCW(field); err != nil {
        return err
    }
}

func (*Receiver) NextPacket

func (r *Receiver) NextPacket(vcid uint8, options ...spp.DecodeOption) (*spp.SpacePacket, bool, error)

NextPacket returns the next Space Packet from a channel, decoded.

It is Next plus spp.Decode, which is what a caller wanting the packet's fields rather than its octets would write anyway.

func (*Receiver) OCFs

func (r *Receiver) OCFs() func(yield func([]byte) bool)

OCFs iterates the operational control fields waiting to be read, draining them. It ends when none is left, so a caller feeding CADUs in as they arrive should call it again after each batch.

func (*Receiver) Packets

func (r *Receiver) Packets(vcid uint8) func(yield func([]byte, error) bool)

Packets iterates the whole Space Packets waiting on a virtual channel.

It ends when the channel has nothing more ready, so a caller feeding CADUs in as they arrive should call it again after each batch.

type Sender

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

Sender turns Space Packets into CADUs, the spacecraft side of a downlink.

It is not safe for concurrent use. A downlink is one ordered stream of frames, and the frame counters say so; serialising access is the caller's job because only the caller knows what order the frames should go in.

func NewSender

func NewSender(config Downlink, options ...SenderOption) (*Sender, error)

NewSender builds the spacecraft side of a downlink.

A configuration carrying an OCF needs WithOCF to say what goes in it. See Downlink.OCF for why that is required rather than defaulted.

func (*Sender) CADUs

func (s *Sender) CADUs() func(yield func([]byte, error) bool)

CADUs iterates the CADUs waiting to be transmitted.

It stops at the first error, handing it to the caller, and ends when nothing is left pending. It does not wait for more: a sender is driven by Send, so an empty queue means the caller has more sending to do.

func (*Sender) Flush

func (s *Sender) Flush() error

Flush pushes the partly filled frame on every channel out.

Without it the last packets of a pass sit in a buffer waiting for traffic that is not coming. The frames it releases are padded to the channel's frame length, as a fixed-length channel requires.

func (*Sender) HasPending

func (s *Sender) HasPending() bool

HasPending reports whether any frame is waiting to go out.

It is a boolean rather than a count because the layer underneath does not offer one: PhysicalChannel.Len reports how many master channels are registered, not how many frames are queued.

func (*Sender) NextCADU

func (s *Sender) NextCADU() ([]byte, bool, error)

NextCADU returns the next CADU, or false when there is none waiting.

Frames come out in the priority order the configuration gave the channels.

func (*Sender) Send

func (s *Sender) Send(vcid uint8, packet []byte) error

Send hands one encoded Space Packet to a virtual channel.

The packet is buffered and packed into frames with whatever else is on that channel; it does not become a CADU until a frame fills up or Flush is called. That is the point of the packet service: a frame carries as many packets as fit, and a packet longer than a frame is split across several.

func (*Sender) SendPacket

func (s *Sender) SendPacket(vcid uint8, packet *spp.SpacePacket) error

SendPacket encodes a Space Packet and sends it.

It is the common case of Send: most callers have a packet rather than octets, and encoding it themselves is a step that can only go one way.

type SenderOption

type SenderOption func(*senderOptions)

SenderOption configures a Sender at construction.

func WithOCF

func WithOCF(supplier func() []byte) SenderOption

WithOCF installs the operational control field supplier for a channel configured with Downlink.OCF.

It is called once per emitted frame and must return exactly four octets. On a mission running COP-1 those octets are a CLCW that FARM-1 generated, which is what carries the uplink's acknowledgement home:

stack.NewSender(config, stack.WithOCF(func() []byte {
    encoded, err := onboard.CLCW(vcid)
    if err != nil {
        return nil // a wrong length is refused rather than transmitted
    }
    return encoded
}))

Returning anything but four octets fails the frame with tmdl.ErrInvalidOCFLength rather than padding or truncating, because a receiver reads the field by position and cannot tell a short one from data.

type Uplink struct {
	// SpacecraftID is the 10-bit identifier both ends expect
	// (CCSDS 232.0-B-4 clause 4.1.2.3).
	SpacecraftID uint16

	// Randomize applies the CCSDS pseudo-randomiser to each codeblock before
	// the CLTU is assembled. Both ends must agree, and they do here.
	Randomize bool

	// Channels are the virtual channels, in any order. At least one is
	// required and their IDs must be distinct.
	Channels []UplinkVC
}

Uplink is the configuration of one command uplink.

As with Downlink, the same value configures both ends.

func (Uplink) Validate

func (u Uplink) Validate() error

Validate reports whether the configuration can be built.

type UplinkVC

type UplinkVC struct {
	// ID is the virtual channel identifier, 0 to MaxUplinkVCID.
	ID uint8

	// MAPID identifies the Multiplexer Access Point within the channel, 0 to
	// 63. A mission that does not use MAP multiplexing leaves it zero.
	MAPID uint8

	// Window is the COP-1 sliding window width: how many frames may be
	// outstanding before the ground has to wait for a CLCW. Zero takes
	// DefaultWindow.
	Window uint8

	// Buffer is how many frames the channel holds. Zero takes DefaultBuffer.
	Buffer int
}

UplinkVC is one virtual channel on the uplink.

type VC

type VC struct {
	// ID is the virtual channel identifier, 0 to MaxVCID.
	ID uint8

	// Priority orders the channel against the others when several have a
	// frame ready. Higher wins. Channels left at zero are all equal, which
	// is fine when only one carries traffic at a time.
	Priority int

	// Buffer is how many frames the channel holds before Send blocks the
	// caller with an error. Zero takes DefaultBuffer.
	Buffer int
}

VC is one virtual channel on the downlink.

Jump to

Keyboard shortcuts

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