Refactored protocol and radio interfaces

This commit is contained in:
2026-02-18 10:08:11 +01:00
parent 74a517a22a
commit 71b4f3734c
5 changed files with 223 additions and 62 deletions

View File

@@ -9,9 +9,10 @@ import (
"log" "log"
"strings" "strings"
"sync" "sync"
"time"
"git.maze.io/go/ham/protocol" "git.maze.io/go/ham/protocol"
"git.maze.io/go/ham/protocol/meshcore/crypto" "git.maze.io/go/ham/radio"
) )
const ( const (
@@ -24,21 +25,6 @@ type Node struct {
driver nodeDriver driver nodeDriver
} }
type NodeInfo struct {
Manufacturer string `json:"manufacturer"`
FirmwareVersion string `json:"firmware_version"`
Type NodeType `json:"node_type"`
Name string `json:"name"`
Power uint8 `json:"power"`
MaxPower uint8 `json:"max_power"`
PublicKey *crypto.PublicKey `json:"public_key"`
Position *Position `json:"position"`
Frequency float64 `json:"frequency"` // in MHz
Bandwidth float64 `json:"bandwidth"` // in kHz
SpreadingFactor uint8 `json:"sf"`
CodingRate uint8 `json:"cr"`
}
// NewCompanion connects to a companion device type (over serial, TCP or BLE). // NewCompanion connects to a companion device type (over serial, TCP or BLE).
func NewCompanion(conn io.ReadWriteCloser) (*Node, error) { func NewCompanion(conn io.ReadWriteCloser) (*Node, error) {
driver := newCompanionDriver(conn) driver := newCompanionDriver(conn)
@@ -64,19 +50,17 @@ func (dev *Node) RawPackets() <-chan *protocol.Packet {
return dev.driver.RawPackets() return dev.driver.RawPackets()
} }
func (dev *Node) Info() *NodeInfo { func (dev *Node) Info() *radio.Info {
return dev.driver.Info() return dev.driver.Info()
} }
type nodeDriver interface { type nodeDriver interface {
radio.Device
protocol.PacketReceiver
Setup() error Setup() error
Close() error
Packets() <-chan *Packet Packets() <-chan *Packet
RawPackets() <-chan *protocol.Packet
Info() *NodeInfo
} }
type CompanionError struct { type CompanionError struct {
@@ -107,7 +91,35 @@ type companionDriver struct {
mu sync.Mutex mu sync.Mutex
packets chan *Packet packets chan *Packet
rawPackets chan *protocol.Packet rawPackets chan *protocol.Packet
info NodeInfo info companionInfo
}
type companionInfo struct {
// Fields returns by CMD_APP_START.
Type NodeType
Power byte // in dBm
MaxPower byte // in dBm
PublicKey [32]byte
Latitude float64
Longitude float64
HasMultiACKs bool
AdvertLocationPolicy byte
TelemetryFlags byte
ManualAddContacts byte
Frequency float64 // in MHz
Bandwidth float64 // in kHz
SpreadingFactor byte
CodingRate byte
Name string
// Fields returns by CMD_DEVICE_QUERY.
FirmwareVersion string
FirmwareVersionCode byte
FirmwareBuildDate string
Manufacturer string
MaxContacts int
MaxGroupChannels int
BLEPIN [4]byte
} }
func newCompanionDriver(conn io.ReadWriteCloser) *companionDriver { func newCompanionDriver(conn io.ReadWriteCloser) *companionDriver {
@@ -145,8 +157,25 @@ func (drv *companionDriver) RawPackets() <-chan *protocol.Packet {
return drv.rawPackets return drv.rawPackets
} }
func (drv *companionDriver) Info() *NodeInfo { func (drv *companionDriver) Info() *radio.Info {
return &drv.info var pos *radio.Position
if drv.info.Latitude != 0 && drv.info.Longitude != 0 {
pos = &radio.Position{
Latitude: drv.info.Latitude,
Longitude: drv.info.Longitude,
}
}
return &radio.Info{
Name: drv.info.Name,
Manufacturer: drv.info.Manufacturer,
Modulation: protocol.LoRa,
Position: pos,
Frequency: drv.info.Frequency,
Bandwidth: drv.info.Bandwidth,
Power: float64(drv.info.Power),
LoRaSF: drv.info.SpreadingFactor,
LoRaCR: drv.info.CodingRate,
}
} }
func (drv *companionDriver) readFrame() ([]byte, error) { func (drv *companionDriver) readFrame() ([]byte, error) {
@@ -252,6 +281,7 @@ func (drv *companionDriver) handleRXData(b []byte) {
return // not listening for packets, discard return // not listening for packets, discard
} }
now := time.Now().UTC()
packet := new(Packet) packet := new(Packet)
if err := packet.UnmarshalBytes(b[2:]); err == nil { if err := packet.UnmarshalBytes(b[2:]); err == nil {
packet.SNR = float64(b[0]) / 4 packet.SNR = float64(b[0]) / 4
@@ -264,6 +294,7 @@ func (drv *companionDriver) handleRXData(b []byte) {
if drv.rawPackets != nil { if drv.rawPackets != nil {
select { select {
case drv.rawPackets <- &protocol.Packet{ case drv.rawPackets <- &protocol.Packet{
Time: now,
Protocol: "meshcore", Protocol: "meshcore",
SNR: packet.SNR, SNR: packet.SNR,
RSSI: packet.RSSI, RSSI: packet.RSSI,
@@ -295,18 +326,18 @@ func (drv *companionDriver) sendAppStart() (err error) {
drv.info.Type = NodeType(b[0]) drv.info.Type = NodeType(b[0])
drv.info.Power = b[1] drv.info.Power = b[1]
drv.info.MaxPower = b[2] drv.info.MaxPower = b[2]
drv.info.PublicKey, _ = crypto.NewPublicKey(b[3 : 3+crypto.PublicKeySize]) copy(drv.info.PublicKey[:], b[3:])
drv.info.Position = new(Position) drv.info.Latitude, drv.info.Longitude = decodeLatLon(b[35:])
drv.info.Position.Unmarshal(b[35:]) drv.info.HasMultiACKs = b[43] != 0
//drv.info.HasMultiACKs = b[43] != 0 drv.info.AdvertLocationPolicy = b[44]
//drv.info.AdvertLocationPolicy = b[44] drv.info.TelemetryFlags = b[45]
//drv.info.TelemetryFlags = b[45] drv.info.ManualAddContacts = b[46]
//drv.info.ManualAddContacts = b[46]
drv.info.Frequency = decodeFrequency(b[47:]) drv.info.Frequency = decodeFrequency(b[47:])
drv.info.Bandwidth = decodeFrequency(b[51:]) drv.info.Bandwidth = decodeFrequency(b[51:])
drv.info.SpreadingFactor = b[55] drv.info.SpreadingFactor = b[55]
drv.info.CodingRate = b[56] drv.info.CodingRate = b[56]
drv.info.Name = strings.TrimRight(string(b[57:]), "\x00") drv.info.Name = strings.TrimRight(string(b[57:]), "\x00")
return return
} }
@@ -325,6 +356,10 @@ func (drv *companionDriver) sendDeviceInfo() (err error) {
} }
b = b[1:] b = b[1:]
drv.info.FirmwareVersionCode = b[0]
drv.info.MaxContacts = int(b[1]) * 2
drv.info.MaxGroupChannels = int(b[2])
drv.info.FirmwareBuildDate = decodeCString(b[7:19])
drv.info.Manufacturer = decodeCString(b[19:59]) drv.info.Manufacturer = decodeCString(b[19:59])
drv.info.FirmwareVersion = decodeCString(b[59:79]) drv.info.FirmwareVersion = decodeCString(b[59:79])
@@ -339,3 +374,8 @@ func (drv *companionDriver) poll() {
} }
} }
} }
var (
_ protocol.PacketReceiver = (*Node)(nil)
_ nodeDriver = (*companionDriver)(nil)
)

View File

@@ -55,6 +55,12 @@ func decodeFrequency(b []byte) float64 {
return float64(int64(binary.LittleEndian.Uint32(b))) / 1e3 return float64(int64(binary.LittleEndian.Uint32(b))) / 1e3
} }
func decodeLatLon(b []byte) (lat float64, lng float64) {
lat = float64(int64(binary.LittleEndian.Uint32(b[0:]))) / 1e6
lng = float64(int64(binary.LittleEndian.Uint32(b[4:]))) / 1e6
return
}
func decodeCString(b []byte) string { func decodeCString(b []byte) string {
if i := bytes.IndexByte(b, 0x00); i > -1 { if i := bytes.IndexByte(b, 0x00); i > -1 {
return string(b[:i]) return string(b[:i])

View File

@@ -1,30 +0,0 @@
package protocol
// Packet represents a raw packet.
type Packet struct {
Protocol string `json:"protocol"` // Protocol name
SNR float64 `json:"snr"` // Signal-to-noise Ratio
RSSI int8 `json:"rssi"` // Received Signal Strength Indicator
Raw []byte `json:"raw"` // Raw packet
}
type Device interface {
// Close the device.
Close() error
}
// Receiver of packets.
type Receiver interface {
Device
// RawPackets starts receiving raw packets.
RawPackets() <-chan *Packet
}
// Transmitter of packets.
type Transmitter interface {
Device
// SendRawPacket sends a raw (encoded) packet.
SendRawPacket(*Packet) error
}

42
protocol/protocol.go Normal file
View File

@@ -0,0 +1,42 @@
package protocol
import (
"time"
"git.maze.io/go/ham/radio"
)
// Protocol standard names (as used in this package).
const (
ADSB = "adsb"
APRS = "aprs"
APRS_IS = "aprsis"
AX25 = "ax25"
LoRa = "lora"
MeshCore = "meshcore"
Meshtastic = "meshtastic"
)
// Packet represents a raw packet.
type Packet struct {
Time time.Time `json:"time,omitempty"` // Receive time stamp
Protocol string `json:"protocol"` // Protocol name
SNR float64 `json:"snr"` // Signal-to-noise Ratio
RSSI int8 `json:"rssi"` // Received Signal Strength Indicator
Raw []byte `json:"raw"` // Raw packet
}
// Receiver of packets.
type PacketReceiver interface {
radio.Device
// RawPackets starts receiving raw packets.
RawPackets() <-chan *Packet
}
type PacketTransmitter interface {
radio.Device
// SendRawPacket sends a raw (encoded) packet.
SendRawPacket(*Packet) error
}

103
radio/radio.go Normal file
View File

@@ -0,0 +1,103 @@
package radio
import "math"
// Info descriptor.
type Info struct {
Name string `yaml:"name" json:"name"` // Name of the device
Device string `yaml:"device" json:"device"` // Device type
Manufacturer string `yaml:"manufacturer" json:"manufacturer"` // Device manufacturer
Antenna string `yaml:"antenna" json:"antenna"` // Antenna type
Modulation string `yaml:"modulation" json:"modulation"` // Modulation (constant from protocol)
Position *Position `yaml:"position" json:"position"` // Position
Frequency float64 `yaml:"frequency" json:"frequency"` // Frequency (in MHz)
RXFrequency float64 `yaml:"rx_frequency" json:"rx_frequency,omitempty"` // Used with split VFOs
TXFrequency float64 `yaml:"tx_frequency" json:"tx_frequency,omitempty"` // Used with split VFOs
Bandwidth float64 `yaml:"bandwidth" json:"bandwidth"` // Bandwidth (in kHz)
Power float64 `yaml:"power" json:"power"` // Power (in dBm)
Gain float64 `yaml:"gain" json:"gain"` // Gain (in dBm)
LoRaSF uint8 `yaml:"lora_sf" json:"lora_sf,omitempty"` // LoRa spreading factor
LoRaCR uint8 `yaml:"lora_cr" json:"lora_cr,omitempty"` // LoRa coding rate
Extra map[string]any `yaml:"extra" json:"extra"` // Extra metadata
}
type Position struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Altitude float64 `json:"altitude,omitempty"`
}
const earthRadiusKm = 6371.0088 // WGS84 mean Earth radius
// RoundTo reduces the accuracy of the position by the provided radius.
func (pos *Position) RoundTo(km float64) *Position {
if km <= 0 {
return pos
}
// Convert km to angular distance (radians)
angular := km / earthRadiusKm
// Convert to degrees
degLatStep := angular * (180.0 / math.Pi)
// Longitude step depends on latitude
latRad := pos.Latitude * math.Pi / 180.0
cosLat := math.Cos(latRad)
var degLonStep float64
if cosLat > 1e-12 {
degLonStep = degLatStep / cosLat
} else {
// Near poles — longitude collapses
degLonStep = 360.0
}
roundedLat := math.Round(pos.Latitude/degLatStep) * degLatStep
roundedLon := math.Round(pos.Longitude/degLonStep) * degLonStep
// Normalize longitude to [-180, 180]
roundedLon = math.Mod(roundedLon+180.0, 360.0)
if roundedLon < 0 {
roundedLon += 360.0
}
roundedLon -= 180.0
return &Position{
Latitude: roundedLat,
Longitude: roundedLon,
Altitude: pos.Altitude,
}
}
// Device is the minimum implementation for a radio device.
type Device interface {
Close() error
Info() *Info
}
// DBmToW converts power in dBm to Watts.
//
// Formula:
//
// P(W) = 10^(dBm/10) / 1000
func DBmToW(dBm float64) float64 {
return math.Pow(10, dBm/10.0) / 1000.0
}
// WToDBm converts power in Watts to dBm.
//
// Behavior:
//
// watts > 0 → normal conversion
// watts <= 0 → returns -Inf (represents zero power)
//
// Formula:
//
// dBm = 10 * log10(P(W) * 1000)
func WToDBm(watts float64) float64 {
if watts <= 0 {
return math.Inf(-1)
}
return 10.0 * math.Log10(watts*1000.0)
}