From 71b4f3734cf9804b9d47fdc7dbdac056148e4e0e Mon Sep 17 00:00:00 2001 From: maze Date: Wed, 18 Feb 2026 10:08:11 +0100 Subject: [PATCH] Refactored protocol and radio interfaces --- protocol/meshcore/node.go | 104 ++++++++++++++++++++++++++------------ protocol/meshcore/util.go | 6 +++ protocol/packet.go | 30 ----------- protocol/protocol.go | 42 +++++++++++++++ radio/radio.go | 103 +++++++++++++++++++++++++++++++++++++ 5 files changed, 223 insertions(+), 62 deletions(-) delete mode 100644 protocol/packet.go create mode 100644 protocol/protocol.go create mode 100644 radio/radio.go diff --git a/protocol/meshcore/node.go b/protocol/meshcore/node.go index e20851d..dc3c6b2 100644 --- a/protocol/meshcore/node.go +++ b/protocol/meshcore/node.go @@ -9,9 +9,10 @@ import ( "log" "strings" "sync" + "time" "git.maze.io/go/ham/protocol" - "git.maze.io/go/ham/protocol/meshcore/crypto" + "git.maze.io/go/ham/radio" ) const ( @@ -24,21 +25,6 @@ type Node struct { 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). func NewCompanion(conn io.ReadWriteCloser) (*Node, error) { driver := newCompanionDriver(conn) @@ -64,19 +50,17 @@ func (dev *Node) RawPackets() <-chan *protocol.Packet { return dev.driver.RawPackets() } -func (dev *Node) Info() *NodeInfo { +func (dev *Node) Info() *radio.Info { return dev.driver.Info() } type nodeDriver interface { + radio.Device + protocol.PacketReceiver + Setup() error - Close() error - Packets() <-chan *Packet - RawPackets() <-chan *protocol.Packet - - Info() *NodeInfo } type CompanionError struct { @@ -107,7 +91,35 @@ type companionDriver struct { mu sync.Mutex packets chan *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 { @@ -145,8 +157,25 @@ func (drv *companionDriver) RawPackets() <-chan *protocol.Packet { return drv.rawPackets } -func (drv *companionDriver) Info() *NodeInfo { - return &drv.info +func (drv *companionDriver) Info() *radio.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) { @@ -252,6 +281,7 @@ func (drv *companionDriver) handleRXData(b []byte) { return // not listening for packets, discard } + now := time.Now().UTC() packet := new(Packet) if err := packet.UnmarshalBytes(b[2:]); err == nil { packet.SNR = float64(b[0]) / 4 @@ -264,6 +294,7 @@ func (drv *companionDriver) handleRXData(b []byte) { if drv.rawPackets != nil { select { case drv.rawPackets <- &protocol.Packet{ + Time: now, Protocol: "meshcore", SNR: packet.SNR, RSSI: packet.RSSI, @@ -295,18 +326,18 @@ func (drv *companionDriver) sendAppStart() (err error) { drv.info.Type = NodeType(b[0]) drv.info.Power = b[1] drv.info.MaxPower = b[2] - drv.info.PublicKey, _ = crypto.NewPublicKey(b[3 : 3+crypto.PublicKeySize]) - drv.info.Position = new(Position) - drv.info.Position.Unmarshal(b[35:]) - //drv.info.HasMultiACKs = b[43] != 0 - //drv.info.AdvertLocationPolicy = b[44] - //drv.info.TelemetryFlags = b[45] - //drv.info.ManualAddContacts = b[46] + copy(drv.info.PublicKey[:], b[3:]) + drv.info.Latitude, drv.info.Longitude = decodeLatLon(b[35:]) + drv.info.HasMultiACKs = b[43] != 0 + drv.info.AdvertLocationPolicy = b[44] + drv.info.TelemetryFlags = b[45] + drv.info.ManualAddContacts = b[46] drv.info.Frequency = decodeFrequency(b[47:]) drv.info.Bandwidth = decodeFrequency(b[51:]) drv.info.SpreadingFactor = b[55] drv.info.CodingRate = b[56] drv.info.Name = strings.TrimRight(string(b[57:]), "\x00") + return } @@ -325,6 +356,10 @@ func (drv *companionDriver) sendDeviceInfo() (err error) { } 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.FirmwareVersion = decodeCString(b[59:79]) @@ -339,3 +374,8 @@ func (drv *companionDriver) poll() { } } } + +var ( + _ protocol.PacketReceiver = (*Node)(nil) + _ nodeDriver = (*companionDriver)(nil) +) diff --git a/protocol/meshcore/util.go b/protocol/meshcore/util.go index cce8bed..657ee5e 100644 --- a/protocol/meshcore/util.go +++ b/protocol/meshcore/util.go @@ -55,6 +55,12 @@ func decodeFrequency(b []byte) float64 { 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 { if i := bytes.IndexByte(b, 0x00); i > -1 { return string(b[:i]) diff --git a/protocol/packet.go b/protocol/packet.go deleted file mode 100644 index 094fd9b..0000000 --- a/protocol/packet.go +++ /dev/null @@ -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 -} diff --git a/protocol/protocol.go b/protocol/protocol.go new file mode 100644 index 0000000..552e2b4 --- /dev/null +++ b/protocol/protocol.go @@ -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 +} diff --git a/radio/radio.go b/radio/radio.go new file mode 100644 index 0000000..6c4e9a0 --- /dev/null +++ b/radio/radio.go @@ -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) +}