Checkpoint
Some checks failed
Test and build / Test and lint (push) Failing after 36s
Test and build / Build collector (push) Failing after 43s
Test and build / Build receiver (push) Failing after 42s

This commit is contained in:
2026-03-05 15:38:18 +01:00
parent 3106b2cf45
commit 13afa08e8a
108 changed files with 19509 additions and 729 deletions

58
.air.toml Normal file
View File

@@ -0,0 +1,58 @@
#:schema https://json.schemastore.org/any.json
env_files = []
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/hamview-server"
cmd = "go build -o ./tmp/hamview-server ./cmd/hamview-server"
delay = 1000
entrypoint = ["./tmp/hamview-server", "-T", "--config", "./etc/hamview-server.yaml"]
exclude_dir = ["assets", "tmp", "vendor", "testdata", "ui"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
ignore_dangerous_root_dir = false
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = false
[misc]
clean_on_exit = false
[proxy]
app_port = 0
app_start_timeout = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true

2
.gitignore vendored
View File

@@ -7,6 +7,7 @@
# Test binary, built with `go test -c`
*.test
test.db
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
@@ -23,6 +24,7 @@ go.work.sum
# Build artifacts
build/
tmp/
# Local configuration files
etc/*.key

0
.gitmodules vendored Normal file
View File

46
.vscode/settings.json vendored
View File

@@ -1,5 +1,45 @@
{
"gopls": {
"formatting.local": "git.maze.io"
}
"gopls": {
"formatting.local": "git.maze.io",
"ui.semanticTokens": true
},
// Global defaults for all other languages (4 spaces)
"editor.tabSize": 4,
"editor.insertSpaces": true,
"editor.detectIndentation": false,
// Go: Use tabs, with a tab size of 4
"[go]": {
"editor.insertSpaces": false,
"editor.tabSize": 4,
"editor.detectIndentation": false
},
// CSS, JavaScript, TypeScript, JSON: Use 2 spaces
"[css]": {
"editor.tabSize": 2,
},
"[javascript]": {
"editor.tabSize": 2,
},
"[typescript]": {
"editor.tabSize": 2,
},
"[typescriptreact]": {
"editor.tabSize": 2,
},
"[json]": {
"editor.tabSize": 2,
},
"[yaml]": {
"editor.tabSize": 2,
},
// For JSON with comments, often used in VSCode config files
"[jsonc]": {
"editor.insertSpaces": true,
"editor.tabSize": 2,
"editor.detectIndentation": false
}
}

43
AGENTS.md Normal file
View File

@@ -0,0 +1,43 @@
# AGENTS
This document provides context for AI agents working on this codebase.
## Project Overview
HAMView is an online Amateur Radio digital protocol live viewer. It features:
- Displaying online radio receivers in near real-time
- Streaming of popular Amateur Radio protocols such as APRS, MeshCore, etc.
- A live packet stream for each of the protocols
- Packet inspection
## Tech Stack
Used technologies:
- **Framework**: Go with echo 4
- **Logging**: logrus
- **Code Editor**: Visual Studio Code
- **Backend**: Xorm on PostgreSQL with PostGIS extensions
- **Broker**: Mosquitto broker
- **Testing**: use `go test -v`
## Testing Requirements
**Always run tests before completing a task.**
Run `go test -v` and `golangci-lint run`.
## Coding Guidelines
### General
- Use builtins from Go, echo and the libraries from `go.mod` where possible
- Follow existing code patterns in the code base
- Don't add new imports unless necessary
### Styling
- Use Go builtins where appropriate
- Order imports with `goimports`
## Protected files
Never add secrets to code, unless a secret is used as a test vector. In that case, ask
for confirmation before adding or changing.

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -33,13 +33,18 @@ type Broker interface {
SubscribeRadios() (<-chan *Radio, error)
PublishPacket(topic string, packet *protocol.Packet) error
SubscribePackets(topic string) (<-chan *protocol.Packet, error)
SubscribePackets(topic string) (<-chan *Packet, error)
}
type Receiver interface {
Disconnected()
}
type Packet struct {
RadioID string
*protocol.Packet
}
type BrokerConfig struct {
Type string `yaml:"type"`
Config yaml.Node `yaml:"conf"`
@@ -197,7 +202,7 @@ func (broker *mqttBroker) SubscribeRadios() (<-chan *Radio, error) {
}
radios := make(chan *Radio, 8)
token := broker.client.Subscribe("radio/#", 0, func(_ mqtt.Client, message mqtt.Message) {
token := broker.client.Subscribe("radio/+", 0, func(_ mqtt.Client, message mqtt.Message) {
var radio Radio
if err := json.Unmarshal(message.Payload(), &radio); err == nil {
select {
@@ -232,17 +237,24 @@ func (broker *mqttBroker) PublishPacket(topic string, packet *protocol.Packet) e
return nil
}
func (broker *mqttBroker) SubscribePackets(topic string) (<-chan *protocol.Packet, error) {
func (broker *mqttBroker) SubscribePackets(topic string) (<-chan *Packet, error) {
if broker.client == nil {
return nil, ErrBrokerNotStarted
}
packets := make(chan *protocol.Packet, 16)
packets := make(chan *Packet, 16)
token := broker.client.Subscribe(topic, 0, func(_ mqtt.Client, message mqtt.Message) {
var packet protocol.Packet
var (
part = strings.Split(message.Topic(), "/")
id = part[len(part)-1]
packet protocol.Packet
)
if err := json.Unmarshal(message.Payload(), &packet); err == nil {
select {
case packets <- &packet:
case packets <- &Packet{
RadioID: id,
Packet: &packet,
}:
default:
}
}

View File

@@ -64,7 +64,6 @@ func run(ctx context.Context, command *cli.Command) error {
if err != nil {
return err
}
defer collector.Close()
broker, err := hamview.NewBroker(&config.Broker)
if err != nil {
@@ -79,7 +78,11 @@ func run(ctx context.Context, command *cli.Command) error {
protocol.APRS,
protocol.MeshCore,
} {
go collector.Collect(broker, proto+"/packet")
go func() {
if err := collector.Collect(broker, proto+"/packet/+"); err != nil {
logger.Fatalf("Error collecting %s packets: %v", proto, err)
}
}()
}
return cmd.WaitForInterrupt(logger, "collector")

View File

@@ -2,19 +2,22 @@ package main
import (
"context"
"encoding/base64"
"time"
"github.com/urfave/cli/v3"
"git.maze.io/go/ham/protocol/aprs/aprsis"
"git.maze.io/go/ham/radio"
"git.maze.io/ham/hamview"
"git.maze.io/ham/hamview/cmd"
)
type aprsisConfig struct {
Broker hamview.BrokerConfig `yaml:"broker"`
Receiver hamview.APRSISConfig `yaml:"receiver"`
Include []string `yaml:"include"`
Broker hamview.BrokerConfig `yaml:"broker"`
Receiver hamview.APRSISConfig `yaml:"receiver"`
Radio map[string]*radio.Info `yaml:"radio"`
Include []string `yaml:"include"`
}
func (config *aprsisConfig) Includes() []string {
@@ -43,15 +46,19 @@ func runAPRSIS(ctx context.Context, command *cli.Command) error {
}
proxy.OnClient = func(callsign string, client *aprsis.ProxyClient) {
go receiveAPRSIS(&config.Broker, callsign, client)
go receiveAPRSIS(&config.Broker, callsign, client, config.Radio[callsign])
}
return waitForInterrupt()
}
func receiveAPRSIS(config *hamview.BrokerConfig, callsign string, client *aprsis.ProxyClient) {
func receiveAPRSIS(config *hamview.BrokerConfig, callsign string, client *aprsis.ProxyClient, extra *radio.Info) {
defer func() { _ = client.Close() }()
if extra == nil {
logger.Warnf("receiver: no radio info configured for %s!", callsign)
}
broker, err := hamview.NewBroker(config)
if err != nil {
logger.Errorf("receiver: can't setup to broker: %v", err)
@@ -59,19 +66,81 @@ func receiveAPRSIS(config *hamview.BrokerConfig, callsign string, client *aprsis
}
defer func() { _ = broker.Close() }()
info := client.Info() // TODO: enrich info from config?
info := client.Info()
if extra != nil {
info.Manufacturer = pick(info.Manufacturer, extra.Manufacturer)
info.Device = pick(info.Device, extra.Device)
info.FirmwareDate = pickTime(info.FirmwareDate, extra.FirmwareDate)
info.FirmwareVersion = pick(info.FirmwareVersion, extra.FirmwareVersion)
info.Antenna = pick(info.Antenna, extra.Antenna)
info.Modulation = pick(info.Modulation, extra.Modulation)
info.Position = pickPosition(info.Position, extra.Position)
info.Frequency = pickFloat64(info.Frequency, extra.Frequency)
info.Bandwidth = pickFloat64(info.Bandwidth, extra.Bandwidth)
info.Power = pickFloat64(info.Power, extra.Power)
info.Gain = pickFloat64(info.Gain, extra.Gain)
info.LoRaSF = pickUint8(info.LoRaSF, extra.LoRaSF)
info.LoRaCR = pickUint8(info.LoRaCR, extra.LoRaCR)
}
if err = broker.StartRadio("aprs", info); err != nil {
logger.Fatalf("receiver: can't start broker: %v", err)
return
}
id := base64.RawURLEncoding.EncodeToString([]byte(callsign))
logger.Infof("receiver: start receiving packets from station: %s", callsign)
for packet := range client.RawPackets() {
logger.Debugf("aprs packet: %#+v", packet)
if err := broker.PublishPacket("aprs/packet", packet); err != nil {
if err := broker.PublishPacket("aprs/packet/"+id, packet); err != nil {
logger.Error(err)
}
}
logger.Infof("receiver: stopped receiving packets from station: %s", callsign)
}
func pick(ss ...string) string {
for _, s := range ss {
if s != "" {
return s
}
}
return ""
}
func pickFloat64(vv ...float64) float64 {
for _, v := range vv {
if v != 0 {
return v
}
}
return 0
}
func pickPosition(vv ...*radio.Position) *radio.Position {
for _, v := range vv {
if v != nil {
return v
}
}
return nil
}
func pickTime(tt ...time.Time) time.Time {
for _, t := range tt {
if !t.Equal(time.Time{}) {
return t
}
}
return time.Time{}
}
func pickUint8(vv ...uint8) uint8 {
for _, v := range vv {
if v != 0 {
return v
}
}
return 0
}

View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/base64"
"github.com/urfave/cli/v3"
@@ -48,6 +49,9 @@ func runMeshCore(ctx context.Context, command *cli.Command) error {
return err
}
// Node id
id := base64.RawURLEncoding.EncodeToString([]byte(info.Name))
// Trace scheduler
//go receiver.RunTraces()
@@ -68,7 +72,7 @@ func runMeshCore(ctx context.Context, command *cli.Command) error {
payloadType,
len(packet.Raw))
}
if err = broker.PublishPacket("meshcore/packet", packet); err != nil {
if err = broker.PublishPacket("meshcore/packet/"+id, packet); err != nil {
logger.Errorf("receiver: failed to publish packet: %v", err)
}
}

View File

@@ -1,15 +1,22 @@
package hamview
import (
"context"
"database/sql"
"encoding/hex"
"fmt"
"strings"
"time"
_ "github.com/cridenour/go-postgis" // PostGIS support
"github.com/lib/pq" // PostgreSQL support
_ "github.com/lib/pq" // PostgreSQL support
"xorm.io/builder"
"xorm.io/xorm"
"git.maze.io/go/ham/protocol"
"git.maze.io/go/ham/protocol/aprs"
"git.maze.io/go/ham/protocol/meshcore"
"git.maze.io/ham/hamview/schema"
)
type CollectorConfig struct {
@@ -22,58 +29,20 @@ type DatabaseConfig struct {
}
type Collector struct {
*sql.DB
radioByID map[string]*schema.Radio
meshCoreGroup map[byte][]*meshcore.Group
}
func NewCollector(config *CollectorConfig) (*Collector, error) {
d, err := sql.Open(config.Database.Type, config.Database.Conf)
if err != nil {
Logger.Debugf("collector: opening %q database", config.Database.Type)
schema.Logger = Logger
if err := schema.Open(config.Database.Type, config.Database.Conf); err != nil {
return nil, err
}
for _, query := range []string{
// radio.*
sqlCreateRadio,
sqlIndexRadioName,
sqlIndexRadioProtocol,
sqlGeometryRadioPosition,
// meshcore_packet.*
sqlCreateMeshCorePacket,
sqlIndexMeshCorePacketHash,
sqlIndexMeshCorePacketPayloadType,
// meshcore_node.*
sqlCreateMeshCoreNode,
sqlIndexMeshCoreNodeName,
sqlAlterMeshCoreNodePrefix,
sqlGeometryMeshCoreNodePosition,
// meshcore_node_position.*
sqlCreateMeshCoreNodePosition,
sqlGeometryMeshCoreNodePositionPosition,
sqlIndexMeshCoreNodePositionPosition,
} {
if _, err := d.Exec(query); err != nil {
var ignore bool
if err, ok := err.(*pq.Error); ok {
switch err.Code {
case "42701": // column "x" of relation "y" already exists (42701)
ignore = true
}
}
Logger.Debugf("collector: sql error %T: %v", err, err)
if !ignore {
return nil, fmt.Errorf("error in query %s: %v", query, err)
}
}
}
return &Collector{
DB: d,
meshCoreGroup: make(map[byte][]*meshcore.Group),
radioByID: make(map[string]*schema.Radio),
}, nil
}
@@ -92,6 +61,8 @@ func (c *Collector) Collect(broker Broker, topic string) error {
return err
}
ctx := context.Background()
loop:
for {
select {
@@ -99,7 +70,7 @@ loop:
if radio == nil {
break loop
}
c.processRadio(radio)
c.processRadio(ctx, radio)
case packet := <-packets:
if packet == nil {
@@ -107,9 +78,9 @@ loop:
}
switch packet.Protocol {
case protocol.APRS:
c.processAPRSPacket(packet)
c.processAPRSPacket(ctx, packet)
case protocol.MeshCore:
c.processMeshCorePacket(packet)
c.processMeshCorePacket(ctx, packet)
}
}
}
@@ -119,150 +90,182 @@ loop:
return nil
}
func (c *Collector) processRadio(radio *Radio) {
Logger.Tracef("collector: process %s radio %q online %t",
radio.Protocol,
radio.Name,
radio.IsOnline)
var latitude, longitude, altitude *float64
if radio.Position != nil {
latitude = &radio.Position.Latitude
longitude = &radio.Position.Longitude
altitude = &radio.Position.Altitude
func (c *Collector) getRadioByID(ctx context.Context, id string) (*schema.Radio, error) {
id = strings.TrimRight(id, "=")
if radio, ok := c.radioByID[id]; ok {
return radio, nil
}
var id int64
if err := c.QueryRow(`
INSERT INTO radio (
name,
is_online,
device,
manufacturer,
firmware_date,
firmware_version,
antenna,
modulation,
protocol,
latitude,
longitude,
altitude,
frequency,
rx_frequency,
tx_frequency,
bandwidth,
power,
gain,
lora_sf,
lora_cr,
extra
) VALUES (
$1,
$2,
NULLIF($3, ''), -- device
NULLIF($4, ''), -- manufacturer
$5,
NULLIF($6, ''), -- firmware_version
NULLIF($7, ''), -- antenna
NULLIF($8, ''), -- modulation
$9, -- protocol
NULLIF($10, 0.0), -- latitude
NULLIF($11, 0.0), -- longitude
$12, -- altitude
$13, -- frequency
NULLIF($14, 0.0), -- rx_frequency
NULLIF($15, 0.0), -- tx_frequency
$16, -- bandwidth
NULLIF($17, 0.0), -- power
NULLIF($18, 0.0), -- gain
NULLIF($19, 0), -- lora_sf
NULLIF($20, 0), -- lora_cr
$21
)
ON CONFLICT (name)
DO UPDATE
SET
is_online = $2,
device = NULLIF($3, ''),
manufacturer = NULLIF($4, ''),
firmware_date = $5,
firmware_version = NULLIF($6, ''),
antenna = NULLIF($7, ''),
modulation = NULLIF($8, ''),
protocol = $9,
latitude = NULLIF($10, 0.0),
longitude = NULLIF($11, 0.0),
altitude = $12,
frequency = $13,
rx_frequency = NULLIF($14, 0.0),
tx_frequency = NULLIF($15, 0.0),
bandwidth = $16,
power = NULLIF($17, 0),
gain = NULLIF($18, 0),
lora_sf = NULLIF($19, 0),
lora_cr = NULLIF($20, 0),
extra = $21
RETURNING id
`,
radio.Name,
radio.IsOnline,
radio.Device,
radio.Manufacturer,
radio.FirmwareDate,
radio.FirmwareVersion,
radio.Antenna,
radio.Modulation,
radio.Protocol,
latitude,
longitude,
altitude,
radio.Frequency,
radio.RXFrequency,
radio.TXFrequency,
radio.Bandwidth,
radio.Power,
radio.Gain,
radio.LoRaSF,
radio.LoRaCR,
nil,
).Scan(&id); err != nil {
Logger.Warnf("collector: error storing radio: %v", err)
radio, err := schema.GetRadioByEncodedID(ctx, id)
if err == nil {
c.radioByID[id] = radio
}
return radio, err
}
func (c *Collector) processRadio(ctx context.Context, received *Radio) {
Logger.Tracef("collector: process %s radio %q online %t",
received.Protocol,
received.Name,
received.IsOnline)
var (
now = time.Now()
engine = schema.Query(ctx).(*xorm.Session)
)
if err := engine.Begin(); err != nil {
Logger.Warnf("collector: can't start session: %v", err)
return
}
radio := new(schema.Radio)
has, err := engine.Where(builder.Eq{
"name": received.Name,
"protocol": received.Protocol,
}).Get(radio)
if err != nil {
Logger.Warnf("collector: can't query radio: %v", err)
return
}
if has {
radio.IsOnline = received.IsOnline
radio.UpdatedAt = now
if _, err = engine.Cols("is_online", "updated_at").Update(radio); err != nil {
_ = engine.Rollback()
Logger.Warnf("collector: can't update radio: %v", err)
return
}
} else {
radio = &schema.Radio{
Name: received.Name,
IsOnline: received.IsOnline,
Manufacturer: received.Manufacturer,
Device: schema.NULLString(received.Device),
FirmwareVersion: schema.NULLString(received.FirmwareVersion),
FirmwareDate: schema.NULLTime(received.FirmwareDate),
Antenna: schema.NULLString(received.Antenna),
Modulation: received.Modulation,
Protocol: received.Protocol,
Frequency: received.Frequency,
Bandwidth: received.Bandwidth,
Power: schema.NULLFloat64(received.Power),
Gain: schema.NULLFloat64(received.Gain),
CreatedAt: now,
UpdatedAt: now,
}
if received.Position != nil {
radio.Latitude = &received.Position.Latitude
radio.Longitude = &received.Position.Longitude
radio.Altitude = &received.Position.Altitude
}
if received.LoRaCR != 0 && received.LoRaSF != 0 {
radio.LoRaSF = &received.LoRaSF
radio.LoRaCR = &received.LoRaCR
}
if _, err = engine.Insert(radio); err != nil {
Logger.Warnf("collector: can't insert radio %#+v: %v", radio, err)
return
}
}
if err = engine.Commit(); err != nil {
Logger.Errorf("collector: can't commit radio session: %v", err)
}
}
func (c *Collector) processAPRSPacket(packet *protocol.Packet) {
decoded, err := aprs.ParsePacket(string(packet.Raw))
func (c *Collector) processAPRSPacket(ctx context.Context, received *Packet) {
radio, err := c.getRadioByID(ctx, received.RadioID)
if err != nil {
Logger.Warnf("collector: invalid %s packet: %v", packet.Protocol, err)
Logger.Warnf("collector: process %s packet: can't find radio %q: %v", received.Protocol, received.RadioID, err)
return
}
decoded, err := aprs.Parse(string(received.Raw))
if err != nil {
Logger.Warnf("collector: invalid %s packet: %v", received.Protocol, err)
return
}
Logger.Tracef("collector: process %s packet (%d bytes)",
packet.Protocol,
len(packet.Raw))
received.Protocol,
len(received.Raw))
var id int64
if err := c.QueryRow(`
INSERT INTO aprs_packet (
src_address,
dst_address,
comment
) VALUES ($1, $2, $3)
RETURNING id;
`,
decoded.Src.String(),
decoded.Dst.String(),
decoded.Comment,
).Scan(&id); err != nil {
Logger.Warnf("collector: error storing packet: %v", err)
engine := schema.Query(ctx)
station := new(schema.APRSStation)
has, err := engine.Where(builder.Eq{"call": strings.ToUpper(decoded.Source.String())}).Get(station)
if err != nil {
Logger.Warnf("collector: can't query APRS station: %v", err)
return
} else if has {
cols := []string{"last_heard_at"}
station.LastHeardAt = received.Time
if decoded.Latitude != 0 {
station.LastLatitude = sql.NullFloat64{Float64: decoded.Latitude, Valid: true}
station.LastLongitude = sql.NullFloat64{Float64: decoded.Longitude, Valid: true}
cols = append(cols, "last_latitude", "last_longitude")
}
if _, err = engine.ID(station.ID).Cols(cols...).Update(station); err != nil {
Logger.Warnf("collector: can't update APRS station: %v", err)
return
}
} else {
station = &schema.APRSStation{
Call: strings.ToUpper(decoded.Source.String()),
Symbol: decoded.Symbol,
FirstHeardAt: received.Time,
LastHeardAt: received.Time,
}
if decoded.Latitude != 0 {
station.LastLatitude = sql.NullFloat64{
Float64: decoded.Latitude,
Valid: decoded.Latitude != 0,
}
station.LastLongitude = sql.NullFloat64{
Float64: decoded.Longitude,
Valid: decoded.Longitude != 0,
}
}
if station.ID, err = engine.Insert(station); err != nil {
Logger.Warnf("collector: can't insert APRS station: %v", err)
return
}
}
packet := &schema.APRSPacket{
RadioID: radio.ID,
StationID: station.ID,
Source: station.Call,
Destination: decoded.Destination.String(),
Path: decoded.Path.String(),
Comment: decoded.Comment,
Symbol: string(decoded.Symbol[:]),
Raw: string(received.Raw),
}
if decoded.Latitude != 0 {
packet.Latitude = sql.NullFloat64{
Float64: decoded.Latitude,
Valid: decoded.Latitude != 0,
}
packet.Longitude = sql.NullFloat64{
Float64: decoded.Longitude,
Valid: decoded.Longitude != 0,
}
}
if _, err = engine.Insert(packet); err != nil {
Logger.Warnf("collector: can't insert APRS packet: %v", err)
}
}
func (c *Collector) processMeshCorePacket(packet *protocol.Packet) {
func (c *Collector) processMeshCorePacket(ctx context.Context, packet *Packet) {
radio, err := c.getRadioByID(ctx, packet.RadioID)
if err != nil {
Logger.Warnf("collector: process %s packet: can't find radio %q: %v", packet.Protocol, packet.RadioID, err)
return
}
var parsed meshcore.Packet
if err := parsed.UnmarshalBytes(packet.Raw); err != nil {
if err = parsed.UnmarshalBytes(packet.Raw); err != nil {
Logger.Warnf("collector: invalid %s packet: %v", packet.Protocol, err)
return
}
@@ -276,125 +279,137 @@ func (c *Collector) processMeshCorePacket(packet *protocol.Packet) {
parsed.Path = nil // store NULL
}
var id int64
if err := c.QueryRow(`
INSERT INTO meshcore_packet (
snr,
rssi,
hash,
route_type,
payload_type,
path,
payload,
raw,
received_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id;`,
packet.SNR,
packet.RSSI,
parsed.Hash(),
parsed.RouteType,
parsed.PayloadType,
parsed.Path,
parsed.Payload,
packet.Raw,
packet.Time,
).Scan(&id); err != nil {
var channelHash string
switch parsed.PayloadType {
case meshcore.TypeGroupText, meshcore.TypeGroupData:
if len(parsed.Payload) > 0 {
channelHash = fmt.Sprintf("%02x", parsed.Payload[0])
}
}
var (
save = &schema.MeshCorePacket{
RadioID: radio.ID,
SNR: packet.SNR,
RSSI: packet.RSSI,
RouteType: uint8(parsed.RouteType),
PayloadType: uint8(parsed.PayloadType),
Version: parsed.Version,
Hash: hex.EncodeToString(parsed.Hash()),
Path: parsed.Path,
Payload: parsed.Payload,
ChannelHash: channelHash,
Raw: packet.Raw,
ReceivedAt: packet.Time,
}
)
if _, err = schema.Query(ctx).Insert(save); err != nil {
Logger.Warnf("collector: error storing packet: %v", err)
return
}
switch parsed.PayloadType {
case meshcore.TypeAdvert:
payload, err := parsed.Decode()
if err != nil {
Logger.Warnf("collector: error decoding packet: %v", err)
return
}
var (
advert = payload.(*meshcore.Advert)
nodeID int64
latitude *float64
longitude *float64
)
if advert.Position != nil {
latitude = &advert.Position.Latitude
longitude = &advert.Position.Longitude
}
if err = c.QueryRow(`
INSERT INTO meshcore_node (
node_type,
public_key,
name,
local_time,
first_heard,
last_heard,
last_latitude,
last_longitude,
last_advert_id
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9
)
ON CONFLICT (public_key)
DO UPDATE
SET
name = $3,
local_time = $4,
last_heard = $6,
last_latitude = $7,
last_longitude = $8,
last_advert_id = $9
RETURNING id
`,
advert.Type,
advert.PublicKey.Bytes(),
advert.Name,
advert.Time,
packet.Time,
packet.Time,
latitude,
longitude,
id,
).Scan(&nodeID); err != nil {
Logger.Warnf("collector: error storing node: %v", err)
return
}
if advert.Position != nil {
if _, err = c.Exec(`
INSERT INTO meshcore_node_position (
node_id,
heard_at,
latitude,
longitude,
position
) VALUES (
$1,
$2,
$3,
$4,
ST_SetSRID(ST_MakePoint($5, $6), 4326)
);
`,
nodeID,
packet.Time,
advert.Position.Latitude,
advert.Position.Longitude,
advert.Position.Latitude,
advert.Position.Longitude,
); err != nil {
Logger.Warnf("collector: error storing node position: %v", err)
return
}
}
c.processMeshCoreAdvert(ctx, save, &parsed)
}
}
func (c *Collector) processMeshCoreAdvert(ctx context.Context, packet *schema.MeshCorePacket, parsed *meshcore.Packet) {
payload, err := parsed.Decode()
if err != nil {
Logger.Warnf("collector: error decoding packet: %v", err)
return
}
advert, ok := payload.(*meshcore.Advert)
if !ok {
Logger.Warnf("collector: expected Advert, got %T!?", payload)
return
}
node := &schema.MeshCoreNode{
PacketHash: packet.Hash,
Name: advert.Name,
Type: uint8(advert.Type),
Prefix: fmt.Sprintf("%02x", advert.PublicKey.Bytes()[0]),
PublicKey: hex.EncodeToString(advert.PublicKey.Bytes()),
FirstHeardAt: packet.ReceivedAt,
LastHeardAt: packet.ReceivedAt,
}
if advert.Position != nil {
node.LastLatitude = &advert.Position.Latitude
node.LastLongitude = &advert.Position.Longitude
}
var (
engine = schema.Query(ctx)
existing = new(schema.MeshCoreNode)
)
if err = engine.Begin(); err != nil {
Logger.Warnf("collector: can't start session: %v", err)
return
}
var has bool
if has, err = engine.Where(builder.Eq{"`public_key`": node.PublicKey}).Get(existing); err != nil {
_ = engine.Rollback()
Logger.Warnf("collector: can't query session: %v", err)
return
}
if has {
cols := []string{"last_heard_at"}
existing.LastHeardAt = packet.ReceivedAt
if advert.Position != nil {
existing.LastLatitude = node.LastLatitude
existing.LastLongitude = node.LastLongitude
cols = append(cols, "last_latitude", "last_longitude")
}
existing.Name = node.Name
_, err = engine.ID(existing.ID).Cols(cols...).Update(existing)
} else {
_, err = engine.Insert(node)
}
if err != nil {
_ = engine.Rollback()
Logger.Warnf("collector: can't save (update: %t): %v", has, err)
return
}
if err = engine.Commit(); err != nil {
Logger.Warnf("collector: can't commit session: %v", err)
return
}
/*
if advert.Position != nil {
if _, err = c.DB.Exec(`
INSERT INTO meshcore_node_position (
node_id,
heard_at,
latitude,
longitude,
position
) VALUES (
$1,
$2,
$3,
$4,
ST_SetSRID(ST_MakePoint($5, $6), 4326)
);
`,
nodeID,
packet.Time,
advert.Position.Latitude,
advert.Position.Longitude,
advert.Position.Latitude,
advert.Position.Longitude,
); err != nil {
Logger.Warnf("collector: error storing node position: %v", err)
return
}
}
}
*/
}

12
go.mod
View File

@@ -1,9 +1,11 @@
module git.maze.io/ham/hamview
go 1.25.6
go 1.26
replace git.maze.io/go/ham => ../ham
require (
git.maze.io/go/ham v0.1.1-0.20260223201507-65f3fe39a98b
git.maze.io/go/ham v0.1.1-0.20260302213739-8f8a97300f5c
github.com/Vaniog/go-postgis v0.0.0-20240619200434-9c2eb8ed621e
github.com/cemkiy/echo-logrus v0.0.0-20200218141616-06f9cd1dae34
github.com/cridenour/go-postgis v1.0.1
@@ -11,20 +13,26 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/labstack/echo/v4 v4.15.0
github.com/lib/pq v1.11.2
github.com/mattn/go-sqlite3 v1.14.32
github.com/sirupsen/logrus v1.9.4
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
github.com/urfave/cli/v3 v3.6.2
go.yaml.in/yaml/v3 v3.0.4
xorm.io/builder v0.3.13
xorm.io/xorm v1.3.11
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/rogpeppe/go-internal v1.8.0 // indirect
github.com/syndtr/goleveldb v1.0.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.48.0 // indirect

74
go.sum
View File

@@ -1,9 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
git.maze.io/go/ham v0.1.0 h1:ytqqkGux4E6h3QbCB3zJy/Ngc+fEqodyMpepbp9o/ts=
git.maze.io/go/ham v0.1.0/go.mod h1:+WuiawzNBqlWgklVoodUAJc0cV+NDW6RR8Tn+AW8hsU=
git.maze.io/go/ham v0.1.1-0.20260223201507-65f3fe39a98b h1:Wzt2uXbqW9h/159KeXY95CrDoLN0m3HCxPC6jPLO6ws=
git.maze.io/go/ham v0.1.1-0.20260223201507-65f3fe39a98b/go.mod h1:+WuiawzNBqlWgklVoodUAJc0cV+NDW6RR8Tn+AW8hsU=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
github.com/Vaniog/go-postgis v0.0.0-20240619200434-9c2eb8ed621e h1:Ck+0lNRr62RM/LNKkkD0R1aJ2DvgELqmmuNvyyHL75E=
github.com/Vaniog/go-postgis v0.0.0-20240619200434-9c2eb8ed621e/go.mod h1:o3MIxN5drWoGBTtBGtLqFZlr7RjfdQKnfwYXoUU77vU=
github.com/cemkiy/echo-logrus v0.0.0-20200218141616-06f9cd1dae34 h1:cGxEwqDl+PiqPtJpQNoiJIXcrVEkkSMuMQtb+PPAHL4=
@@ -15,12 +13,29 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -49,9 +64,20 @@ github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOA
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
@@ -61,9 +87,12 @@ github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC4
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU=
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8=
@@ -81,13 +110,18 @@ golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20200214034016-1d94cc7ab1c6/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -107,11 +141,43 @@ golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.20.4 h1:J8+m2trkN+KKoE7jglyHYYYiaq5xmz2HoHJIiBlRzbE=
modernc.org/sqlite v1.20.4/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.3.11 h1:i4tlVUASogb0ZZFJHA7dZqoRU2pUpUsutnNdaOlFyMI=
xorm.io/xorm v1.3.11/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=

View File

@@ -24,9 +24,10 @@ type MeshCoreConfig struct {
}
type MeshCoreCompanionConfig struct {
Port string `yaml:"port"`
Baud int `yaml:"baud"`
Addr string `yaml:"addr"`
Port string `yaml:"port"`
Baud int `yaml:"baud"`
Addr string `yaml:"addr"`
HasSNR bool `yaml:"has_snr"` // patch: adds SNR/RSSI data
}
type MeshCorePrefix byte
@@ -54,6 +55,9 @@ func NewMeshCoreReceiver(config *MeshCoreConfig) (protocol.PacketReceiver, error
case "companion", "":
return newMeshCoreCompanionReceiver(config.Conf)
case "repeater":
return newMeshCoreRepeaterReceiver(config.Conf)
default:
return nil, fmt.Errorf("hamview: unsupported MeshCore node type %q", config.Type)
}
@@ -104,6 +108,51 @@ func newMeshCoreCompanionReceiver(node yaml.Node) (protocol.PacketReceiver, erro
return receiver, nil
}
func newMeshCoreRepeaterReceiver(node yaml.Node) (protocol.PacketReceiver, error) {
var config MeshCoreCompanionConfig
if err := node.Decode(&config); err != nil {
return nil, err
}
var (
conn io.ReadWriteCloser
err error
)
switch {
case config.Addr != "":
Logger.Infof("receiver: connecting to MeshCore repeater at tcp://%s", config.Addr)
conn, err = net.Dial("tcp", config.Addr)
default:
if config.Port == "" {
// TODO: detect serial ports
config.Port = "/dev/ttyUSB0"
}
if config.Baud == 0 {
config.Baud = 115200
}
Logger.Infof("receiver: connecting to MeshCore repeater on %s at %d baud", config.Port, config.Baud)
conn, err = serial.OpenPort(&serial.Config{
Name: config.Port,
Baud: config.Baud,
})
}
if err != nil {
return nil, err
}
receiver, err := meshcore.NewRepeater(conn, config.HasSNR)
if err != nil {
_ = conn.Close()
Logger.Warnf("receiver: error connecting to repeater: %v", err)
return nil, err
}
info := receiver.Info()
Logger.Infof("receiver: connected to MeshCore repeater %q model %q version %q", info.Name, info.Manufacturer, info.FirmwareVersion)
return receiver, nil
}
type meshCoreNode struct {
Name string `json:"name"`
PublicKey []byte `json:"public_key"`

87
schema/aprs.go Normal file
View File

@@ -0,0 +1,87 @@
package schema
import (
"context"
"database/sql"
"os"
"strings"
"time"
"xorm.io/builder"
)
func init() {
RegisterModel(new(APRSStation))
RegisterModel(new(APRSPacket))
}
type APRSStation struct {
ID int64 `xorm:"pk autoincr" json:"id"`
Call string `xorm:"varchar(10) unique not null" json:"call"`
Symbol string `xorm:"varchar(2)" json:"symbol"`
FirstHeardAt time.Time `xorm:"timestamp not null"`
LastHeardAt time.Time `xorm:"timestamp not null"`
LastLatitude sql.NullFloat64
LastLongitude sql.NullFloat64
}
func GetAPRSStation(ctx context.Context, call string) (*APRSStation, error) {
station := new(APRSStation)
has, err := Query(ctx).
Where(builder.Eq{`"call"`: strings.ToUpper(call)}).
Get(station)
if err != nil {
return nil, err
} else if !has {
return nil, os.ErrNotExist
}
return station, nil
}
func (station APRSStation) GetPackets(ctx context.Context) ([]*APRSPacket, error) {
packets := make([]*APRSPacket, 0, 10)
return packets, Query(ctx).
Where(builder.Eq{"`station_id`": station.ID}).
Find(&packets)
}
type APRSPacket struct {
ID int64 `xorm:"pk autoincr" json:"id"`
RadioID int64 `xorm:"index" json:"radio_id"`
Radio Radio `json:"radio"`
StationID int64 `json:"-"`
Station *APRSStation `xorm:"-" json:"station"`
Source string `xorm:"varchar(10) not null" json:"src"`
Destination string `xorm:"varchar(10) not null" json:"dst"`
Path string `xorm:"varchar(88) not null default ''" json:"path"`
Comment string `xorm:"varchar(250)" json:"comment"`
Latitude sql.NullFloat64 `json:"latitude,omitempty"`
Longitude sql.NullFloat64 `json:"longitude,omitempty"`
Symbol string `xorm:"varchar(2)" json:"symbol"`
Raw string `json:"raw"`
ReceivedAt time.Time `json:"received_at"`
}
func GetAPRSPackets(ctx context.Context, limit int) ([]*APRSPacket, error) {
packets := make([]*APRSPacket, 0, limit)
return packets, Query(ctx).
OrderBy("`received_at` DESC").
Limit(limit).
Find(&packets)
}
func GetAPRSPacketsBySource(ctx context.Context, source string) ([]*APRSPacket, error) {
packets := make([]*APRSPacket, 0, 100)
return packets, Query(ctx).
Where(builder.Eq{"source": strings.ToUpper(source)}).
OrderBy("`received_at` DESC").
Find(&packets)
}
func GetAPRSPacketsByDestination(ctx context.Context, destination string) ([]*APRSPacket, error) {
packets := make([]*APRSPacket, 0, 100)
return packets, Query(ctx).
Where(builder.Eq{"destination": strings.ToUpper(destination)}).
OrderBy("`received_at` DESC").
Find(&packets)
}

192
schema/engine.go Normal file
View File

@@ -0,0 +1,192 @@
package schema
import (
"context"
"database/sql"
"time"
"github.com/sirupsen/logrus"
"xorm.io/xorm"
"xorm.io/xorm/log"
"xorm.io/xorm/names"
_ "github.com/lib/pq" // PostgreSQL support
_ "github.com/mattn/go-sqlite3" // SQLite support
)
// Logger used by this package
var Logger = logrus.New()
var xormEngine *xorm.Engine
type engineContextKeyType struct{}
var engineContextKey = engineContextKeyType{}
// Engine represents a xorm engine or session.
type Engine interface {
Table(tableNameOrBean any) *xorm.Session
Count(...any) (int64, error)
Decr(column string, arg ...any) *xorm.Session
Delete(...any) (int64, error)
Truncate(...any) (int64, error)
Exec(...any) (sql.Result, error)
Find(any, ...any) error
Get(beans ...any) (bool, error)
ID(any) *xorm.Session
In(string, ...any) *xorm.Session
Incr(column string, arg ...any) *xorm.Session
Insert(...any) (int64, error)
Iterate(any, xorm.IterFunc) error
Join(joinOperator string, tablename, condition any, args ...any) *xorm.Session
SQL(any, ...any) *xorm.Session
Where(any, ...any) *xorm.Session
Asc(colNames ...string) *xorm.Session
Desc(colNames ...string) *xorm.Session
Limit(limit int, start ...int) *xorm.Session
NoAutoTime() *xorm.Session
SumInt(bean any, columnName string) (res int64, err error)
Sync(...any) error
Select(string) *xorm.Session
SetExpr(string, any) *xorm.Session
NotIn(string, ...any) *xorm.Session
OrderBy(any, ...any) *xorm.Session
Exist(...any) (bool, error)
Distinct(...string) *xorm.Session
Query(...any) ([]map[string][]byte, error)
Cols(...string) *xorm.Session
Context(ctx context.Context) *xorm.Session
Ping() error
IsTableExist(tableNameOrBean any) (bool, error)
Begin() error
Rollback() error
Commit() error
}
// Query the engine from the context.
func Query(ctx context.Context) Engine {
if engine, ok := ctx.Value(engineContextKey).(Engine); ok {
return engine
}
return xormEngine.Context(ctx)
}
// Open a database connection.
func Open(driver, config string) error {
var err error
if xormEngine, err = xorm.NewEngine(driver, config); err != nil {
return err
}
gonicNames := []string{
"ID",
"SSL", "UID",
"SNR", "RSSI",
"APRS", "MeshCore",
"LoRa",
}
for _, name := range gonicNames {
names.LintGonicMapper[name] = true
}
xormEngine.SetMapper(names.GonicMapper{})
xormEngine.SetLogger(xormLogger{})
for _, model := range registeredModels {
Logger.Debugf("schema: sync schema %T", model)
if err = xormEngine.Sync(model); err != nil {
_ = xormEngine.Close()
xormEngine = nil
return err
}
}
return nil
}
var (
registeredModels []any
registeredInitFuncs []func() error
)
func RegisterModel(model any, initFuncs ...func() error) {
registeredModels = append(registeredModels, model)
if len(initFuncs) > 0 && initFuncs[0] != nil {
registeredInitFuncs = append(registeredInitFuncs, initFuncs...)
}
}
func NULLFloat64(v float64) *float64 {
if v == 0 {
return nil
}
return &v
}
func NULLString(s string) *string {
if s == "" {
return nil
}
return &s
}
func NULLTime(t time.Time) *time.Time {
if t.Equal(time.Time{}) {
return nil
}
return &t
}
type xormLogger struct{}
func (l xormLogger) BeforeSQL(context log.LogContext) {} // only invoked when IsShowSQL is true
func (l xormLogger) AfterSQL(context log.LogContext) {} // only invoked when IsShowSQL is true
func (l xormLogger) Debug(args ...any) { Logger.Debug(append([]any{"engine: "}, args...)...) }
func (l xormLogger) Debugf(format string, args ...any) { Logger.Debugf("engine: "+format, args...) }
func (l xormLogger) Error(args ...any) { Logger.Error(append([]any{"engine: "}, args...)...) }
func (l xormLogger) Errorf(format string, args ...any) { Logger.Errorf("engine: "+format, args...) }
func (l xormLogger) Info(args ...any) { Logger.Info(append([]any{"engine: "}, args...)...) }
func (l xormLogger) Infof(format string, args ...any) { Logger.Infof("engine: "+format, args...) }
func (l xormLogger) Warn(args ...any) { Logger.Warn(append([]any{"engine: "}, args...)...) }
func (l xormLogger) Warnf(format string, args ...any) { Logger.Warnf("engine: "+format, args...) }
func (l xormLogger) Level() log.LogLevel {
switch Logger.Level {
case logrus.TraceLevel:
return log.LOG_DEBUG
case logrus.DebugLevel:
return log.LOG_DEBUG
case logrus.InfoLevel:
return log.LOG_INFO
case logrus.ErrorLevel:
return log.LOG_ERR
case logrus.WarnLevel:
return log.LOG_WARNING
case logrus.FatalLevel:
return log.LOG_OFF
default:
return log.LOG_UNKNOWN
}
}
func (l xormLogger) SetLevel(level log.LogLevel) {
switch level {
case log.LOG_DEBUG:
Logger.SetLevel(logrus.DebugLevel)
case log.LOG_INFO:
Logger.SetLevel(logrus.InfoLevel)
case log.LOG_ERR:
Logger.SetLevel(logrus.ErrorLevel)
case log.LOG_OFF:
Logger.SetLevel(logrus.FatalLevel)
}
}
func (l xormLogger) ShowSQL(show ...bool) {
_ = show
}
func (l xormLogger) IsShowSQL() bool {
return false
}
var _ log.ContextLogger = (*xormLogger)(nil)

273
schema/meshcore.go Normal file
View File

@@ -0,0 +1,273 @@
package schema
import (
"context"
"encoding/hex"
"errors"
"fmt"
"os"
"time"
"xorm.io/builder"
"git.maze.io/go/ham/protocol"
"git.maze.io/go/ham/protocol/meshcore"
)
func init() {
RegisterModel(new(MeshCorePacket))
RegisterModel(new(MeshCoreNode))
RegisterModel(new(MeshCoreNodePosition))
RegisterModel(new(MeshCoreGroup))
}
type MeshCorePacket struct {
ID int64 `xorm:"pk autoincr" json:"id"`
RadioID int64 `xorm:"index" json:"radio_id"`
Radio *Radio `xorm:"-" json:"radio"`
SNR float64 `xorm:"not null default 0" json:"snr"`
RSSI int `xorm:"not null default 0" json:"rssi"`
Version int `xorm:"not null default 1" json:"version"`
RouteType uint8 `xorm:"index not null" json:"route_type"`
PayloadType uint8 `xorm:"index not null" json:"payload_type"`
Hash string `xorm:"varchar(16) index not null" json:"hash"`
Path []byte `xorm:"bytea" json:"path"`
Payload []byte `xorm:"bytea not null" json:"payload"`
Raw []byte `xorm:"bytea not null" json:"raw"`
Parsed *string `xorm:"jsonb" json:"parsed"`
ChannelHash string `xorm:"varchar(2) index" json:"channel_hash,omitempty"`
ReceivedAt time.Time `json:"received_at"`
}
func (MeshCorePacket) TableName() string {
return "meshcore_packet"
}
func GetMeshCorePackets(ctx context.Context, limit int) ([]*MeshCorePacket, error) {
packets := make([]*MeshCorePacket, 0, limit)
return packets, Query(ctx).
OrderBy("`received_at` DESC").
Limit(limit).
Find(&packets)
}
func GetMeshCorePacketsByHash(ctx context.Context, hash string) ([]*MeshCorePacket, error) {
if len(hash) != 16 {
return nil, errors.New("invalid hash")
} else if _, err := hex.DecodeString(hash); err != nil {
return nil, err
}
packets := make([]*MeshCorePacket, 0, 10)
return packets, Query(ctx).
Where(builder.Eq{"hash": hash}).
OrderBy("`received_at` ASC").
Find(&packets)
}
func GetMeshCorePacketsByPayloadType(ctx context.Context, payloadType meshcore.PayloadType) ([]*MeshCorePacket, error) {
packets := make([]*MeshCorePacket, 0, 10)
return packets, Query(ctx).
Where(builder.Eq{"payload_type": int(payloadType)}).
OrderBy("`received_at` DESC").
Find(&packets)
}
func GetMeshCorePacketsByChannelHash(ctx context.Context, hash string) ([]*MeshCorePacket, error) {
packets := make([]*MeshCorePacket, 0, 10)
return packets, Query(ctx).
Where(builder.Eq{
"`payload_type`": int(meshcore.TypeGroupText),
"`channel_hash`": hash,
}).
OrderBy("`received_at` DESC").
Find(&packets)
}
type MeshCoreGroup struct {
ID int64 `xorm:"pk autoincr" json:"id"`
Name string `xorm:"varchar(32) not null unique" json:"name"`
Secret string `xorm:"varchar(32) not null" json:"secret"`
IsPublic bool `xorm:"boolean not null default false" json:"-"`
}
func (MeshCoreGroup) TableName() string {
return "meshcore_group"
}
func GetMeshCoreGroups(ctx context.Context) ([]*MeshCoreGroup, error) {
groups := make([]*MeshCoreGroup, 0, 10)
return groups, Query(ctx).
Where(builder.Eq{"is_public": true}).
OrderBy("name asc").
Find(&groups)
}
type MeshCoreNode struct {
ID int64 `xorm:"pk autoincr" json:"id"`
PacketHash string `xorm:"varchar(16) index 'meshcore_packet_hash'" json:"-"`
Packets []*MeshCorePacket `xorm:"-" json:"packet"`
Name string `xorm:"varchar(100) not null" json:"name"`
Type uint8 `xorm:"index not null" json:"type"`
Prefix string `xorm:"varchar(2) not null" json:"prefix"`
PublicKey string `xorm:"varchar(64) not null unique" json:"public_key"`
FirstHeardAt time.Time `xorm:"timestamp not null" json:"first_heard_at"`
LastHeardAt time.Time `xorm:"timestamp not null" json:"last_heard_at"`
LastLatitude *float64 `json:"last_latitude"`
LastLongitude *float64 `json:"last_longitude"`
Distance float64 `xorm:"-" json:"distance,omitempty"`
}
func (MeshCoreNode) TableName() string {
return "meshcore_node"
}
func GetMeshCoreNodeByPublicKey(ctx context.Context, publicKey string) (*MeshCoreNode, error) {
node := new(MeshCoreNode)
if ok, err := Query(ctx).
Where(builder.Eq{"public_key": publicKey}).
Get(node); err != nil {
return nil, err
} else if !ok {
return nil, os.ErrNotExist
}
return node, nil
}
func GetMeshCoreNodes(ctx context.Context) ([]*MeshCoreNode, error) {
nodes := make([]*MeshCoreNode, 0, 100)
return nodes, Query(ctx).
OrderBy("`last_heard_at` DESC").
Find(&nodes)
}
func GetMeshCoreNodesByType(ctx context.Context, nodeType meshcore.NodeType) ([]*MeshCoreNode, error) {
nodes := make([]*MeshCoreNode, 0, 100)
return nodes, Query(ctx).
Where(builder.Eq{"type": nodeType}).
OrderBy("`last_heard_at` DESC").
Find(&nodes)
}
type MeshCoreNodeWithDistance struct {
MeshCoreNode `xorm:"extends"`
Distance float64 `xorm:"distance"`
}
func GetMeshCoreNodesCloseTo(ctx context.Context, publicKey string, radius float64) (*MeshCoreNode, []*MeshCoreNode, error) {
node, err := GetMeshCoreNodeByPublicKey(ctx, publicKey)
if err != nil {
return nil, nil, err
} else if node.LastLatitude == nil || node.LastLongitude == nil {
return nil, nil, errors.New("node has no location")
}
nodesWithDistance := make([]*MeshCoreNodeWithDistance, 0, 100)
selectClause := fmt.Sprintf("*, "+
"ST_Distance("+
"ST_SetSRID(ST_MakePoint(%f, %f), 4326)::geography, "+
"ST_SetSRID(ST_MakePoint(last_longitude, last_latitude), 4326)::geography"+
") as distance",
*node.LastLongitude, *node.LastLatitude)
if err = Query(ctx).
Select(selectClause).
Where("id != ?", node.ID).
Where("ST_DWithin("+
"ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, "+
"ST_SetSRID(ST_MakePoint(last_longitude, last_latitude), 4326)::geography, ?)",
*node.LastLongitude, *node.LastLatitude, radius).
OrderBy("`distance` ASC").
Find(&nodesWithDistance); err != nil {
return nil, nil, err
}
nodes := make([]*MeshCoreNode, len(nodesWithDistance))
for i, node := range nodesWithDistance {
node.MeshCoreNode.Distance = node.Distance
nodes[i] = &node.MeshCoreNode
}
return node, nodes, nil
}
type MeshCoreNodePosition struct {
ID int64 `xorm:"pk autoincr" json:"id"`
MeshCoreNodeId int64 `xorm:"not null 'meshcore_node_id'" json:"-"`
MeshCoreNode *MeshCoreNode `xorm:"-" json:"node"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
ReceivedAt time.Time `xorm:"timestamp not null" json:"received_at"`
}
func (MeshCoreNodePosition) TableName() string {
return "meshcore_node_position"
}
type MeshCoreStats struct {
Messages int64 `json:"messages"`
Nodes int64 `json:"nodes"`
Receivers int64 `json:"receivers"`
Packets struct {
Timestamps []int64 `json:"timestamps"`
Packets []int64 `json:"packets"`
} `json:"packets"`
}
func GetMeshCoreStats(ctx context.Context) (*MeshCoreStats, error) {
var (
engine = Query(ctx)
stats = new(MeshCoreStats)
err error
)
if stats.Messages, err = engine.
In("`payload_type`", meshcore.TypeText, meshcore.TypeGroupText).
Count(&MeshCorePacket{}); err != nil {
return nil, err
}
if stats.Nodes, err = engine.
Count(&MeshCoreNode{}); err != nil {
return nil, err
}
if stats.Receivers, err = engine.
Where(builder.Eq{"`protocol`": protocol.MeshCore}).
Count(&Radio{}); err != nil {
return nil, err
}
return stats, nil
}
/*
Column | Type | Collation | Nullable | Default
--------------+--------------------------+-----------+----------+---------------------------------------------
id | bigint | | not null | nextval('meshcore_packet_id_seq'::regclass)
snr | real | | not null | 0
rssi | smallint | | not null | 0
hash | bytea | | not null |
route_type | smallint | | not null |
payload_type | smallint | | not null |
path | bytea | | |
payload | bytea | | |
raw | bytea | | |
parsed | jsonb | | |
received_at | timestamp with time zone | | not null | now()
created_at | timestamp with time zone | | not null | now()
*/
/*
id | bigint | | not null | nextval('meshcore_node_id_seq'::regclass)
last_advert_id | bigint | | |
node_type | smallint | | not null | 0
public_key | bytea | | not null |
name | text | | |
local_time | timestamp with time zone | | not null |
first_heard | timestamp with time zone | | not null | now()
last_heard | timestamp with time zone | | not null | now()
last_latitude | numeric(10,8) | | |
last_longitude | numeric(11,8) | | |
prefix | bytea | | | generated always as ("substring"(public_key, 0, 2)) stored
last_position | geometry(Point,4326) | | | generated always as ( +
| | | | CASE +
| | | | WHEN last_latitude IS NOT NULL AND last_longitude IS NOT NULL THEN st_setsrid(st_makepoint(last_latitude::double precision, last_longitude::double precision), 4326)+
| | | | ELSE NULL::geometry +
| | | | END) stored
position | geometry(Point,4326) | | |
*/

75
schema/radio.go Normal file
View File

@@ -0,0 +1,75 @@
package schema
import (
"context"
"encoding/base64"
"os"
"strings"
"time"
"xorm.io/builder"
)
func init() {
RegisterModel(new(Radio))
}
type Radio struct {
ID int64 `xorm:"pk autoincr" json:"id"`
Name string `xorm:"not null unique" json:"name"`
IsOnline bool `xorm:"bool not null default false" json:"is_online"`
Manufacturer string `xorm:"varchar(64) not null" json:"manufacturer"`
Device *string `xorm:"varchar(64)" json:"device"`
FirmwareVersion *string `xorm:"varchar(32)" json:"firmware_version"`
FirmwareDate *time.Time `json:"firmware_date"`
Antenna *string `xorm:"varchar(100)" json:"antenna"`
Modulation string `xorm:"varchar(16) not null" json:"modulation"`
Protocol string `xorm:"varchar(16) not null index" json:"protocol"`
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
Altitude *float64 `json:"altitude,omitempty"`
Frequency float64 `xorm:"not null" json:"frequency"`
Bandwidth float64 `xorm:"not null" json:"bandwidth"`
Power *float64 `json:"power,omitempty"`
Gain *float64 `json:"gain,omitempty"`
LoRaSF *uint8 `xorm:"smallint 'lora_sf'" json:"lora_sf,omitempty"`
LoRaCR *uint8 `xorm:"smallint 'lora_cr'" json:"lora_cr,omitempty"`
Extra []byte `xorm:"jsonb" json:"extra,omitempty"`
CreatedAt time.Time `xorm:"timestamp not null default current_timestamp" json:"created_at"`
UpdatedAt time.Time `xorm:"timestamp not null default current_timestamp" json:"updated_at"`
}
func GetRadioByEncodedID(ctx context.Context, id string) (*Radio, error) {
name, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(id, "="))
if err != nil {
return nil, err
}
radio := new(Radio)
has, err := Query(ctx).Where(builder.Eq{"`name`": name}).Get(radio)
if err != nil {
return nil, err
} else if !has {
return nil, os.ErrNotExist
}
return radio, nil
}
func GetRadios(ctx context.Context) ([]*Radio, error) {
radios := make([]*Radio, 0, 5)
return radios, Query(ctx).Find(&radios)
}
func GetRadiosByProtocol(ctx context.Context, protocol string) ([]*Radio, error) {
radios := make([]*Radio, 0, 5)
return radios, Query(ctx).
Where(builder.Eq{"`protocol`": protocol}).
Find(&radios)
}
func GetRadiosRecentlyOnline(ctx context.Context) ([]*Radio, error) {
radios := make([]*Radio, 0, 5)
return radios, Query(ctx).
Where(builder.Eq{"`is_online`": true}).
Find(&radios)
}

425
server.go
View File

@@ -1,423 +1,30 @@
package hamview
import (
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"os"
"slices"
"strconv"
"strings"
"time"
"github.com/sirupsen/logrus"
echologrus "github.com/cemkiy/echo-logrus"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"git.maze.io/go/ham/protocol/meshcore"
"git.maze.io/go/ham/protocol/meshcore/crypto"
"git.maze.io/ham/hamview/server"
)
const DefaultServerListen = ":8073"
// Deprecated: Use server.Config instead
type ServerConfig = server.Config
type ServerConfig struct {
Listen string `yaml:"listen"`
}
type Server struct {
listen string
listenAddr *net.TCPAddr
db *sql.DB
}
// Deprecated: Use server.Server instead
type Server = server.Server
// NewServer creates a new server instance
// Deprecated: Use server.New instead
func NewServer(serverConfig *ServerConfig, databaseConfig *DatabaseConfig) (*Server, error) {
if serverConfig.Listen == "" {
serverConfig.Listen = DefaultServerListen
// Get logger from the global context or create a new one
logger := Logger
if logger == nil {
logger = logrus.New()
}
listenAddr, err := net.ResolveTCPAddr("tcp", serverConfig.Listen)
if err != nil {
return nil, fmt.Errorf("hamview: invalid listen address %q: %v", serverConfig.Listen, err)
dbConfig := &server.DatabaseConfig{
Type: databaseConfig.Type,
Conf: databaseConfig.Conf,
}
db, err := sql.Open(databaseConfig.Type, databaseConfig.Conf)
if err != nil {
return nil, err
}
return &Server{
listen: serverConfig.Listen,
listenAddr: listenAddr,
db: db,
}, nil
}
func (server *Server) Run() error {
echologrus.Logger = Logger
e := echo.New()
e.Logger = echologrus.GetEchoLogger()
e.Use(echologrus.Hook())
e.Use(middleware.RequestLogger())
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"},
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept},
}))
e.GET("/api/v1/meshcore/nodes", server.apiGetMeshCoreNodes)
e.GET("/api/v1/meshcore/packets", server.apiGetMeshCorePackets)
e.GET("/api/v1/meshcore/path/:origin/:path", server.apiGetMeshCorePath)
e.GET("/api/v1/meshcore/sources", server.apiGetMeshCoreSources)
if server.listenAddr.IP == nil || server.listenAddr.IP.Equal(net.ParseIP("0.0.0.0")) || server.listenAddr.IP.Equal(net.ParseIP("::")) {
Logger.Infof("server: listening on http://127.0.0.1:%d", server.listenAddr.Port)
} else {
Logger.Infof("server: listening on http://%s:%d", server.listenAddr.IP, server.listenAddr.Port)
}
return e.Start(server.listen)
}
func (server *Server) apiError(ctx echo.Context, err error, status ...int) error {
Logger.Warnf("server: error serving %s %s: %v", ctx.Request().Method, ctx.Request().URL.Path, err)
if len(status) > 0 {
return ctx.JSON(status[0], map[string]any{
"error": err.Error(),
})
}
switch {
case os.IsNotExist(err):
return ctx.JSON(http.StatusNotFound, nil)
case os.IsPermission(err):
return ctx.JSON(http.StatusUnauthorized, nil)
default:
return ctx.JSON(http.StatusInternalServerError, map[string]any{
"error": err.Error(),
})
}
}
type meshCoreNodeResponse struct {
SNR float64 `json:"snr"`
RSSI int8 `json:"rssi"`
Name string `json:"name"`
PublicKey []byte `json:"public_key"`
Prefix byte `json:"prefix"`
NodeType meshcore.NodeType `json:"node_type"`
FirstHeard time.Time `json:"first_heard"`
LastHeard time.Time `json:"last_heard"`
Position *meshcore.Position `json:"position"`
}
func (server *Server) apiGetMeshCoreNodes(ctx echo.Context) error {
/*
nodeTypes := getQueryInts(ctx, "type")
if len(nodeTypes) == 0 {
nodeTypes = []int{
int(meshcore.Repeater),
}
}
*/
nodeType := meshcore.Repeater
if ctx.QueryParam("type") != "" {
nodeType = meshcore.NodeType(getQueryInt(ctx, "type"))
}
rows, err := server.db.Query(sqlSelectMeshCoreNodesLastPosition, nodeType, 25)
if err != nil {
return server.apiError(ctx, err)
}
var (
response []meshCoreNodeResponse
prefix []byte
)
for rows.Next() {
var (
row meshCoreNodeResponse
lat, lng *float64
)
if err := rows.Scan(
&row.SNR,
&row.RSSI,
&row.Name,
&row.PublicKey,
&prefix,
&row.NodeType,
&row.FirstHeard,
&row.LastHeard,
&lat,
&lng,
); err != nil {
return server.apiError(ctx, err)
}
if lat != nil && lng != nil {
row.Position = &meshcore.Position{
Latitude: *lat,
Longitude: *lng,
}
}
if len(prefix) > 0 {
row.Prefix = prefix[0]
}
response = append(response, row)
}
return ctx.JSON(http.StatusOK, response)
}
type meshCorePacketResponse struct {
SNR float64 `json:"snr"`
RSSI int8 `json:"rssi"`
Hash []byte `json:"hash"`
RouteType byte `json:"route_type"`
PayloadType byte `json:"payload_type"`
Path []byte `json:"path"`
ReceivedAt time.Time `json:"received_at"`
Raw []byte `json:"raw"`
Parsed []byte `json:"parsed"`
}
func (server *Server) apiGetMeshCorePackets(ctx echo.Context) error {
var (
query string
limit = 25
args []any
)
if hashParam := ctx.QueryParam("hash"); hashParam != "" {
var (
hash []byte
err error
)
switch len(hashParam) {
case base64.URLEncoding.EncodedLen(8):
hash, err = base64.URLEncoding.DecodeString(hashParam)
case hex.EncodedLen(8):
hash, err = hex.DecodeString(hashParam)
default:
err = errors.New("invalid encoding")
}
if err != nil {
return server.apiError(ctx, err, http.StatusBadRequest)
}
query = sqlSelectMeshCorePacketsByHash
args = []any{hash}
} else {
query = sqlSelectMeshCorePackets
args = []any{limit}
}
rows, err := server.db.Query(query, args...)
if err != nil {
return server.apiError(ctx, err)
}
var response []meshCorePacketResponse
for rows.Next() {
var row meshCorePacketResponse
if err := rows.Scan(
&row.SNR,
&row.RSSI,
&row.Hash,
&row.RouteType,
&row.PayloadType,
&row.Path,
&row.ReceivedAt,
&row.Raw,
&row.Parsed,
); err != nil {
return server.apiError(ctx, err)
}
response = append(response, row)
}
return ctx.JSON(http.StatusOK, response)
}
type meshCorePathResponse struct {
Origin *meshCoreNode `json:"origin"`
Path []*meshCoreNodeDistance `json:"path"`
}
func (server *Server) apiGetMeshCorePath(ctx echo.Context) error {
origin, err := hex.DecodeString(ctx.Param("origin"))
if err != nil || len(origin) != crypto.PublicKeySize {
return ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid origin",
})
}
path, err := hex.DecodeString(ctx.Param("path"))
if err != nil || len(path) == 0 {
return ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid path",
})
}
var (
node meshCoreNodeDistance
prefix []byte
latitude, longitude *float64
)
if err := server.db.QueryRow(`
SELECT
n.name,
n.public_key,
n.prefix,
n.first_heard,
n.last_heard,
n.last_latitude,
n.last_longitude
FROM
meshcore_node n
WHERE
n.node_type = 2 AND
n.public_key = $1
`,
origin,
).Scan(
&node.Name,
&node.PublicKey,
&prefix,
&node.FirstHeard,
&node.LastHeard,
&latitude,
&longitude,
); err != nil {
return server.apiError(ctx, err)
}
node.Prefix = MeshCorePrefix(prefix[0])
if latitude == nil || longitude == nil {
return ctx.JSON(http.StatusNotFound, map[string]any{
"error": "origin has no known position",
})
}
node.Position = &meshcore.Position{
Latitude: *latitude,
Longitude: *longitude,
}
var (
current = &node
trace []*meshCoreNodeDistance
)
slices.Reverse(path)
for _, prefix := range path {
if prefix != byte(current.Prefix) {
var hop *meshCoreNodeDistance
if hop, err = meshCoreRepeaterWithPrefixCloseTo(server.db, MeshCorePrefix(prefix), current.Position); err != nil {
if !os.IsNotExist(err) {
return server.apiError(ctx, err)
}
current = &meshCoreNodeDistance{
meshCoreNode: meshCoreNode{
Prefix: MeshCorePrefix(prefix),
Position: current.Position,
},
}
} else {
current = hop
}
}
trace = append(trace, current)
}
/*
if path[len(path)-1] == node.Prefix {
path = path[:len(path)-2]
}
*/
var response = meshCorePathResponse{
Origin: &node.meshCoreNode,
Path: trace,
}
return ctx.JSON(http.StatusOK, response)
}
type meshCoreSourcesResponse struct {
Window time.Time `json:"time"`
Packets map[string]int `json:"packets"`
}
func (server *Server) apiGetMeshCoreSources(ctx echo.Context) error {
var (
now = time.Now().UTC()
windows = map[string]struct {
Interval int
Since time.Duration
}{
"24h": {900, time.Hour * 24},
"1w": {3600, time.Hour * 24 * 7},
}
window = ctx.QueryParam("window")
)
if window == "" {
window = "24h"
}
params, ok := windows[window]
if !ok {
return server.apiError(ctx, os.ErrNotExist)
}
rows, err := server.db.Query(sqlSelectMeshCorePacketsByRepeaterWindowed, params.Interval, now.Add(-params.Since))
if err != nil {
return server.apiError(ctx, err)
}
var (
response []*meshCoreSourcesResponse
buckets = make(map[int64]*meshCoreSourcesResponse)
)
for rows.Next() {
var result struct {
Window time.Time
Repeater string
Packets int
}
if err := rows.Scan(&result.Window, &result.Repeater, &result.Packets); err != nil {
return server.apiError(ctx, err)
}
if result.Packets <= 10 {
continue // ignore
}
if bucket, ok := buckets[result.Window.Unix()]; ok {
bucket.Packets[result.Repeater] = result.Packets
} else {
bucket = &meshCoreSourcesResponse{
Window: result.Window,
Packets: map[string]int{
result.Repeater: result.Packets,
},
}
response = append(response, bucket)
buckets[result.Window.Unix()] = bucket
}
}
return ctx.JSON(http.StatusOK, response)
}
func getQueryInt(ctx echo.Context, param string) int {
v, _ := strconv.Atoi(ctx.QueryParam(param))
return v
}
func getQueryInts(ctx echo.Context, param string) []int {
var values []int
if keys := strings.Split(ctx.QueryParam(param), ","); len(keys) > 0 {
for _, value := range keys {
if v, err := strconv.Atoi(value); err == nil {
values = append(values, v)
}
}
}
return values
return server.New(serverConfig, dbConfig, logger)
}

713
server/API.md Normal file
View File

@@ -0,0 +1,713 @@
# HAMView API Reference
Version: 1.0
Base URL: `/api/v1`
Content-Type: `application/json`
## Table of Contents
1. [Authentication](#authentication)
2. [Error Handling](#error-handling)
3. [Endpoints](#endpoints)
- [Radios](#radios)
- [MeshCore](#meshcore)
- [APRS](#aprs)
4. [Data Models](#data-models)
5. [Examples](#examples)
---
## Authentication
Currently, the API does not require authentication. All endpoints are publicly accessible.
---
## Error Handling
All error responses follow this format:
```json
{
"error": "Human-readable error message"
}
```
### HTTP Status Codes
| Code | Description |
|------|-------------|
| 200 | Success |
| 400 | Bad Request - Invalid parameters |
| 404 | Not Found - Resource does not exist |
| 500 | Internal Server Error |
---
## Endpoints
### Radios
#### List All Radios
```
GET /api/v1/radios
```
Returns a list of all radio receivers/stations.
**Response:** `200 OK`
```typescript
Radio[]
```
**Example:**
```bash
curl http://localhost:8073/api/v1/radios
```
```json
[
{
"id": 1,
"name": "Station-Alpha",
"is_online": true,
"manufacturer": "Heltec",
"protocol": "meshcore",
"frequency": 868.1
}
]
```
---
#### List Radios by Protocol
```
GET /api/v1/radios/:protocol
```
Returns radios filtered by protocol.
**Parameters:**
| Name | Type | In | Description |
|----------|--------|------|-------------|
| protocol | string | path | Protocol name (e.g., "meshcore", "aprs") |
**Response:** `200 OK`
```typescript
Radio[]
```
**Example:**
```bash
curl http://localhost:8073/api/v1/radios/meshcore
```
---
### MeshCore
#### Get MeshCore Statistics
```
GET /api/v1/meshcore
```
Returns aggregated network statistics.
**Response:** `200 OK`
```typescript
{
messages: number;
nodes: number;
receivers: number;
packets: {
timestamps: number[];
packets: number[];
};
}
```
**Example:**
```bash
curl http://localhost:8073/api/v1/meshcore
```
```json
{
"messages": 150234,
"nodes": 127,
"receivers": 8,
"packets": {
"timestamps": [1709650800, 1709654400],
"packets": [142, 203]
}
}
```
---
#### List MeshCore Groups
```
GET /api/v1/meshcore/groups
```
Returns public MeshCore groups/channels.
**Response:** `200 OK`
```typescript
{
id: number;
name: string;
secret: string;
}[]
```
**Example:**
```bash
curl http://localhost:8073/api/v1/meshcore/groups
```
```json
[
{
"id": 5,
"name": "General Chat",
"secret": "0123456789abcdef0123456789abcdef"
}
]
```
---
#### List MeshCore Nodes
```
GET /api/v1/meshcore/nodes
```
Returns MeshCore network nodes.
**Query Parameters:**
| Name | Type | Required | Description |
|------|--------|----------|-------------|
| type | string | No | Node type: "chat", "room", "sensor", "repeater" |
**Response:** `200 OK`
```typescript
{
id: number;
packet: MeshCorePacket[];
name: string;
type: number;
prefix: string;
public_key: string;
first_heard_at: string;
last_heard_at: string;
last_latitude: number | null;
last_longitude: number | null;
distance?: number;
}[]
```
**Example:**
```bash
curl http://localhost:8073/api/v1/meshcore/nodes
curl http://localhost:8073/api/v1/meshcore/nodes?type=chat
```
```json
[
{
"id": 42,
"packet": [],
"name": "NODE-CHARLIE",
"type": 0,
"prefix": "mc",
"public_key": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"first_heard_at": "2026-01-15T08:00:00Z",
"last_heard_at": "2026-03-05T14:25:00Z",
"last_latitude": 52.3667,
"last_longitude": 4.8945
}
]
```
---
#### Find Nodes Near Location
```
GET /api/v1/meshcore/nodes/close-to/:publickey
```
Returns nodes within a specified radius of a reference node.
**Path Parameters:**
| Name | Type | Description |
|-----------|--------|-------------|
| publickey | string | 64-character hex-encoded Ed25519 public key |
**Query Parameters:**
| Name | Type | Required | Default | Description |
|--------|--------|----------|---------|-------------|
| radius | number | No | 25000 | Search radius in meters |
**Response:** `200 OK`
```typescript
{
node: MeshCoreNode;
nodes: MeshCoreNode[]; // Sorted by distance, with distance field populated
}
```
**Example:**
```bash
curl "http://localhost:8073/api/v1/meshcore/nodes/close-to/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef?radius=50000"
```
```json
{
"node": {
"id": 42,
"name": "NODE-CHARLIE",
"public_key": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"last_latitude": 52.3667,
"last_longitude": 4.8945,
"distance": 0
},
"nodes": [
{
"id": 43,
"name": "NODE-DELTA",
"last_latitude": 52.3700,
"last_longitude": 4.9000,
"distance": 450.5
}
]
}
```
---
#### List MeshCore Packets
```
GET /api/v1/meshcore/packets
```
Returns MeshCore packets based on filter criteria.
**Query Parameters (mutually exclusive):**
| Name | Type | Required | Description |
|--------------|--------|----------|-------------|
| hash | string | No | 16-character hex hash |
| type | number | No | Payload type (0-255) |
| channel_hash | string | No | 2-character channel hash (requires type) |
**Response:** `200 OK`
```typescript
{
id: number;
radio_id: number;
radio: Radio | null;
snr: number;
rssi: number;
version: number;
route_type: number;
payload_type: number;
hash: string;
path: string; // Base64
payload: string; // Base64
raw: string; // Base64
parsed: object | null;
channel_hash: string;
received_at: string;
}[]
```
**Payload Types:**
| Value | Description |
|-------|-------------|
| 0 | Ping |
| 1 | Node announcement |
| 2 | Direct text message |
| 3 | Group text message |
| 4 | Position update |
**Examples:**
```bash
# Get 100 most recent packets
curl http://localhost:8073/api/v1/meshcore/packets
# Get packets by hash
curl http://localhost:8073/api/v1/meshcore/packets?hash=a1b2c3d4e5f67890
# Get packets by type
curl http://localhost:8073/api/v1/meshcore/packets?type=3
# Get group messages for a channel
curl http://localhost:8073/api/v1/meshcore/packets?type=3&channel_hash=ab
```
```json
[
{
"id": 12345,
"radio_id": 1,
"snr": 8.5,
"rssi": -95,
"version": 1,
"route_type": 0,
"payload_type": 3,
"hash": "a1b2c3d4e5f67890",
"path": "AQIDBA==",
"payload": "SGVsbG8gV29ybGQ=",
"raw": "AQIDBAUGBwg=",
"parsed": {"text": "Hello World"},
"channel_hash": "ab",
"received_at": "2026-03-05T14:30:00Z"
}
]
```
---
### APRS
#### List APRS Packets
```
GET /api/v1/aprs/packets
```
Returns APRS packets based on filter criteria.
**Query Parameters (evaluated in order):**
| Name | Type | Required | Default | Description |
|-------|--------|----------|---------|-------------|
| src | string | No | - | Source callsign (case-insensitive) |
| dst | string | No | - | Destination callsign (case-insensitive) |
| limit | number | No | 100 | Maximum number of packets when no src/dst filter is used |
**Response:** `200 OK`
```typescript
{
id: number;
radio_id: number;
radio: Radio;
src: string;
dst: string;
path: string;
comment: string;
latitude: number | null;
longitude: number | null;
symbol: string;
raw: string;
received_at: string;
}[]
```
**Examples:**
```bash
# Get 100 most recent packets
curl http://localhost:8073/api/v1/aprs/packets
# Get packets by source callsign
curl http://localhost:8073/api/v1/aprs/packets?src=OE1ABC
# Get packets by destination callsign
curl http://localhost:8073/api/v1/aprs/packets?dst=APRS
# Get recent packets with explicit limit
curl http://localhost:8073/api/v1/aprs/packets?limit=200
```
---
## Data Models
### Radio
```typescript
interface Radio {
id: number; // Unique identifier
name: string; // Station name
is_online: boolean; // Online status
manufacturer: string; // Hardware manufacturer
device: string | null; // Device model
firmware_version: string | null; // Firmware version
firmware_date: string | null; // ISO 8601 timestamp
antenna: string | null; // Antenna description
modulation: string; // e.g., "LoRa"
protocol: string; // e.g., "meshcore", "aprs"
latitude: number | null; // Decimal degrees
longitude: number | null; // Decimal degrees
altitude: number | null; // Meters
frequency: number; // Hz
bandwidth: number; // Hz
power: number | null; // dBm
gain: number | null; // dBi
lora_sf: number | null; // LoRa spreading factor (7-12)
lora_cr: number | null; // LoRa coding rate (5-8)
extra: object | null; // Additional metadata
created_at: string; // ISO 8601 timestamp
updated_at: string; // ISO 8601 timestamp
}
```
### MeshCorePacket
```typescript
interface MeshCorePacket {
id: number;
radio_id: number;
radio: Radio | null;
snr: number; // Signal-to-noise ratio (dB)
rssi: number; // Received signal strength (dBm)
version: number;
route_type: number;
payload_type: number;
hash: string; // 16-char hex
path: string; // Base64-encoded
payload: string; // Base64-encoded
raw: string; // Base64-encoded
parsed: object | null; // Depends on payload_type
channel_hash: string; // 2-char hex
received_at: string; // ISO 8601 timestamp
}
```
### APRSPacket
```typescript
interface APRSPacket {
id: number;
radio_id: number;
radio: Radio;
src: string; // Source callsign
dst: string; // Destination callsign
path: string; // Digipeater path
comment: string;
latitude: number | null;
longitude: number | null;
symbol: string; // APRS symbol table + code
raw: string; // Raw APRS packet
received_at: string; // ISO 8601 timestamp
}
```
### MeshCoreNode
```typescript
interface MeshCoreNode {
id: number;
packet: MeshCorePacket[];
name: string;
type: number; // 0=repeater, 1=chat, 2=room, 3=sensor
prefix: string; // 2-char network prefix
public_key: string; // 64-char hex Ed25519 public key
first_heard_at: string; // ISO 8601 timestamp
last_heard_at: string; // ISO 8601 timestamp
last_latitude: number | null;
last_longitude: number | null;
distance?: number; // Meters (proximity queries only)
}
```
### MeshCoreGroup
```typescript
interface MeshCoreGroup {
id: number;
name: string; // Max 32 characters
secret: string; // 32-char hex
}
```
### MeshCoreStats
```typescript
interface MeshCoreStats {
messages: number;
nodes: number;
receivers: number;
packets: {
timestamps: number[]; // Unix timestamps
packets: number[]; // Packet counts
};
}
```
---
## Examples
### JavaScript/TypeScript Client
```typescript
// Using fetch API
const API_BASE = 'http://localhost:8073/api/v1';
// Get all radios
async function getRadios(): Promise<Radio[]> {
const response = await fetch(`${API_BASE}/radios`);
if (!response.ok) throw new Error(await response.text());
return response.json();
}
// Get MeshCore nodes by type
async function getMeshCoreNodes(type?: string): Promise<MeshCoreNode[]> {
const url = type
? `${API_BASE}/meshcore/nodes?type=${type}`
: `${API_BASE}/meshcore/nodes`;
const response = await fetch(url);
if (!response.ok) throw new Error(await response.text());
return response.json();
}
// Find nearby nodes
async function getNodesNearby(publicKey: string, radius: number = 25000) {
const response = await fetch(
`${API_BASE}/meshcore/nodes/close-to/${publicKey}?radius=${radius}`
);
if (!response.ok) throw new Error(await response.text());
return response.json();
}
```
### Python Client
```python
import requests
from typing import List, Optional, Dict, Any
API_BASE = 'http://localhost:8073/api/v1'
def get_radios(protocol: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get all radios or filter by protocol."""
url = f"{API_BASE}/radios/{protocol}" if protocol else f"{API_BASE}/radios"
response = requests.get(url)
response.raise_for_status()
return response.json()
def get_meshcore_packets(
hash: Optional[str] = None,
type: Optional[int] = None,
channel_hash: Optional[str] = None
) -> List[Dict[str, Any]]:
"""Get MeshCore packets with optional filters."""
params = {}
if hash:
params['hash'] = hash
if type is not None:
params['type'] = type
if channel_hash:
params['channel_hash'] = channel_hash
response = requests.get(f"{API_BASE}/meshcore/packets", params=params)
response.raise_for_status()
return response.json()
def get_meshcore_stats() -> Dict[str, Any]:
"""Get MeshCore network statistics."""
response = requests.get(f"{API_BASE}/meshcore")
response.raise_for_status()
return response.json()
```
### Go Client
```go
package hamview
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)
const APIBase = "http://localhost:8073/api/v1"
type Client struct {
BaseURL string
HTTP *http.Client
}
func NewClient() *Client {
return &Client{
BaseURL: APIBase,
HTTP: &http.Client{},
}
}
func (c *Client) GetRadios() ([]Radio, error) {
var radios []Radio
err := c.get("/radios", &radios)
return radios, err
}
func (c *Client) GetMeshCoreNodes(nodeType string) ([]MeshCoreNode, error) {
path := "/meshcore/nodes"
if nodeType != "" {
path += "?type=" + url.QueryEscape(nodeType)
}
var nodes []MeshCoreNode
err := c.get(path, &nodes)
return nodes, err
}
func (c *Client) get(path string, result interface{}) error {
resp, err := c.HTTP.Get(c.BaseURL + path)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(result)
}
```
---
## Rate Limiting
Currently no rate limiting is implemented.
## Versioning
API version is specified in the URL path (`/api/v1`). Breaking changes will increment the version number.
## Support
For issues or questions, please refer to the project documentation at https://git.maze.io/ham/hamview

164
server/README.md Normal file
View File

@@ -0,0 +1,164 @@
# Server Package
This package contains the restructured HTTP server implementation for HAMView.
## Structure
```
server/
├── server.go # Main server setup and configuration
├── router.go # Route configuration with nested routers
├── handlers_radios.go # Radio endpoint handlers
├── handlers_meshcore.go # MeshCore endpoint handlers
├── error.go # Error handling utilities
└── server_test.go # Test infrastructure and test cases
```
## Design Principles
### Clean Separation of Concerns
- **server.go**: Server initialization, configuration, and lifecycle management
- **router.go**: Centralized route definition using Echo's Group feature for nested routing
- **handlers_*.go**: Domain-specific handler functions grouped by feature
- **error.go**: Consistent error handling across all endpoints
### Nested Routers
The routing structure uses Echo's Group feature to create a clean hierarchy:
```
/api/v1
├── /radios
│ ├── GET / -> handleGetRadios
│ └── GET /:protocol -> handleGetRadios
└── /meshcore
├── GET / -> handleGetMeshCore
├── GET /groups -> handleGetMeshCoreGroups
├── GET /packets -> handleGetMeshCorePackets
└── /nodes
├── GET / -> handleGetMeshCoreNodes
└── GET /close-to/:publickey -> handleGetMeshCoreNodesCloseTo
```
### Testing Infrastructure
The test suite uses an in-memory SQLite3 database for fast, isolated unit tests:
- **setupTestServer()**: Creates a test Echo instance with routes and in-memory DB
- **teardownTestServer()**: Cleans up test resources
- Test cases cover all endpoints with various query parameters
- Benchmarks for performance testing
- Example showing how to populate test data
## Usage
### Creating a Server
```go
import (
"github.com/sirupsen/logrus"
"git.maze.io/ham/hamview/server"
)
logger := logrus.New()
serverConfig := &server.Config{
Listen: ":8073",
}
dbConfig := &server.DatabaseConfig{
Type: "postgres",
Conf: "host=localhost user=ham dbname=hamview",
}
srv, err := server.New(serverConfig, dbConfig, logger)
if err != nil {
log.Fatal(err)
}
if err := srv.Run(); err != nil {
log.Fatal(err)
}
```
### Running Tests
```bash
# Run all tests
go test -v ./server/
# Run specific test
go test -v ./server/ -run TestRadiosEndpoints
# Run with coverage
go test -v -cover ./server/
# Run benchmarks
go test -v -bench=. ./server/
```
### Adding New Endpoints
1. **Add handler function** in the appropriate `handlers_*.go` file:
```go
func (s *Server) handleNewEndpoint(c echo.Context) error {
// Implementation
return c.JSON(http.StatusOK, result)
}
```
2. **Register route** in `router.go`:
```go
func setupSomethingRoutes(s *Server, api *echo.Group) {
group := api.Group("/something")
group.GET("/new", s.handleNewEndpoint)
}
```
3. **Add test** in `server_test.go`:
```go
func TestNewEndpoint(t *testing.T) {
e, _ := setupTestServer(t)
defer teardownTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/something/new", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
// Assertions
}
```
## Backward Compatibility
The root `server.go` file maintains backward compatibility by re-exporting types and providing a compatibility wrapper for `NewServer()`. Existing code continues to work without changes.
## Best Practices
1. **Handler naming**: Use `handle` prefix (e.g., `handleGetRadios`)
2. **Context parameter**: Use short name `c` for `echo.Context`
3. **Error handling**: Always use `s.apiError()` for consistent error responses
4. **Query parameters**: Use helper functions like `getQueryInt()` for parsing
5. **Testing**: Write tests for both success and error cases
6. **Documentation**: Add godoc comments for exported functions
## Performance Considerations
- Use in-memory SQLite for tests (fast and isolated)
- Benchmark critical endpoints
- Use Echo's built-in middleware for CORS, logging, etc.
- Consider adding route-specific middleware for caching or rate limiting
## Future Enhancements
Potential improvements to consider:
- [ ] Add middleware for authentication/authorization
- [ ] Implement request validation using a schema library
- [ ] Add structured logging with trace IDs
- [ ] Implement health check and metrics endpoints
- [ ] Add integration tests with a real PostgreSQL instance
- [ ] Consider adding OpenAPI/Swagger documentation
- [ ] Add graceful shutdown handling

30
server/error.go Normal file
View File

@@ -0,0 +1,30 @@
package server
import (
"net/http"
"os"
"github.com/labstack/echo/v4"
)
// apiError handles API errors consistently
func (s *Server) apiError(c echo.Context, err error, status ...int) error {
s.logger.Warnf("server: error serving %s %s: %v", c.Request().Method, c.Request().URL.Path, err)
if len(status) > 0 {
return c.JSON(status[0], map[string]any{
"error": err.Error(),
})
}
switch {
case os.IsNotExist(err):
return c.JSON(http.StatusNotFound, nil)
case os.IsPermission(err):
return c.JSON(http.StatusUnauthorized, nil)
default:
return c.JSON(http.StatusInternalServerError, map[string]any{
"error": err.Error(),
})
}
}

65
server/handlers_aprs.go Normal file
View File

@@ -0,0 +1,65 @@
package server
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"git.maze.io/ham/hamview/schema"
)
// handleGetAPRSPackets returns APRS packets based on filter criteria.
//
// Endpoint: GET /api/v1/aprs/packets
//
// Query Parameters (evaluated in order):
// - src: APRS source callsign (case-insensitive)
// - dst: APRS destination callsign (case-insensitive)
// - limit: Maximum number of recent packets (default: 100)
// - (no parameters): Returns the 100 most recent packets
//
// Response: 200 OK
//
// []APRSPacket - Array of APRS packet objects
//
// Response: 500 Internal Server Error
//
// ErrorResponse - Error retrieving packets
//
// Example Requests:
//
// GET /api/v1/aprs/packets
// GET /api/v1/aprs/packets?src=OE1ABC
// GET /api/v1/aprs/packets?dst=APRS
// GET /api/v1/aprs/packets?limit=200
func (s *Server) handleGetAPRSPackets(c echo.Context) error {
var (
ctx = c.Request().Context()
packets []*schema.APRSPacket
err error
)
if source := c.QueryParam("src"); source != "" {
packets, err = schema.GetAPRSPacketsBySource(ctx, source)
} else if destination := c.QueryParam("dst"); destination != "" {
packets, err = schema.GetAPRSPacketsByDestination(ctx, destination)
} else {
limit := 100
if value := c.QueryParam("limit"); value != "" {
if limit, err = strconv.Atoi(value); err != nil {
return s.apiError(c, err)
}
}
if limit <= 0 {
limit = 100
}
packets, err = schema.GetAPRSPackets(ctx, limit)
}
if err != nil {
return s.apiError(c, err)
}
return c.JSON(http.StatusOK, packets)
}

331
server/handlers_meshcore.go Normal file
View File

@@ -0,0 +1,331 @@
package server
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"git.maze.io/go/ham/protocol/meshcore"
"git.maze.io/ham/hamview/schema"
)
// handleGetMeshCore returns aggregated statistics for the MeshCore network.
//
// Endpoint: GET /api/v1/meshcore
//
// Response: 200 OK
//
// MeshCoreStats - Network statistics including message counts and packet timeline
//
// Response: 500 Internal Server Error
//
// ErrorResponse - Error retrieving statistics
//
// Example Request:
//
// GET /api/v1/meshcore
//
// Example Response:
//
// {
// "messages": 150234,
// "nodes": 127,
// "receivers": 8,
// "packets": {
// "timestamps": [1709650800, 1709654400, 1709658000],
// "packets": [142, 203, 178]
// }
// }
func (s *Server) handleGetMeshCore(c echo.Context) error {
stats, err := schema.GetMeshCoreStats(c.Request().Context())
if err != nil {
return s.apiError(c, err)
}
return c.JSON(http.StatusOK, stats)
}
// handleGetMeshCoreGroups returns a list of public MeshCore groups/channels.
//
// Endpoint: GET /api/v1/meshcore/groups
//
// Response: 200 OK
//
// []MeshCoreGroup - Array of public group objects
//
// Response: 500 Internal Server Error
//
// ErrorResponse - Error retrieving groups
//
// Example Request:
//
// GET /api/v1/meshcore/groups
//
// Example Response:
//
// [
// {
// "id": 5,
// "name": "General Chat",
// "secret": "0123456789abcdef0123456789abcdef"
// },
// {
// "id": 7,
// "name": "Emergency",
// "secret": "fedcba9876543210fedcba9876543210"
// }
// ]
func (s *Server) handleGetMeshCoreGroups(c echo.Context) error {
groups, err := schema.GetMeshCoreGroups(c.Request().Context())
if err != nil {
return s.apiError(c, err)
}
return c.JSON(http.StatusOK, groups)
}
// handleGetMeshCoreNodes returns a list of MeshCore network nodes.
//
// Endpoint: GET /api/v1/meshcore/nodes
//
// Query Parameters:
// - type (optional): Filter by node type
// - "chat" - Chat nodes
// - "room" - Room nodes
// - "sensor" - Sensor nodes
// - "repeater" - Repeater nodes (default)
//
// Response: 200 OK
//
// []MeshCoreNode - Array of node objects, sorted by last_heard_at (descending)
//
// Response: 500 Internal Server Error
//
// ErrorResponse - Error retrieving nodes
//
// Example Request:
//
// GET /api/v1/meshcore/nodes
// GET /api/v1/meshcore/nodes?type=chat
//
// Example Response:
//
// [
// {
// "id": 42,
// "packet": [],
// "name": "NODE-CHARLIE",
// "type": 0,
// "prefix": "mc",
// "public_key": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
// "first_heard_at": "2026-01-15T08:00:00Z",
// "last_heard_at": "2026-03-05T14:25:00Z",
// "last_latitude": 52.3667,
// "last_longitude": 4.8945
// }
// ]
func (s *Server) handleGetMeshCoreNodes(c echo.Context) error {
var (
nodes []*schema.MeshCoreNode
err error
)
if kind := c.QueryParam("type"); kind != "" {
switch kind {
case "chat":
nodes, err = schema.GetMeshCoreNodesByType(c.Request().Context(), meshcore.Chat)
case "room":
nodes, err = schema.GetMeshCoreNodesByType(c.Request().Context(), meshcore.Room)
case "sensor":
nodes, err = schema.GetMeshCoreNodesByType(c.Request().Context(), meshcore.Sensor)
case "repeater":
fallthrough
default:
nodes, err = schema.GetMeshCoreNodesByType(c.Request().Context(), meshcore.Repeater)
}
} else {
nodes, err = schema.GetMeshCoreNodes(c.Request().Context())
}
if err != nil {
return s.apiError(c, err)
}
return c.JSON(http.StatusOK, nodes)
}
// handleGetMeshCoreNodesCloseTo returns nodes within a specified radius of a reference node.
//
// Endpoint: GET /api/v1/meshcore/nodes/close-to/:publickey
//
// Path Parameters:
// - publickey: Hex-encoded Ed25519 public key (64 characters) of the reference node
//
// Query Parameters:
// - radius (optional): Search radius in meters (default: 25000 = 25 km)
//
// Response: 200 OK
//
// NodesCloseToResponse - Object containing the reference node and nearby nodes
// {
// "node": MeshCoreNode, // The reference node
// "nodes": []MeshCoreNode // Nearby nodes, sorted by distance (ascending)
// // Each node has the "distance" field populated (in meters)
// }
//
// Response: 404 Not Found
//
// null - Node not found or has no location data
//
// Response: 400 Bad Request
//
// ErrorResponse - Invalid radius parameter
//
// Response: 500 Internal Server Error
//
// ErrorResponse - Error performing proximity query
//
// Example Request:
//
// GET /api/v1/meshcore/nodes/close-to/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
// GET /api/v1/meshcore/nodes/close-to/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef?radius=50000
//
// Example Response:
//
// {
// "node": {
// "id": 42,
// "name": "NODE-CHARLIE",
// "type": 0,
// "prefix": "mc",
// "public_key": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
// "first_heard_at": "2026-01-15T08:00:00Z",
// "last_heard_at": "2026-03-05T14:25:00Z",
// "last_latitude": 52.3667,
// "last_longitude": 4.8945,
// "distance": 0
// },
// "nodes": [
// {
// "id": 43,
// "name": "NODE-DELTA",
// "type": 0,
// "prefix": "mc",
// "public_key": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
// "first_heard_at": "2026-02-01T10:00:00Z",
// "last_heard_at": "2026-03-05T14:20:00Z",
// "last_latitude": 52.3700,
// "last_longitude": 4.9000,
// "distance": 450.5
// }
// ]
// }
func (s *Server) handleGetMeshCoreNodesCloseTo(c echo.Context) error {
var radius float64
if value := c.QueryParam("radius"); value != "" {
var err error
if radius, err = strconv.ParseFloat(value, 64); err != nil {
return s.apiError(c, err)
}
}
if radius <= 0 {
radius = 25000.0 // 25 km
}
node, nodes, err := schema.GetMeshCoreNodesCloseTo(c.Request().Context(), c.Param("publickey"), radius)
if err != nil {
return s.apiError(c, err)
}
return c.JSON(http.StatusOK, map[string]any{
"node": node,
"nodes": nodes,
})
}
// handleGetMeshCorePackets returns MeshCore packets based on various filter criteria.
//
// Endpoint: GET /api/v1/meshcore/packets
//
// Query Parameters (mutually exclusive, evaluated in order):
// - hash: 16-character hex hash - Returns all packets with this hash
// - type: Integer payload type - Returns packets of this type
// - Can be combined with channel_hash for group messages
// - channel_hash: 2-character channel hash (requires type parameter)
// - (no parameters): Returns the 100 most recent packets
//
// Response: 200 OK
//
// []MeshCorePacket - Array of packet objects
//
// Response: 400 Bad Request
//
// ErrorResponse - Invalid hash format or payload type
//
// Response: 500 Internal Server Error
//
// ErrorResponse - Error retrieving packets
//
// Example Requests:
//
// GET /api/v1/meshcore/packets
// GET /api/v1/meshcore/packets?hash=a1b2c3d4e5f67890
// GET /api/v1/meshcore/packets?type=3
// GET /api/v1/meshcore/packets?type=3&channel_hash=ab
//
// Example Response:
//
// [
// {
// "id": 12345,
// "radio_id": 1,
// "radio": null,
// "snr": 8.5,
// "rssi": -95,
// "version": 1,
// "route_type": 0,
// "payload_type": 3,
// "hash": "a1b2c3d4e5f67890",
// "path": "AQIDBA==",
// "payload": "SGVsbG8gV29ybGQ=",
// "raw": "AQIDBAUGBwg=",
// "parsed": {"text": "Hello World"},
// "channel_hash": "ab",
// "received_at": "2026-03-05T14:30:00Z"
// }
// ]
//
// Payload Types:
// - 0: Ping
// - 1: Node announcement
// - 2: Direct text message
// - 3: Group text message
// - 4: Position update
// - (other types may be defined by the protocol)
func (s *Server) handleGetMeshCorePackets(c echo.Context) error {
var (
ctx = c.Request().Context()
packets []*schema.MeshCorePacket
err error
)
if hash := c.QueryParam("hash"); hash != "" {
packets, err = schema.GetMeshCorePacketsByHash(ctx, hash)
} else if kind := c.QueryParam("type"); kind != "" {
var payloadType int
if payloadType, err = strconv.Atoi(kind); err == nil {
if hash := c.QueryParam("channel_hash"); hash != "" {
packets, err = schema.GetMeshCorePacketsByChannelHash(ctx, hash)
} else {
packets, err = schema.GetMeshCorePacketsByPayloadType(ctx, meshcore.PayloadType(payloadType))
}
}
} else {
packets, err = schema.GetMeshCorePackets(ctx, 100)
}
if err != nil {
return s.apiError(c, err)
}
return c.JSON(http.StatusOK, packets)
}

68
server/handlers_radios.go Normal file
View File

@@ -0,0 +1,68 @@
package server
import (
"net/http"
"github.com/labstack/echo/v4"
"git.maze.io/ham/hamview/schema"
)
// handleGetRadios returns a list of radio stations/receivers.
//
// Endpoint: GET /api/v1/radios
// Endpoint: GET /api/v1/radios/:protocol
//
// Path Parameters:
// - protocol (optional): Filter by protocol name (e.g., "meshcore", "aprs")
//
// Response: 200 OK
//
// []Radio - Array of radio objects
//
// Response: 500 Internal Server Error
//
// ErrorResponse - Error retrieving radios
//
// Example Request:
//
// GET /api/v1/radios
// GET /api/v1/radios/meshcore
//
// Example Response:
//
// [
// {
// "id": 1,
// "name": "Station-Alpha",
// "is_online": true,
// "manufacturer": "Heltec",
// "device": "WiFi LoRa 32 V3",
// "modulation": "LoRa",
// "protocol": "meshcore",
// "latitude": 52.3667,
// "longitude": 4.8945,
// "frequency": 868.1,
// "bandwidth": 125.0,
// "created_at": "2026-01-01T12:00:00Z",
// "updated_at": "2026-03-05T10:30:00Z"
// }
// ]
func (s *Server) handleGetRadios(c echo.Context) error {
var (
radios []*schema.Radio
err error
)
if protocol := c.Param("protocol"); protocol != "" {
radios, err = schema.GetRadiosByProtocol(c.Request().Context(), protocol)
} else {
radios, err = schema.GetRadios(c.Request().Context())
}
if err != nil {
return s.apiError(c, err)
}
return c.JSON(http.StatusOK, radios)
}

48
server/router.go Normal file
View File

@@ -0,0 +1,48 @@
package server
import (
"github.com/labstack/echo/v4"
)
// setupRoutes configures all API routes using nested routers
func setupRoutes(s *Server, e *echo.Echo) {
// API v1 group
api := e.Group("/api/v1")
setupRadiosRoutes(s, api.Group("/radios"))
setupMeshCoreRoutes(s, api.Group("/meshcore"))
setupAPRSRoutes(s, api.Group("/aprs"))
}
// setupRadiosRoutes configures routes for radio endpoints
func setupRadiosRoutes(s *Server, root *echo.Group) {
root.GET("", s.handleGetRadios)
root.GET("/:protocol", s.handleGetRadios)
}
// setupMeshCoreRoutes configures routes for MeshCore endpoints
func setupMeshCoreRoutes(s *Server, root *echo.Group) {
// Stats endpoint
root.GET("", s.handleGetMeshCore)
// Groups endpoints
root.GET("/groups", s.handleGetMeshCoreGroups)
// Nodes endpoints
nodes := root.Group("/nodes")
nodes.GET("", s.handleGetMeshCoreNodes)
nodes.GET("/close-to/:publickey", s.handleGetMeshCoreNodesCloseTo)
// Packets endpoint
root.GET("/packets", s.handleGetMeshCorePackets)
// Commented out routes from original
// meshcore.GET("/path/:origin/:path", s.handleGetMeshCorePath)
// meshcore.GET("/sources", s.handleGetMeshCoreSources)
}
// setupAPRSRoutes configures routes for APRS endpoints
func setupAPRSRoutes(s *Server, root *echo.Group) {
// Packets endpoint
root.GET("/packets", s.handleGetAPRSPackets)
}

99
server/server.go Normal file
View File

@@ -0,0 +1,99 @@
package server
import (
"fmt"
"net"
echologrus "github.com/cemkiy/echo-logrus"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/sirupsen/logrus"
"git.maze.io/ham/hamview/schema"
)
const DefaultServerListen = ":8073"
// Config holds the server configuration
type Config struct {
Listen string `yaml:"listen"`
}
// DatabaseConfig holds database configuration
type DatabaseConfig struct {
Type string `yaml:"type"`
Conf string `yaml:"conf"`
}
// Server represents the HTTP server
type Server struct {
listen string
listenAddr *net.TCPAddr
logger *logrus.Logger
}
// New creates a new Server instance
func New(serverConfig *Config, databaseConfig *DatabaseConfig, logger *logrus.Logger) (*Server, error) {
if serverConfig.Listen == "" {
serverConfig.Listen = DefaultServerListen
}
listenAddr, err := net.ResolveTCPAddr("tcp", serverConfig.Listen)
if err != nil {
return nil, fmt.Errorf("hamview: invalid listen address %q: %v", serverConfig.Listen, err)
}
if err = schema.Open(databaseConfig.Type, databaseConfig.Conf); err != nil {
return nil, err
}
return &Server{
listen: serverConfig.Listen,
listenAddr: listenAddr,
logger: logger,
}, nil
}
// Run starts the HTTP server
func (s *Server) Run() error {
echologrus.Logger = s.logger
e := echo.New()
e.HideBanner = true
e.Logger = echologrus.GetEchoLogger()
e.Use(echologrus.Hook())
e.Use(middleware.RequestLogger())
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"},
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept},
}))
// Static files
e.File("/", "./dashboard/dist/index.html")
e.Static("/asset/", "./asset")
e.Static("/assets/", "./dashboard/dist/assets")
// Setup API routes
setupRoutes(s, e)
if s.listenAddr.IP == nil || s.listenAddr.IP.Equal(net.ParseIP("0.0.0.0")) || s.listenAddr.IP.Equal(net.ParseIP("::")) {
s.logger.Infof("server: listening on http://127.0.0.1:%d", s.listenAddr.Port)
} else {
s.logger.Infof("server: listening on http://%s:%d", s.listenAddr.IP, s.listenAddr.Port)
}
return e.Start(s.listen)
}
// NewTestServer creates a server for testing with an in-memory SQLite database
func NewTestServer(logger *logrus.Logger) (*Server, error) {
serverConfig := &Config{
Listen: "127.0.0.1:0",
}
databaseConfig := &DatabaseConfig{
Type: "sqlite3",
Conf: ":memory:",
}
return New(serverConfig, databaseConfig, logger)
}

370
server/server_test.go Normal file
View File

@@ -0,0 +1,370 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
"github.com/sirupsen/logrus"
"git.maze.io/ham/hamview/schema"
)
// setupTestServer creates an Echo instance with routes configured and an in-memory database
func setupTestServer(t *testing.T) (*echo.Echo, *Server) {
t.Helper()
logger := logrus.New()
logger.SetLevel(logrus.WarnLevel)
// Initialize in-memory SQLite database
if err := schema.Open("sqlite3", ":memory:"); err != nil {
t.Fatalf("Failed to open test database: %v", err)
}
// Create server instance
server := &Server{
listen: "127.0.0.1:0",
logger: logger,
}
// Setup Echo with routes
e := echo.New()
e.HideBanner = true
setupRoutes(server, e)
return e, server
}
// teardownTestServer cleans up test resources
func teardownTestServer(t *testing.T) {
t.Helper()
// Close database connection if needed
// schema.Close() // Add this method to schema package if needed
}
// TestRadiosEndpoints tests all radio-related endpoints
func TestRadiosEndpoints(t *testing.T) {
e, _ := setupTestServer(t)
defer teardownTestServer(t)
t.Run("GET /api/v1/radios", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/radios", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code)
}
var radios []*schema.Radio
if err := json.Unmarshal(rec.Body.Bytes(), &radios); err != nil {
t.Errorf("Failed to unmarshal response: %v", err)
}
})
t.Run("GET /api/v1/radios/:protocol", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/radios/aprs", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code)
}
var radios []*schema.Radio
if err := json.Unmarshal(rec.Body.Bytes(), &radios); err != nil {
t.Errorf("Failed to unmarshal response: %v", err)
}
})
}
// TestAPRSEndpoints tests all APRS-related endpoints
func TestAPRSEndpoints(t *testing.T) {
e, _ := setupTestServer(t)
defer teardownTestServer(t)
t.Run("GET /api/v1/aprs/packets", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/aprs/packets", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code)
}
var packets []*schema.APRSPacket
if err := json.Unmarshal(rec.Body.Bytes(), &packets); err != nil {
t.Errorf("Failed to unmarshal response: %v", err)
}
})
t.Run("GET /api/v1/aprs/packets filters", func(t *testing.T) {
testCases := []string{
"?src=OE1ABC",
"?dst=APRS",
"?limit=200",
}
for _, query := range testCases {
req := httptest.NewRequest(http.MethodGet, "/api/v1/aprs/packets"+query, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK && rec.Code != http.StatusInternalServerError {
t.Errorf("Expected status %d or %d, got %d", http.StatusOK, http.StatusInternalServerError, rec.Code)
}
}
})
}
// TestMeshCoreEndpoints tests all MeshCore-related endpoints
func TestMeshCoreEndpoints(t *testing.T) {
e, _ := setupTestServer(t)
defer teardownTestServer(t)
t.Run("GET /api/v1/meshcore", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/meshcore", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code)
}
var stats map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &stats); err != nil {
t.Errorf("Failed to unmarshal response: %v", err)
}
})
t.Run("GET /api/v1/meshcore/groups", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/meshcore/groups", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code)
}
var groups []any
if err := json.Unmarshal(rec.Body.Bytes(), &groups); err != nil {
t.Errorf("Failed to unmarshal response: %v", err)
}
})
t.Run("GET /api/v1/meshcore/nodes", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/meshcore/nodes", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code)
}
var nodes []*schema.MeshCoreNode
if err := json.Unmarshal(rec.Body.Bytes(), &nodes); err != nil {
t.Errorf("Failed to unmarshal response: %v", err)
}
})
t.Run("GET /api/v1/meshcore/nodes?type=chat", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/meshcore/nodes?type=chat", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code)
}
var nodes []*schema.MeshCoreNode
if err := json.Unmarshal(rec.Body.Bytes(), &nodes); err != nil {
t.Errorf("Failed to unmarshal response: %v", err)
}
})
t.Run("GET /api/v1/meshcore/packets", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/meshcore/packets", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code)
}
var packets []*schema.MeshCorePacket
if err := json.Unmarshal(rec.Body.Bytes(), &packets); err != nil {
t.Errorf("Failed to unmarshal response: %v", err)
}
})
}
// TestMeshCoreNodesCloseTo tests the close-to endpoint
func TestMeshCoreNodesCloseTo(t *testing.T) {
e, _ := setupTestServer(t)
defer teardownTestServer(t)
// First, insert a test node if needed
// This is a placeholder - you'll need actual test data setup
t.Run("GET /api/v1/meshcore/nodes/close-to/:publickey", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/meshcore/nodes/close-to/test_key", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
// May return 404 or error if no data exists, which is fine for skeleton test
if rec.Code != http.StatusOK && rec.Code != http.StatusNotFound && rec.Code != http.StatusInternalServerError {
t.Errorf("Expected status %d, %d, or %d, got %d", http.StatusOK, http.StatusNotFound, http.StatusInternalServerError, rec.Code)
}
})
t.Run("GET /api/v1/meshcore/nodes/close-to/:publickey with radius", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/meshcore/nodes/close-to/test_key?radius=50000", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
// May return 404 or error if no data exists, which is fine for skeleton test
if rec.Code != http.StatusOK && rec.Code != http.StatusNotFound && rec.Code != http.StatusInternalServerError {
t.Errorf("Expected status %d, %d, or %d, got %d", http.StatusOK, http.StatusNotFound, http.StatusInternalServerError, rec.Code)
}
})
}
// TestMeshCorePacketsWithFilters tests packet endpoint with various query parameters
func TestMeshCorePacketsWithFilters(t *testing.T) {
e, _ := setupTestServer(t)
defer teardownTestServer(t)
testCases := []struct {
name string
queryParam string
}{
{"With hash", "?hash=test_hash"},
{"With type", "?type=1"},
{"With type and channel_hash", "?type=1&channel_hash=test_channel"},
{"No filters", ""},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/meshcore/packets"+tc.queryParam, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK && rec.Code != http.StatusInternalServerError {
t.Errorf("Expected status %d or %d, got %d", http.StatusOK, http.StatusInternalServerError, rec.Code)
}
})
}
}
// BenchmarkGetRadios benchmarks the radios endpoint
func BenchmarkGetRadios(b *testing.B) {
logger := logrus.New()
logger.SetLevel(logrus.ErrorLevel)
if err := schema.Open("sqlite3", ":memory:"); err != nil {
b.Fatalf("Failed to open test database: %v", err)
}
server := &Server{
listen: "127.0.0.1:0",
logger: logger,
}
e := echo.New()
e.HideBanner = true
setupRoutes(server, e)
req := httptest.NewRequest(http.MethodGet, "/api/v1/radios", nil)
b.ResetTimer()
for i := 0; i < b.N; i++ {
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
}
}
// BenchmarkGetMeshCorePackets benchmarks the packets endpoint
func BenchmarkGetMeshCorePackets(b *testing.B) {
logger := logrus.New()
logger.SetLevel(logrus.ErrorLevel)
if err := schema.Open("sqlite3", ":memory:"); err != nil {
b.Fatalf("Failed to open test database: %v", err)
}
server := &Server{
listen: "127.0.0.1:0",
logger: logger,
}
e := echo.New()
e.HideBanner = true
setupRoutes(server, e)
req := httptest.NewRequest(http.MethodGet, "/api/v1/meshcore/packets", nil)
b.ResetTimer()
for i := 0; i < b.N; i++ {
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
}
}
// Example test showing how to populate test data
func TestWithTestData(t *testing.T) {
e, _ := setupTestServer(t)
defer teardownTestServer(t)
// Example: Insert test data
ctx := context.Background()
// Example radio insertion (adapt based on your schema package)
// radio := &schema.Radio{
// ID: "test-radio-1",
// Protocol: "aprs",
// Name: "Test Radio",
// }
// if err := schema.InsertRadio(ctx, radio); err != nil {
// t.Fatalf("Failed to insert test radio: %v", err)
// }
t.Run("GET /api/v1/radios with data", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/radios", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code)
}
var radios []*schema.Radio
if err := json.Unmarshal(rec.Body.Bytes(), &radios); err != nil {
t.Errorf("Failed to unmarshal response: %v", err)
}
// Add assertions about the data
// if len(radios) != 1 {
// t.Errorf("Expected 1 radio, got %d", len(radios))
// }
})
_ = ctx // Use ctx to avoid unused variable error
}

273
server/types.go Normal file
View File

@@ -0,0 +1,273 @@
// Package server provides HTTP API endpoints for HAMView
//
// # API Documentation
//
// This package exposes REST API endpoints for querying amateur radio data.
// All endpoints return JSON responses and use standard HTTP status codes.
//
// # Base URL
//
// All API endpoints are prefixed with `/api/v1`
//
// # Response Format
//
// Success responses return the requested data with HTTP 200 OK status.
// Error responses return JSON with the following structure:
//
// {
// "error": "error message description"
// }
//
// # HTTP Status Codes
//
// - 200 OK: Request successful
// - 400 Bad Request: Invalid query parameters
// - 404 Not Found: Resource not found
// - 500 Internal Server Error: Server error
//
// # Data Types
//
// ## Radio
//
// Represents an amateur radio receiver/station.
//
// JSON Schema:
//
// {
// "id": integer, // Unique identifier
// "name": string, // Station name (unique)
// "is_online": boolean, // Current online status
// "manufacturer": string, // Hardware manufacturer
// "device": string | null, // Device model name
// "firmware_version": string | null, // Firmware version string
// "firmware_date": string | null, // Firmware date (ISO 8601)
// "antenna": string | null, // Antenna description
// "modulation": string, // Modulation type (e.g., "LoRa")
// "protocol": string, // Protocol name (e.g., "meshcore", "aprs")
// "latitude": number | null, // Latitude in decimal degrees
// "longitude": number | null, // Longitude in decimal degrees
// "altitude": number | null, // Altitude in meters
// "frequency": number, // Frequency in Hz
// "bandwidth": number, // Bandwidth in Hz
// "power": number | null, // Transmit power in dBm
// "gain": number | null, // Antenna gain in dBi
// "lora_sf": integer | null, // LoRa spreading factor (7-12)
// "lora_cr": integer | null, // LoRa coding rate (5-8)
// "extra": object | null, // Additional metadata
// "created_at": string, // Creation timestamp (ISO 8601)
// "updated_at": string // Last update timestamp (ISO 8601)
// }
//
// Example:
//
// {
// "id": 1,
// "name": "Station-Alpha",
// "is_online": true,
// "manufacturer": "Heltec",
// "device": "WiFi LoRa 32 V3",
// "firmware_version": "1.0.0",
// "firmware_date": "2026-01-15T00:00:00Z",
// "antenna": "868MHz 3dBi",
// "modulation": "LoRa",
// "protocol": "meshcore",
// "latitude": 52.3667,
// "longitude": 4.8945,
// "altitude": 5.0,
// "frequency": 868.1,
// "bandwidth": 125.0,
// "power": 14.0,
// "gain": 3.0,
// "lora_sf": 7,
// "lora_cr": 5,
// "extra": null,
// "created_at": "2026-01-01T12:00:00Z",
// "updated_at": "2026-03-05T10:30:00Z"
// }
//
// ## MeshCorePacket
//
// Represents a received MeshCore protocol packet.
//
// JSON Schema:
//
// {
// "id": integer, // Unique packet identifier
// "radio_id": integer, // Radio that received this packet
// "radio": Radio | null, // Radio object (when populated)
// "snr": number, // Signal-to-noise ratio in dB
// "rssi": integer, // Received signal strength in dBm
// "version": integer, // Protocol version
// "route_type": integer, // Routing type (0=broadcast, 1=unicast, etc.)
// "payload_type": integer, // Payload type identifier
// "hash": string, // 16-character hex hash
// "path": string, // Base64-encoded routing path
// "payload": string, // Base64-encoded payload data
// "raw": string, // Base64-encoded raw packet
// "parsed": object | null, // Parsed payload (type depends on payload_type)
// "channel_hash": string, // 2-character channel hash (for group messages)
// "received_at": string // Reception timestamp (ISO 8601)
// }
//
// Example:
//
// {
// "id": 12345,
// "radio_id": 1,
// "radio": null,
// "snr": 8.5,
// "rssi": -95,
// "version": 1,
// "route_type": 0,
// "payload_type": 3,
// "hash": "a1b2c3d4e5f67890",
// "path": "AQIDBA==",
// "payload": "SGVsbG8gV29ybGQ=",
// "raw": "AQIDBAUGBwg=",
// "parsed": {"text": "Hello World"},
// "channel_hash": "ab",
// "received_at": "2026-03-05T14:30:00Z"
// }
//
// ## MeshCoreNode
//
// Represents a MeshCore network node.
//
// JSON Schema:
//
// {
// "id": integer, // Unique node identifier
// "packet": MeshCorePacket[], // Associated packets
// "name": string, // Node name/callsign
// "type": integer, // Node type (0=repeater, 1=chat, 2=room, 3=sensor)
// "prefix": string, // 2-character network prefix
// "public_key": string, // Hex-encoded Ed25519 public key (64 chars)
// "first_heard_at": string, // First heard timestamp (ISO 8601)
// "last_heard_at": string, // Last heard timestamp (ISO 8601)
// "last_latitude": number | null, // Last known latitude
// "last_longitude": number | null, // Last known longitude
// "distance": number // Distance in meters (only in proximity queries)
// }
//
// Example:
//
// {
// "id": 42,
// "packet": [],
// "name": "NODE-CHARLIE",
// "type": 0,
// "prefix": "mc",
// "public_key": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
// "first_heard_at": "2026-01-15T08:00:00Z",
// "last_heard_at": "2026-03-05T14:25:00Z",
// "last_latitude": 52.3667,
// "last_longitude": 4.8945,
// "distance": 0
// }
//
// ## MeshCoreGroup
//
// Represents a MeshCore group/channel.
//
// JSON Schema:
//
// {
// "id": integer, // Unique group identifier
// "name": string, // Group name (max 32 chars)
// "secret": string // Group secret/key (32 chars)
// }
//
// Example:
//
// {
// "id": 5,
// "name": "General Chat",
// "secret": "0123456789abcdef0123456789abcdef"
// }
//
// ## MeshCoreStats
//
// Aggregated statistics for the MeshCore network.
//
// JSON Schema:
//
// {
// "messages": integer, // Total message count
// "nodes": integer, // Total node count
// "receivers": integer, // Total receiver count
// "packets": {
// "timestamps": integer[], // Unix timestamps
// "packets": integer[] // Packet counts per timestamp
// }
// }
//
// Example:
//
// {
// "messages": 150234,
// "nodes": 127,
// "receivers": 8,
// "packets": {
// "timestamps": [1709650800, 1709654400, 1709658000],
// "packets": [142, 203, 178]
// }
// }
//
// ## NodesCloseToResponse
//
// Response for nodes proximity query.
//
// JSON Schema:
//
// {
// "node": MeshCoreNode, // The reference node
// "nodes": MeshCoreNode[] // Nearby nodes (with distance field populated)
// }
//
// Example:
//
// {
// "node": {
// "id": 42,
// "name": "NODE-CHARLIE",
// "type": 0,
// "prefix": "mc",
// "public_key": "1234...abcdef",
// "first_heard_at": "2026-01-15T08:00:00Z",
// "last_heard_at": "2026-03-05T14:25:00Z",
// "last_latitude": 52.3667,
// "last_longitude": 4.8945,
// "distance": 0
// },
// "nodes": [
// {
// "id": 43,
// "name": "NODE-DELTA",
// "type": 0,
// "prefix": "mc",
// "public_key": "abcd...5678",
// "first_heard_at": "2026-02-01T10:00:00Z",
// "last_heard_at": "2026-03-05T14:20:00Z",
// "last_latitude": 52.3700,
// "last_longitude": 4.9000,
// "distance": 450.5
// }
// ]
// }
//
// ## ErrorResponse
//
// Standard error response format.
//
// JSON Schema:
//
// {
// "error": string // Human-readable error message
// }
//
// Example:
//
// {
// "error": "invalid hash"
// }
package server

107
sql.go
View File

@@ -1,5 +1,31 @@
package hamview
import (
"database/sql/driver"
"encoding/json"
"fmt"
)
type JSONB map[string]any
func (v JSONB) Value() (driver.Value, error) {
return json.Marshal(v)
}
func (v *JSONB) Scan(value any) error {
if value == nil {
*v = nil
return nil
}
b, ok := value.([]byte)
if !ok {
return fmt.Errorf("type assertion to []byte failed, got %T", value)
}
return json.Unmarshal(b, &v)
}
/*
const (
sqlCreateRadio = `
CREATE TABLE IF NOT EXISTS radio (
@@ -16,20 +42,56 @@ const (
latitude NUMERIC(10, 8), -- GPS latitude in decimal degrees
longitude NUMERIC(11, 8), -- GPS longitude in decimal degrees
altitude REAL, -- Altitude in meters
frequency DOUBLE PRECISION,
bandwidth DOUBLE PRECISION,
frequency DOUBLE PRECISION NOT NULL,
bandwidth DOUBLE PRECISION NOT NULL,
rx_frequency DOUBLE PRECISION,
tx_frequency DOUBLE PRECISION,
power REAL,
gain REAL,
lora_sf SMALLINT,
lora_cr SMALLINT,
extra JSONB
extra JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`
sqlIndexRadioName = `CREATE INDEX IF NOT EXISTS idx_radio_name ON radio(name);`
sqlIndexRadioProtocol = `CREATE INDEX IF NOT EXISTS idx_radio_protocol ON radio(protocol);`
sqlGeometryRadioPosition = `SELECT AddGeometryColumn('public', 'radio', 'position', 4326, 'POINT', 2);`
sqlSelectRadios = `
SELECT
name,
is_online,
device,
manufacturer,
firmware_date,
firmware_version,
antenna,
modulation,
protocol,
latitude,
longitude,
altitude,
frequency,
bandwidth,
rx_frequency,
tx_frequency,
power,
gain,
lora_sf,
lora_cr,
extra
FROM radio
WHERE
protocol LIKE $1
AND (
is_online
OR
updated_at BETWEEN NOW() - INTERVAL '24 HOURS' AND NOW()
)
ORDER BY
protocol, name
`
)
const (
@@ -74,6 +136,38 @@ const (
`
sqlIndexMeshCorePacketHash = `CREATE INDEX IF NOT EXISTS idx_meshcore_packet_hash ON meshcore_packet(hash);`
sqlIndexMeshCorePacketPayloadType = `CREATE INDEX IF NOT EXISTS idx_meshcore_packet_payload_type ON meshcore_packet(payload_type);`
sqlSelectMeshCoreTextStats = `
SELECT
COUNT(id)
FROM
meshcore_packet
WHERE
received_at BETWEEN NOW() - INTERVAL '24 HOURS' AND NOW()
AND
payload_type IN (2, 5)
`
sqlSelectMeshCorePacketStatsOverTime = `
WITH time_buckets AS (
SELECT
time_bucket,
COUNT(*) as packet_count
FROM (
SELECT
date_trunc('minute', received_at) - INTERVAL '1 minute' * (EXTRACT(minute FROM received_at)::int % 5) AS time_bucket
FROM
meshcore_packet
WHERE
received_at >= NOW() - INTERVAL '24 hours'
) AS bucketed
GROUP BY time_bucket
ORDER BY time_bucket DESC
)
SELECT
time_bucket,
packet_count
FROM
time_buckets;
`
)
const (
@@ -105,6 +199,12 @@ const (
ELSE NULL
END
) STORED;`
sqlSelectMeshCoreNodeStats = `
SELECT
COUNT(id)
FROM
meshcore_node
`
)
const (
@@ -234,3 +334,4 @@ const (
);
`
)
*/

24
ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

59
ui/AGENTS.md Normal file
View File

@@ -0,0 +1,59 @@
# AGENTS
This document provides context for AI agents working on this codebase.
## Project Overview
HAMView is an online Amateur Radio digital protocol live viewer. It features:
- Displaying online radio receivers in near real-time
- Streaming of popular Amateur Radio protocols such as APRS, MeshCore, etc.
- A live packet stream for each of the protocols
- Packet inspection
## Tech Stack
Used technologies:
- **Framework**: React 19 with TypeScript
- **Build Tool**: Vite 7
- **User Interface**: React-Bootstrap with Bootstrap version 5
- **Code Editor**: Visual Studio Code
- **Backend**: Go with labstack echo router
- **Libraries used**: Axios for API requests, mqtt.js for streaming
- **Testing**: use `npm run build`
Relevant documents:
- API documentation is in `../server`
## Testing Requirements
**Always run tests before completing a task.**
Run `npm run build`.
## Coding Guidelines
### General
- Prefer ESM imports (`import`/`export`)
- Use builtins from React, React-Boostrap where possible
- Follow existing code patterns in the code base
- Never make changes outside of the `ui` directory, if you think this is necessary prompt me for approval.
### Styling
- Use React-Bootstrap components where appropriate
- Follow existing CSS patterns
- Add reusable style elements to the `src/App.scss`
- Order imports:
- React import first; then any react plugin
- Third-party libraries;
- Services;
- Local types imports;
- Local imports;
- Stylesheets
- Long import statements (> 3 imports) should use multiline import
- Sort import imports alphabetically
## Protected files
**Never modify files inside the `data/` directory.** This directory contains game data that should remain unchanged.
Never add secrets to code.

73
ui/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
ui/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HAMView</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5960
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

53
ui/package.json Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run"
},
"dependencies": {
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.8",
"@noble/ciphers": "^2.1.1",
"@noble/curves": "^2.0.1",
"@noble/ed25519": "^3.0.0",
"@noble/hashes": "^2.0.1",
"axios": "^1.13.6",
"bootstrap": "^5.3.8",
"date-fns": "^4.1.0",
"mqtt": "^5.15.0",
"react": "^19.2.0",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
"react-qr-code": "^2.0.18",
"react-router": "^7.13.1",
"tweetnacl": "^1.0.3"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/crypto-js": "^4.2.2",
"@types/node": "^24.11.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/ui": "^4.0.18",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"happy-dom": "^20.8.3",
"sass": "^1.93.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
}

1
ui/public/image Symbolic link
View File

@@ -0,0 +1 @@
../../asset/image

1
ui/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

147
ui/src/App.scss Normal file
View File

@@ -0,0 +1,147 @@
/* Import theme configuration */
@import './styles/variables';
@import './styles/theme';
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
}
body {
display: block;
background-color: var(--app-bg);
color: var(--app-text);
}
#root {
max-width: none;
padding: 0;
text-align: initial;
background-color: var(--app-bg);
color: var(--app-text);
}
a {
color: var(--app-accent);
}
a:hover {
color: #bfd2ff;
}
.full-view {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.split-root {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
display: flex;
}
.split-pane {
min-width: 0;
min-height: 0;
overflow: auto;
color: var(--app-text);
}
.horizontal-split {
.split-pane {
width: 100%;
}
.split-pane-primary,
.split-pane-secondary {
flex: 1 1 0;
}
.split-gutter {
height: var(--layout-gutter);
flex: 0 0 var(--layout-gutter);
width: 100%;
}
}
.vertical-split {
flex-direction: row;
.split-gutter {
width: var(--layout-gutter);
flex: 0 0 var(--layout-gutter);
}
}
.vertical-split-50-50 {
.split-pane-primary,
.split-pane-secondary {
flex: 1 1 0;
}
}
.vertical-split-75-25 {
.split-pane-primary {
flex: 3 1 0;
}
.split-pane-secondary {
flex: 1 1 0;
}
}
.vertical-split-25-70 {
.split-pane-primary {
flex: 25 1 0;
}
.split-pane-secondary {
flex: 70 1 0;
}
}
/* List Group Styles */
.list-item {
padding: 0.75rem;
border-bottom: 1px solid rgba(173, 205, 255, 0.15);
cursor: pointer;
transition: background 0.15s ease, border-left 0.15s ease, padding-left 0.15s ease;
background: rgba(11, 39, 82, 0.6);
color: var(--app-text);
&:hover {
background: rgba(20, 60, 120, 0.85);
border-bottom-color: rgba(173, 205, 255, 0.25);
}
&.is-selected {
background: rgba(30, 90, 160, 0.75);
border-left: 4px solid rgba(142, 180, 255, 0.95);
padding-left: calc(0.75rem - 4px);
}
&:last-child {
border-bottom: none;
}
}
.list-item-title {
font-weight: 600;
color: var(--app-text);
margin-bottom: 0.25rem;
}
.list-item-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--app-text-muted);
}

51
ui/src/App.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
import { RadiosProvider } from './contexts/RadiosContext'
import Overview from './pages/Overview'
import APRS from './pages/APRS'
import APRSPacketsView from './pages/aprs/APRSPacketsView'
import MeshCore from './pages/MeshCore'
import MeshCoreGroupChatView from './pages/meshcore/MeshCoreGroupChatView'
import MeshCoreMapView from './pages/meshcore/MeshCoreMapView'
import MeshCorePacketsView from './pages/meshcore/MeshCorePacketsView'
import StyleGuide from './pages/StyleGuide'
import NotFound from './pages/NotFound'
import './App.scss'
const navLinks = [
{ label: 'Radios', to: '/' },
{ label: 'APRS', to: '/aprs' },
{ label: 'ADSB', to: '/adsb' },
{ label: 'MeshCore', to: '/meshcore' },
];
const withRadiosProvider = (Component: React.ComponentType<{ navLinks: typeof navLinks }>) =>
() => (
<RadiosProvider>
<Component navLinks={navLinks} />
</RadiosProvider>
);
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={withRadiosProvider(Overview)()} />
<Route path="/aprs" element={withRadiosProvider(APRS)()}>
<Route index element={<Navigate to="packets" replace />} />
<Route path="packets" element={<APRSPacketsView />} />
</Route>
<Route path="/meshcore" element={withRadiosProvider(MeshCore)()}>
<Route index element={<Navigate to="packets" replace />} />
<Route path="packets" element={<MeshCorePacketsView />} />
<Route path="groupchat" element={<MeshCoreGroupChatView />} />
<Route path="map" element={<MeshCoreMapView />} />
</Route>
<Route path="/style-guide" element={<StyleGuide />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
)
}
export default App

1
ui/src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { Container } from 'react-bootstrap';
import type { FullProps } from '../types/layout.types';
const Full: React.FC<FullProps> = ({ children, className = '' }) => {
return (
<Container fluid className={`full-view p-0 d-flex flex-column ${className}`.trim()}>
{children}
</Container>
);
};
export default Full;

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { Col, Container, Row } from 'react-bootstrap';
import type { HorizontalSplitProps } from '../types/layout.types';
const HorizontalSplit: React.FC<HorizontalSplitProps> = ({ top, bottom, className = '' }) => {
return (
<Container fluid className="p-0 h-100 w-100">
<Row className={`split-root horizontal-split g-0 flex-column h-100 w-100 ${className}`.trim()}>
<Col className="split-pane split-pane-primary d-flex flex-column">{top}</Col>
<Col xs="auto" className="split-gutter p-0" />
<Col className="split-pane split-pane-secondary d-flex flex-column">{bottom}</Col>
</Row>
</Container>
);
};
export default HorizontalSplit;

View File

@@ -0,0 +1,90 @@
.layout-container {
--layout-gutter: 16px;
width: 100%;
height: 100vh;
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: var(--app-bg);
color: var(--app-text);
}
.layout-navbar {
flex: 0 0 auto;
background: linear-gradient(
135deg,
rgba(23, 58, 110, 0.46) 0%,
rgba(10, 32, 72, 0.62) 50%,
rgba(30, 74, 140, 0.42) 100%
);
border-bottom: 1px solid rgba(167, 203, 255, 0.35);
box-shadow:
0 8px 24px rgba(2, 10, 26, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
.navbar-brand,
.nav-link,
.navbar-toggler {
color: var(--app-text);
}
.navbar-brand:hover,
.nav-link:hover,
.nav-link:focus {
color: #ffffff;
}
.navbar-toggler {
border-color: rgba(255, 255, 255, 0.35);
}
.navbar-toggler-icon {
filter: brightness(0) invert(1);
}
.nav-link-custom {
color: var(--app-text-muted);
transition: color 160ms ease, text-shadow 160ms ease;
}
.nav-link-custom:hover,
.nav-link-custom:focus {
color: #f3f7ff;
text-shadow: 0 0 12px rgba(168, 203, 255, 0.45);
}
.nav-link-custom.active {
color: #ffffff;
font-weight: 600;
text-shadow: 0 0 14px rgba(153, 193, 255, 0.5);
}
}
.main-content {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
background-color: var(--app-bg);
color: var(--app-text);
padding-left: var(--layout-gutter);
padding-right: var(--layout-gutter);
padding-bottom: var(--layout-gutter);
}
.layout-footer {
flex: 0 0 auto;
padding: 12px var(--layout-gutter);
background-color: var(--app-bg);
border-top: 1px solid rgba(167, 203, 255, 0.35);
text-align: center;
font-size: 12px;
color: var(--app-text);
p {
margin: 0;
}
}

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { Container, Navbar, Nav } from 'react-bootstrap';
import { Link, NavLink, Outlet, useLocation } from 'react-router';
import './Layout.scss';
import type { LayoutProps, NavLinkItem } from '../types/layout.types';
const Layout: React.FC<LayoutProps> = ({
children,
brandText = 'PD0MZ HAM View',
brandTo = '/',
buttonGroup,
navLinks = [],
gutterSize = 16
}) => {
const location = useLocation();
const isActive = (link: NavLinkItem): boolean => {
return location.pathname.startsWith(link.to);
};
const resolvedGutter = typeof gutterSize === 'number' ? `${gutterSize}px` : gutterSize;
return (
<div className="layout-container" style={{ ['--layout-gutter' as string]: resolvedGutter }}>
<Navbar bg="transparent" variant="dark" expand="lg" className="layout-navbar px-4">
<Container fluid>
<div className="d-flex align-items-center">
<Navbar.Brand as={Link} to={brandTo} className="fw-bold brand-text">
{brandText}
</Navbar.Brand>
{buttonGroup && <div className="ms-3">{buttonGroup}</div>}
</div>
<Navbar.Toggle aria-controls="layout-navbar-nav" />
<Navbar.Collapse id="layout-navbar-nav" className="justify-content-end">
<Nav className="ms-auto">
{navLinks.map((link, index) => (
<Nav.Link
key={index}
as={NavLink}
to={link.to}
end={link.end}
className={`mx-2 nav-link-custom ${isActive(link) ? 'active' : ''}`}
>
{link.label}
</Nav.Link>
))}
</Nav>
</Navbar.Collapse>
</Container>
</Navbar>
<Container fluid className="main-content d-flex flex-column" style={{ marginTop: resolvedGutter }}>
{children || <Outlet />}
</Container>
<footer className="layout-footer">
<p>&copy; {new Date().getFullYear()} PD0MZ. All rights reserved.</p>
</footer>
</div>
);
};
export default Layout;

View File

@@ -0,0 +1,138 @@
.radio-card {
background: linear-gradient(
135deg,
rgba(20, 60, 120, 0.85) 0%,
rgba(25, 75, 140, 0.75) 100%
);
border: 1px solid rgba(142, 180, 255, 0.35);
border-radius: 8px;
color: var(--app-text);
box-shadow:
0 4px 16px rgba(2, 10, 26, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
transition: all 200ms ease;
min-width: 260px;
height: 100%;
&:hover {
border-color: rgba(142, 180, 255, 0.4);
box-shadow:
0 8px 24px rgba(2, 10, 26, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
transform: translateY(-4px);
}
}
.radio-card-clickable {
cursor: pointer;
&:hover {
border-color: rgba(142, 180, 255, 0.6);
box-shadow:
0 12px 32px rgba(142, 180, 255, 0.2),
0 8px 24px rgba(2, 10, 26, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
transform: translateY(-6px);
}
}
.radio-card-header {
background: linear-gradient(
135deg,
rgba(30, 85, 160, 0.9) 0%,
rgba(35, 100, 180, 0.85) 100%
);
border-bottom: 1px solid rgba(142, 180, 255, 0.3);
padding: 12px 8px;
display: flex;
align-items: center;
justify-content: center;
min-height: 160px;
}
.radio-card-image-container {
width: calc(100% - 16px);
height: 140px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 8px;
overflow: hidden;
border-radius: 4px;
}
.radio-card-protocol-image,
.radio-card-device-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
filter: brightness(1.1) drop-shadow(0 0 8px rgba(142, 180, 255, 0.3));
}
.radio-card-body {
padding: 16px;
}
.radio-card-title {
font-size: 14px;
font-weight: 600;
color: var(--app-text);
margin-bottom: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.radio-card-details {
display: flex;
flex-direction: column;
gap: 8px;
}
.radio-card-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
gap: 8px;
}
.radio-card-label {
color: var(--app-text-muted);
font-weight: 500;
flex: 0 0 auto;
}
.radio-card-value {
color: var(--app-accent);
font-weight: 600;
text-align: right;
flex: 1 1 auto;
}
.radio-card-status {
display: flex;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(142, 180, 255, 0.15);
}
.radio-card-status-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
width: 100%;
text-align: center;
&.online {
background: rgba(34, 197, 94, 0.2);
color: #86efac;
}
&.offline {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
}

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { Card } from 'react-bootstrap';
import type { Radio } from '../types/radio.types';
import { getDeviceImageURL } from '../libs/deviceImageMapper';
import './RadioCard.scss';
interface RadioCardProps {
radio: Radio;
}
export const RadioCard: React.FC<RadioCardProps> = ({ radio }) => {
const deviceImageURL = getDeviceImageURL(radio.protocol, radio.manufacturer, radio.device);
return (
<Card className="radio-card">
<Card.Header className="radio-card-header">
<div className="radio-card-image-container">
<img
src={deviceImageURL}
alt={`${radio.manufacturer || 'Unknown'} ${radio.device || ''}`}
className="radio-card-device-image"
onError={(e) => {
(e.target as HTMLImageElement).src = '/image/device/unknown.png';
}}
/>
</div>
</Card.Header>
<Card.Body className="radio-card-body">
<Card.Title className="radio-card-title">{radio.name}</Card.Title>
<div className="radio-card-details">
<div className="radio-card-row">
<span className="radio-card-label">Frequency:</span>
<span className="radio-card-value">{radio.frequency.toFixed(2)} MHz</span>
</div>
<div className="radio-card-row">
<span className="radio-card-label">Bandwidth:</span>
<span className="radio-card-value">{radio.bandwidth.toFixed(2)} kHz</span>
</div>
<div className="radio-card-row">
<span className="radio-card-label">Modulation:</span>
<span className="radio-card-value">{radio.modulation}</span>
</div>
<div className="radio-card-row">
<span className="radio-card-label">Protocol:</span>
<span className="radio-card-value">{radio.protocol}</span>
</div>
{(radio.lora_sf !== undefined || radio.lora_cr !== undefined) && (
<>
{radio.lora_sf !== undefined && (
<div className="radio-card-row">
<span className="radio-card-label">LoRa SF:</span>
<span className="radio-card-value">{radio.lora_sf}</span>
</div>
)}
{radio.lora_cr !== undefined && (
<div className="radio-card-row">
<span className="radio-card-label">LoRa CR:</span>
<span className="radio-card-value">{radio.lora_cr}</span>
</div>
)}
</>
)}
<div className="radio-card-status">
<span className={`radio-card-status-badge ${radio.is_online ? 'online' : 'offline'}`}>
{radio.is_online ? '🟢 Online' : '🔴 Offline'}
</span>
</div>
</div>
</Card.Body>
</Card>
);
};
export default RadioCard;

View File

@@ -0,0 +1,29 @@
@keyframes stream-status-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
.stream-status {
&__text {
font-size: 0.875rem;
}
&__dot {
font-size: 0.875rem;
&.is-ready {
color: #22c55e;
animation: none;
}
&.is-connecting {
color: #ef4444;
animation: stream-status-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
}
}

View File

@@ -0,0 +1,22 @@
import React from 'react';
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
import './StreamStatus.scss';
interface StreamStatusProps {
ready: boolean;
}
const StreamStatus: React.FC<StreamStatusProps> = ({ ready }) => {
return (
<div className="stream-status d-flex align-items-center gap-2">
<span className="stream-status__text">
{ready ? 'Connected' : 'Connecting'}
</span>
<FiberManualRecordIcon
className={`stream-status__dot ${ready ? 'is-ready' : 'is-connecting'}`}
/>
</div>
);
};
export default StreamStatus;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { Col, Container, Row } from 'react-bootstrap';
import type { VerticalSplitProps } from '../types/layout.types';
const VerticalSplit: React.FC<VerticalSplitProps> = ({
left,
right,
ratio = '50/50',
className = ''
}) => {
const ratioClass =
ratio === '75/25' ? 'vertical-split-75-25' :
ratio === '25/70' ? 'vertical-split-25-70' :
'vertical-split-50-50';
return (
<Container fluid className="p-0 h-100 w-100">
<Row className={`split-root vertical-split g-0 flex-nowrap h-100 w-100 ${ratioClass} ${className}`.trim()}>
<Col className="split-pane split-pane-primary d-flex flex-column h-100">{left}</Col>
<Col xs="auto" className="split-gutter p-0" />
<Col className="split-pane split-pane-secondary d-flex flex-column h-100">{right}</Col>
</Row>
</Container>
);
};
export default VerticalSplit;

View File

@@ -0,0 +1,60 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import API from '../services/API';
import type { Radio } from '../types/radio.types';
interface RadiosContextValue {
radios: Radio[];
loading: boolean;
error: string | null;
}
const RadiosContext = createContext<RadiosContextValue | null>(null);
export const RadiosProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [radios, setRadios] = useState<Radio[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchRadios = async () => {
try {
setLoading(true);
setError(null);
const data = await API.fetchAllRadios();
setRadios(data || []);
} catch (err) {
console.error('Failed to fetch radios:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch radios');
} finally {
setLoading(false);
}
};
fetchRadios();
}, []);
const value = useMemo<RadiosContextValue>(
() => ({
radios,
loading,
error,
}),
[radios, loading, error]
);
return <RadiosContext.Provider value={value}>{children}</RadiosContext.Provider>;
};
export const useRadios = (): RadiosContextValue => {
const ctx = useContext(RadiosContext);
if (!ctx) {
throw new Error('useRadios must be used within RadiosProvider');
}
return ctx;
};
export const useRadiosByProtocol = (protocol: string): Radio[] => {
const { radios } = useRadios();
return useMemo(() => radios.filter((radio) => radio.protocol === protocol), [radios, protocol]);
};

View File

@@ -0,0 +1,215 @@
import React, { createContext, useContext, useEffect, useRef, useState, useCallback, type JSX } from 'react';
import type { BaseStream } from '../services/Stream';
import type { StreamState } from '../types/stream.types';
interface StreamContextValue {
stream: BaseStream | null;
state: StreamState;
subscribe: <T = any>(
topic: string,
callback: (data: T, topic: string) => void,
qos?: 0 | 1 | 2
) => () => void;
subscribeMany: (
subscriptions: Array<{ topic: string; qos?: 0 | 1 | 2 }>,
callback: (data: any, topic: string) => void
) => () => void;
reconnect: () => void;
getLastMessage: <T = any>(topic: string) => T | undefined;
isSubscribed: (topic: string) => boolean;
}
const StreamContext = createContext<StreamContextValue | undefined>(undefined);
interface StreamProviderProps {
stream: BaseStream;
children: React.ReactNode;
}
export function StreamProvider({ stream, children }: StreamProviderProps): JSX.Element {
const [state, setState] = useState<StreamState>(stream.getState());
const streamRef = useRef(stream);
useEffect(() => {
const currentStream = streamRef.current;
const unsubscribeState = currentStream.subscribeToState((newState) => {
setState(newState);
});
return () => {
unsubscribeState();
};
}, []);
const subscribe = useCallback(<T = any,>(
topic: string,
callback: (data: T, topic: string) => void,
qos: 0 | 1 | 2 = 0
) => {
return streamRef.current.subscribe<T>(topic, callback, qos);
}, []);
const subscribeMany = useCallback((
subscriptions: Array<{ topic: string; qos?: 0 | 1 | 2 }>,
callback: (data: any, topic: string) => void
) => {
return streamRef.current.subscribeMany(subscriptions, callback);
}, []);
const reconnect = useCallback(() => {
if (streamRef.current) {
streamRef.current.disconnect();
streamRef.current.connect();
}
}, []);
const getLastMessage = useCallback(<T = any,>(topic: string) => {
return streamRef.current.getLastMessage<T>(topic);
}, []);
const isSubscribed = useCallback((topic: string) => {
return streamRef.current.isSubscribed(topic);
}, []);
const value: StreamContextValue = {
stream: streamRef.current,
state,
subscribe,
subscribeMany,
reconnect,
getLastMessage,
isSubscribed,
};
return (
<StreamContext.Provider value={value}>
{children}
</StreamContext.Provider>
);
}
// Main hook
export function useStream(): StreamContextValue {
const context = useContext(StreamContext);
if (context === undefined) {
throw new Error('useStream must be used within a StreamProvider');
}
return context;
}
// Topic-specific hook
export function useTopic<T = any>(
topic: string,
qos: 0 | 1 | 2 = 0
): {
lastMessage: T | undefined;
isSubscribed: boolean;
subscribe: () => void;
unsubscribe: () => void;
} {
const { subscribe: subscribeToTopic, getLastMessage, isSubscribed: checkSubscription } = useStream();
const [lastMessage, setLastMessage] = useState<T | undefined>(() => getLastMessage<T>(topic));
const unsubscribeRef = useRef<(() => void) | null>(null);
useEffect(() => {
// Cleanup on unmount
return () => {
if (unsubscribeRef.current) {
unsubscribeRef.current();
}
};
}, []);
const subscribe = useCallback(() => {
if (!unsubscribeRef.current) {
unsubscribeRef.current = subscribeToTopic<T>(topic, (data) => {
setLastMessage(data);
}, qos);
}
}, [topic, qos, subscribeToTopic]);
const unsubscribe = useCallback(() => {
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
setLastMessage(undefined);
}
}, []);
return {
lastMessage,
isSubscribed: checkSubscription(topic),
subscribe,
unsubscribe,
};
}
// Multiple topics hook
export function useTopics<T extends Record<string, any>>(
topics: Array<{ topic: keyof T & string; qos?: 0 | 1 | 2 }>
): {
lastMessages: Partial<T>;
isSubscribed: Record<keyof T, boolean>;
subscribe: () => void;
unsubscribe: () => void;
} {
const { subscribe: subscribeToTopic, getLastMessage, isSubscribed: checkSubscription } = useStream();
const [lastMessages, setLastMessages] = useState<Partial<T>>(() => {
const initial: Partial<T> = {};
topics.forEach(({ topic }) => {
initial[topic as keyof T] = getLastMessage(topic as string);
});
return initial;
});
const unsubscribersRef = useRef<Map<keyof T, () => void>>(new Map());
useEffect(() => {
return () => {
unsubscribersRef.current.forEach(unsub => unsub());
};
}, []);
const subscribe = useCallback(() => {
topics.forEach(({ topic, qos = 0 }) => {
if (!unsubscribersRef.current.has(topic)) {
const unsub = subscribeToTopic(topic as string, (data) => {
setLastMessages(prev => ({ ...prev, [topic]: data }));
}, qos);
unsubscribersRef.current.set(topic, unsub);
}
});
}, [topics, subscribeToTopic]);
const unsubscribe = useCallback(() => {
unsubscribersRef.current.forEach(unsub => unsub());
unsubscribersRef.current.clear();
setLastMessages({});
}, []);
const isSubscribedRecord = topics.reduce((acc, { topic }) => {
acc[topic as keyof T] = checkSubscription(topic as string);
return acc;
}, {} as Record<keyof T, boolean>);
return {
lastMessages,
isSubscribed: isSubscribedRecord,
subscribe,
unsubscribe,
};
}
// Connection state hook
export function useStreamConnection() {
const { state } = useStream();
return {
isConnected: state.isConnected,
isConnecting: state.isConnecting,
error: state.error,
subscriptions: Array.from(state.subscriptions.keys()),
};
}

142
ui/src/libs/base91.test.ts Normal file
View File

@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest';
import { base91ToNumber, numberToBase91 } from './base91';
describe('Base91 encoding/decoding', () => {
describe('base91ToNumber', () => {
it('should decode single Base91 character', () => {
expect(base91ToNumber('!')).toBe(0);
expect(base91ToNumber('"')).toBe(1);
expect(base91ToNumber('#')).toBe(2);
});
it('should decode multiple Base91 characters', () => {
// "!!" = 0 * 91 + 0 = 0
expect(base91ToNumber('!!')).toBe(0);
// "!#" = 0 * 91 + 2 = 2
expect(base91ToNumber('!#')).toBe(2);
// "#!" = 2 * 91 + 0 = 182
expect(base91ToNumber('#!')).toBe(182);
// "##" = 2 * 91 + 2 = 184
expect(base91ToNumber('##')).toBe(184);
});
it('should decode 4-character Base91 strings (used in APRS)', () => {
// Test with printable ASCII Base91 characters (33-123)
const testValue = base91ToNumber('!#%\'');
expect(testValue).toBeGreaterThan(0);
expect(testValue).toBeLessThan(91 * 91 * 91 * 91);
});
it('should decode maximum valid Base91 value', () => {
// Maximum is '{' (ASCII 123, digit 90) repeated
const maxValue = base91ToNumber('{{{{');
const expected = 90 * 91 * 91 * 91 + 90 * 91 * 91 + 90 * 91 + 90;
expect(maxValue).toBe(expected);
});
it('should throw error for invalid Base91 characters', () => {
expect(() => base91ToNumber(' ')).toThrow('Invalid Base91 character');
expect(() => base91ToNumber('}')).toThrow('Invalid Base91 character');
expect(() => base91ToNumber('~')).toThrow('Invalid Base91 character');
});
it('should handle APRS compressed position example', () => {
// Using actual characters from APRS test vector
const latStr = '/:*E';
const lonStr = 'qZ=O';
const latValue = base91ToNumber(latStr);
const lonValue = base91ToNumber(lonStr);
// Just verify they decode without error and produce valid numbers
expect(typeof latValue).toBe('number');
expect(typeof lonValue).toBe('number');
expect(latValue).toBeGreaterThanOrEqual(0);
expect(lonValue).toBeGreaterThanOrEqual(0);
});
});
describe('numberToBase91', () => {
it('should encode zero', () => {
expect(numberToBase91(0)).toBe('!');
expect(numberToBase91(0, 1)).toBe('!');
expect(numberToBase91(0, 4)).toBe('!!!!');
});
it('should encode small numbers', () => {
expect(numberToBase91(5, 1)).toBe('&');
expect(numberToBase91(1)).toBe('"');
expect(numberToBase91(90)).toBe('{');
});
it('should encode multi-digit Base91 values', () => {
// 91 = "!" (1 char to next position) but we get "#!" (2 chars) because it's 1*91
const encoded = numberToBase91(91);
expect(encoded.length).toBeGreaterThan(1);
});
it('should pad with leading ! when length specified', () => {
const encoded1 = numberToBase91(5, 1);
expect(encoded1).toBe('&'); // ASCII 33 + 5 = 38 (&)
expect(encoded1.length).toBe(1);
const encoded4 = numberToBase91(5, 4);
expect(encoded4.length).toBe(4);
expect(encoded4.endsWith('&')).toBe(true);
expect(encoded4.startsWith('!')).toBe(true);
});
it('should encode and decode consistently', () => {
const testValues = [0, 1, 42, 91, 182, 1000, 10000, 100000];
for (const value of testValues) {
const encoded = numberToBase91(value, 4);
const decoded = base91ToNumber(encoded);
expect(decoded).toBe(value);
}
});
it('should handle maximum Base91 4-byte value', () => {
const maxValue = 91 * 91 * 91 * 91 - 1;
const encoded = numberToBase91(maxValue, 4);
expect(encoded.length).toBe(4);
const decoded = base91ToNumber(encoded);
expect(decoded).toBe(maxValue);
});
});
describe('Round-trip encoding/decoding', () => {
it('should preserve values through encode-decode cycle', () => {
const testCases = [
{ value: 12345, length: 4 },
{ value: 0, length: 4 },
{ value: 65535, length: 4 },
{ value: 1000000, length: 4 },
];
for (const { value, length } of testCases) {
const encoded = numberToBase91(value, length);
const decoded = base91ToNumber(encoded);
expect(decoded).toBe(value);
}
});
it('should work with APRS latitude/longitude encoding pattern', () => {
// APRS uses 4-character Base91 strings for latitude and longitude
// Latitude range: 0 to 380926 (representing 90 to -90 degrees)
const latValues = [0, 190463, 380926]; // ~90N, ~0, ~90S
for (const value of latValues) {
const encoded = numberToBase91(value, 4);
expect(encoded.length).toBe(4);
const decoded = base91ToNumber(encoded);
expect(decoded).toBe(value);
}
});
});
});

55
ui/src/libs/base91.ts Normal file
View File

@@ -0,0 +1,55 @@
/**
* Decode a Base91 encoded string to a number.
* Base91 uses ASCII characters 33-123 (! to {) for encoding.
* Each character represents a digit in base 91.
*
* @param str Base91 encoded string (typically 4 characters for 32-bit values in APRS)
* @returns Decoded number value
*/
export const base91ToNumber = (str: string): number => {
let value = 0;
const base = 91;
for (let i = 0; i < str.length; i++) {
const charCode = str.charCodeAt(i);
const digit = charCode - 33; // Base91 uses chars 33-123 (! to {)
if (digit < 0 || digit >= base) {
throw new Error(`Invalid Base91 character: '${str[i]}' (code ${charCode})`);
}
value = value * base + digit;
}
return value;
}
/**
* Encode a number to Base91 representation.
* Base91 uses ASCII characters 33-123 (! to {) for encoding.
*
* @param value Number to encode
* @param length Number of Base91 characters to generate (default: minimum needed)
* @returns Base91 encoded string
*/
export const numberToBase91 = (value: number, length?: number): string => {
const base = 91;
const chars: string[] = [];
let num = Math.abs(Math.floor(value));
if (num === 0) {
return length ? '!'.repeat(length) : '!';
}
while (num > 0) {
chars.unshift(String.fromCharCode((num % base) + 33));
num = Math.floor(num / base);
}
// Pad with leading '!' characters if needed
if (length && chars.length < length) {
chars.unshift(...Array(length - chars.length).fill('!'));
}
return chars.join('');
}

View File

@@ -0,0 +1,235 @@
/**
* Protocol-aware device image mapper
* Maps manufacturer and device names to device image filenames based on protocol
*/
type DeviceImageMap = Record<string, Record<string, string>>;
const meshcoreDeviceMap: DeviceImageMap = {
heltec: {
mesh_solar: 'heltec_mesh_solar.svg',
meshpocket: 'heltec_meshpocket.svg',
paper: 'heltec_paper.svg',
t114: 'heltec_t114.svg',
v2: 'heltec_v2.svg',
v3: 'heltec_v3.svg',
v4: 'heltec_v4.svg',
wp: 'heltec_wp.svg',
wsl3: 'heltec_wsl3.svg',
wt3: 'heltec_wt3.svg',
},
lilygo: {
t_beam: 'lilygo_tbeam.svg',
tbeam: 'lilygo_tbeam.svg',
t_beam_supreme: 'lilygo_tbeam_supreme.svg',
tbeam_supreme: 'lilygo_tbeam_supreme.svg',
t_deck: 'lilygo_tdeck.svg',
tdeck: 'lilygo_tdeck.svg',
t_deck_pro: 'lilygo_tdeck_pro.svg',
tdeck_pro: 'lilygo_tdeck_pro.svg',
t_display: 'lilygo_tdisplay.svg',
tdisplay: 'lilygo_tdisplay.svg',
t_echo: 'lilygo_techo.svg',
techo: 'lilygo_techo.svg',
t_echo_lite: 'lilygo_techo_lite.svg',
techo_lite: 'lilygo_techo_lite.svg',
t_pager: 'lilygo_pager.svg',
pager: 'lilygo_pager.svg',
},
rak: {
'4631': 'rak_4631.svg',
'11300': 'rak_11300.svg',
wismesh_tag: 'rak_wismesh_tag.svg',
},
seeed: {
wio_tracker_l1: 'wio_tracker_l1.svg',
wio_tracker_l1_eink: 'wio_tracker_l1_eink.svg',
sensecap_solar: 'sensecap_solar.svg',
sensecap_t1000e: 'sensecap_t1000e.svg',
},
xiao: {
esp32c3: 'xiao_esp32c3.svg',
esp32c6: 'xiao_esp32c6.svg',
esp32s3: 'xiao_esp32s3.svg',
nrf52: 'xiao_nrf52.svg',
},
ikoka: {
nano: 'ikoka_nano.svg',
stick: 'ikoka_stick.svg',
},
keepteen: {
lt1: 'keepteen_lt1.svg',
},
thinknode: {
m1: 'thinknode_m1.svg',
m2: 'thinknode_m2.svg',
m3: 'thinknode_m3.svg',
m5: 'thinknode_m5.svg',
m6: 'thinknode_m6.svg',
},
nano: {
g2: 'nano_g2.svg',
},
station: {
g2: 'station_g2.svg',
},
generic: {
meshcore: 'meshcore.svg',
esp32: 'esp32.svg',
esp_now: 'esp_now.svg',
lora: 'lora.svg',
nrf52: 'nrf52.svg',
rpi: 'rpi.svg',
rpi_pico: 'rpi_picow.svg',
picow: 'rpi_picow.svg',
},
};
const aprsDeviceMap: DeviceImageMap = {
lilygo: {
lora32: 'lilygo_tlora_1.6.svg',
'lora32 v2': 'lilygo_tlora_1.6.svg',
'lora32 v2.1': 'lilygo_tlora_1.6.svg',
't3s3': 'lilygo_t3s3.svg',
't5_pro': 'lilygo_t5_pro.svg',
't_beam': 'lilygo_tbeam.svg',
'tbeam_v1.1': 'lilygo_tbeam.svg',
't_deck': 'lilygo_tdeck.svg',
't_lora_c6': 'lilygo_tlora_c6.svg',
't_echo': 'lilygo_techo.svg',
},
heltec: {
'lora32': 'heltec_v2.svg',
'lora32 v2': 'heltec_v3.svg',
'lora32 v3': 'heltec_v3.svg',
'lora32 v4': 'heltec_v4.svg',
'wireless_paper': 'heltec_paper.svg',
'wireless_paper_pro': 'heltec_paper.svg',
},
rak: {
'4631': 'rak_4631.svg',
'11300': 'rak_11300.svg',
},
generic: {
lora: 'lora.svg',
esp32: 'esp32.svg',
},
};
export function getDeviceImageURL(
protocol: string,
manufacturer?: string | null,
device?: string | null
): string {
if (!manufacturer || !device) {
return '/image/device/unknown.png';
}
const protocolLower = protocol.toLowerCase();
const manufacturerLower = manufacturer.toLowerCase().replace(/\s+/g, '_');
const deviceLower = device.toLowerCase().replace(/\s+/g, '_');
let deviceMap: DeviceImageMap | undefined;
if (protocolLower === 'meshcore' || protocolLower.includes('mesh')) {
deviceMap = meshcoreDeviceMap;
} else if (protocolLower === 'aprs') {
deviceMap = aprsDeviceMap;
}
if (!deviceMap) {
// Unknown protocol, try generic lookup
return `/image/device/unknown.png`;
}
// Try exact match first
const mfgMap = deviceMap[manufacturerLower];
if (mfgMap && mfgMap[deviceLower]) {
return `/image/device/${mfgMap[deviceLower]}`;
}
// Try partial match (search for device containing the key)
if (mfgMap) {
for (const [key, filename] of Object.entries(mfgMap)) {
if (deviceLower.includes(key) || key.includes(deviceLower)) {
return `/image/device/${filename}`;
}
}
}
// Fall back to looking for a generic manufacturer-based image
const genericMfgKey = manufacturerLower.split('_')[0];
const genericMfgImage = `/image/device/${genericMfgKey}.svg`;
// Check if a manufacturer-specific generic image exists
if (hasDeviceImage(genericMfgImage)) {
return genericMfgImage;
}
return `/image/device/unknown.png`;
}
/**
* Helper to check if a device image likely exists
* (In a real app, this could be from an asset manifest)
*/
function hasDeviceImage(filename: string): boolean {
const knownImages = [
'esp32.svg',
'esp_now.svg',
'faketec.svg',
'heltec_mesh_solar.svg',
'heltec_meshpocket.svg',
'heltec_paper.svg',
'heltec_t114.svg',
'heltec_v2.svg',
'heltec_v3.svg',
'heltec_v4.svg',
'heltec_wp.svg',
'heltec_wsl3.svg',
'heltec_wt3.svg',
'ikoka_nano.svg',
'ikoka_stick.svg',
'keepteen_lt1.svg',
'lilygo_pager.svg',
'lilygo_t3s3.svg',
'lilygo_t5_pro.svg',
'lilygo_tbeam.svg',
'lilygo_tbeam_supreme.svg',
'lilygo_tdeck.svg',
'lilygo_tdeck_pro.svg',
'lilygo_tdisplay.svg',
'lilygo_techo.svg',
'lilygo_techo_lite.svg',
'lilygo_tlora_1.6.svg',
'lilygo_tlora_c6.svg',
'lora.svg',
'meshcore.svg',
'nano_g2.svg',
'nrf52.svg',
'rak_11300.svg',
'rak_4631.svg',
'rak_wismesh_tag.svg',
'rpi.svg',
'rpi_picow.svg',
'sensecap_solar.svg',
'sensecap_t1000e.svg',
'station_g2.svg',
'thinknode_m1.svg',
'thinknode_m2.svg',
'thinknode_m3.svg',
'thinknode_m5.svg',
'thinknode_m6.svg',
'wio_tracker_l1.svg',
'wio_tracker_l1_eink.svg',
'xiao_esp32c3.svg',
'xiao_esp32c6.svg',
'xiao_esp32s3.svg',
'xiao_nrf52.svg',
'yeasu_ft817.jpg',
'unknown.png',
];
return knownImages.some(
(img) => filename.includes(img) || img.includes(filename.split('/').pop() || '')
);
}

10
ui/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import 'bootstrap/dist/css/bootstrap.min.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

107
ui/src/pages/APRS.scss Normal file
View File

@@ -0,0 +1,107 @@
.aprs-view-switch {
.btn {
border-color: rgba(173, 205, 255, 0.45);
color: var(--app-text);
}
.btn.btn-primary {
background: rgba(90, 146, 255, 0.5);
border-color: rgba(173, 205, 255, 0.75);
}
.btn:hover,
.btn:focus {
color: #ffffff;
border-color: rgba(225, 237, 255, 0.8);
}
}
.aprs-table-card,
.aprs-detail-card {
background: rgba(8, 24, 56, 0.5);
border: 1px solid rgba(173, 205, 255, 0.2);
color: var(--app-text);
}
.aprs-table-header {
background: rgba(27, 56, 108, 0.45);
border-bottom: 1px solid rgba(173, 205, 255, 0.2);
color: var(--app-text);
font-weight: 600;
}
.aprs-table-body {
height: 100%;
min-height: 0;
}
.aprs-table-scroll {
height: 100%;
max-height: 100%;
overflow-y: auto;
}
.aprs-table {
color: var(--app-text);
thead th {
position: sticky;
top: 0;
z-index: 2;
background: rgba(13, 36, 82, 0.95);
border-color: rgba(173, 205, 255, 0.18);
color: var(--app-text);
}
td {
border-color: rgba(173, 205, 255, 0.12);
vertical-align: middle;
cursor: pointer;
}
tr.is-selected td {
background: rgba(102, 157, 255, 0.16);
}
tr:hover td {
background: rgba(102, 157, 255, 0.08);
}
}
.aprs-detail-stack {
overflow-y: auto;
padding-right: 0.25rem;
}
.aprs-fact-row {
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px;
padding: 4px 0;
}
.aprs-fact-label {
color: var(--app-text-muted);
}
.aprs-fact-value {
color: var(--app-text);
word-break: break-all;
}
.aprs-raw-code {
display: block;
padding: 0.5rem;
background: rgba(0, 0, 0, 0.3);
border-radius: 0.25rem;
white-space: pre-wrap;
word-break: break-all;
color: #b8d1ff;
font-size: 0.85rem;
}
.aprs-map {
.leaflet-container {
background: rgba(13, 36, 82, 0.95);
}
}

37
ui/src/pages/APRS.tsx Normal file
View File

@@ -0,0 +1,37 @@
import React from 'react';
import { Button, ButtonGroup } from 'react-bootstrap';
import Layout from '../components/Layout';
import { NavLink, Outlet, useLocation } from 'react-router';
import { APRSDataProvider } from './aprs/APRSData';
import type { NavLinkItem } from '../types/layout.types';
import './APRS.scss';
interface Props {
navLinks?: NavLinkItem[];
}
const APRS: React.FC<Props> = ({ navLinks = [] }) => {
const location = useLocation();
const viewButtons = (
<ButtonGroup className="aprs-view-switch" size="sm" aria-label="APRS view switch">
<Button
as={NavLink}
to="/aprs/packets"
variant={location.pathname.startsWith('/aprs/packets') ? 'primary' : 'outline-light'}
>
Packets
</Button>
</ButtonGroup>
);
return (
<APRSDataProvider>
<Layout navLinks={navLinks} buttonGroup={viewButtons}>
<Outlet />
</Layout>
</APRSDataProvider>
);
};
export default APRS;

416
ui/src/pages/MeshCore.scss Normal file
View File

@@ -0,0 +1,416 @@
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.meshcore-stream-status-text {
color: var(--app-text);
font-weight: 500;
}
.meshcore-view-switch {
.btn {
border-color: rgba(173, 205, 255, 0.45);
color: var(--app-text);
}
.btn.btn-primary {
background: rgba(90, 146, 255, 0.5);
border-color: rgba(173, 205, 255, 0.75);
}
.btn:hover,
.btn:focus {
color: #ffffff;
border-color: rgba(225, 237, 255, 0.8);
}
}
.meshcore-btn-icon {
display: inline-flex;
align-items: center;
gap: 0.25rem;
.meshcore-icon {
font-size: 1rem;
width: 1rem;
height: 1rem;
}
}
.meshcore-node-type-cell {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem !important;
border: none !important;
.meshcore-node-type-icon {
display: inline-flex;
align-items: center;
justify-content: center;
svg {
font-size: 1.125rem;
width: 1.125rem;
height: 1.125rem;
color: black;
}
}
}
.meshcore-table-card,
.meshcore-detail-card {
background: rgba(8, 24, 56, 0.5);
border: 1px solid rgba(173, 205, 255, 0.2);
color: var(--app-text);
}
.meshcore-table-header {
background: rgba(27, 56, 108, 0.45);
border-bottom: 1px solid rgba(173, 205, 255, 0.2);
color: var(--app-text);
font-weight: 600;
}
.meshcore-table-body {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.meshcore-filters {
background: rgba(13, 36, 82, 0.6);
padding: 0.75rem !important;
}
.meshcore-filter-dropdown {
.meshcore-dropdown-toggle {
border-color: rgba(173, 205, 255, 0.35) !important;
color: var(--app-text) !important;
font-size: 0.875rem;
padding: 0.375rem 0.75rem !important;
display: inline-flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
&:hover,
&:focus {
background: rgba(90, 146, 255, 0.2) !important;
border-color: rgba(173, 205, 255, 0.6) !important;
color: #ffffff !important;
}
svg {
width: 1rem;
height: 1rem;
transition: transform 0.2s ease;
}
&[aria-expanded='true'] svg {
transform: rotate(180deg);
}
}
.meshcore-dropdown-menu {
background: rgba(8, 24, 56, 0.95);
border-color: rgba(173, 205, 255, 0.25);
min-width: 200px;
.meshcore-dropdown-item {
padding: 0.5rem 1rem;
color: var(--app-text);
&:hover {
background: rgba(90, 146, 255, 0.2);
}
.form-check {
margin-bottom: 0;
}
.form-check-input {
background: rgba(8, 24, 56, 0.8);
border-color: rgba(173, 205, 255, 0.3);
cursor: pointer;
&:checked {
background: rgba(90, 146, 255, 0.6);
border-color: rgba(173, 205, 255, 0.75);
}
&:focus {
border-color: rgba(173, 205, 255, 0.5);
box-shadow: 0 0 0 0.25rem rgba(90, 146, 255, 0.25);
}
}
.form-check-label {
color: var(--app-text);
cursor: pointer;
margin-bottom: 0;
}
}
.dropdown-divider {
border-color: rgba(173, 205, 255, 0.2);
}
}
}
.meshcore-checkbox-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 200px;
overflow-y: auto;
padding-right: 0.25rem;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: rgba(173, 205, 255, 0.05);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: rgba(173, 205, 255, 0.2);
border-radius: 3px;
&:hover {
background: rgba(173, 205, 255, 0.4);
}
}
}
.meshcore-filter-check {
font-size: 0.875rem;
.form-check-input {
background: rgba(8, 24, 56, 0.8);
border-color: rgba(173, 205, 255, 0.25);
cursor: pointer;
&:checked {
background: rgba(90, 146, 255, 0.6);
border-color: rgba(173, 205, 255, 0.75);
}
&:focus {
border-color: rgba(173, 205, 255, 0.5);
box-shadow: 0 0 0 0.25rem rgba(90, 146, 255, 0.25);
}
}
.form-check-label {
color: var(--app-text);
cursor: pointer;
margin-bottom: 0;
}
}
.meshcore-table-scroll {
height: 100%;
max-height: 100%;
overflow-y: auto;
}
.meshcore-table {
color: var(--app-text);
thead th {
position: sticky;
top: 0;
z-index: 2;
background: rgba(13, 36, 82, 0.95);
border-color: rgba(173, 205, 255, 0.18);
color: var(--app-text);
}
td {
border-color: rgba(173, 205, 255, 0.12);
vertical-align: middle;
}
tr.is-selected td {
background: rgba(102, 157, 255, 0.16);
}
tr.meshcore-packet-green td {
border-left: 3px solid #22c55e;
&:nth-child(4) svg {
color: #22c55e !important;
}
&:nth-child(5) {
color: #22c55e;
font-weight: 500;
}
}
tr.meshcore-packet-purple td {
border-left: 3px solid #a855f7;
&:nth-child(4) svg {
color: #a855f7 !important;
}
&:nth-child(5) {
color: #a855f7;
font-weight: 500;
}
}
tr.meshcore-packet-amber td {
border-left: 3px solid #f59e0b;
&:nth-child(4) svg {
color: #f59e0b !important;
}
&:nth-child(5) {
color: #f59e0b;
font-weight: 500;
}
}
}
.meshcore-hash-button {
appearance: none;
border: 0;
background: transparent;
color: #b8d1ff;
font-family: monospace;
padding: 0;
&:hover {
color: #f2f7ff;
text-decoration: underline;
}
}
.meshcore-expand-button {
appearance: none;
border: 0;
background: transparent;
color: #b8d1ff;
padding: 0;
margin: 0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 0.15s ease-in-out;
&:hover {
color: #f2f7ff;
}
svg {
transition: transform 0.2s ease-in-out;
}
}
.meshcore-duplicate-badge {
display: inline-block;
margin-left: 0.375rem;
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
font-weight: 600;
color: #b8d1ff;
background: rgba(102, 157, 255, 0.16);
border: 1px solid rgba(173, 205, 255, 0.25);
border-radius: 0.25rem;
}
.meshcore-duplicate-row {
td {
border-color: rgba(173, 205, 255, 0.08);
background: rgba(13, 36, 82, 0.2);
font-size: 0.875rem;
color: var(--app-text-muted);
}
}
.meshcore-detail-stack {
overflow-y: auto;
padding-right: 0.25rem;
}
.meshcore-fact-row {
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px;
padding: 4px 0;
}
.meshcore-fact-label {
color: var(--app-text-muted);
}
.meshcore-fact-value {
color: var(--app-text);
word-break: break-all;
}
.meshcore-message-item {
padding: 0.75rem;
border: 1px solid rgba(173, 205, 255, 0.12);
border-radius: 0.375rem;
background: rgba(13, 36, 82, 0.3);
color: var(--app-text);
}
.meshcore-message-sender {
color: #b8d1ff;
}
.meshcore-message-text {
color: var(--app-text);
word-break: break-word;
line-height: 1.5;
}
.meshcore-hash-code {
font-family: monospace;
color: #82aeff;
font-size: 0.8125rem;
word-break: break-all;
}
.meshcore-map-view {
min-height: 0;
}
.meshcore-map-canvas {
position: relative;
width: 100%;
min-height: 220px;
border: 1px solid rgba(173, 205, 255, 0.25);
border-radius: 0.5rem;
background:
radial-gradient(circle at 20% 20%, rgba(130, 174, 255, 0.18), transparent 45%),
linear-gradient(180deg, rgba(13, 35, 79, 0.9), rgba(8, 24, 56, 0.9));
overflow: hidden;
}
.meshcore-map-dot {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
background: #a8c9ff;
box-shadow: 0 0 10px rgba(168, 201, 255, 0.9);
transform: translate(-50%, -50%);
}

57
ui/src/pages/MeshCore.tsx Normal file
View File

@@ -0,0 +1,57 @@
import React from 'react';
import { ButtonGroup } from 'react-bootstrap';
import ChatIcon from '@mui/icons-material/Chat';
import MapIcon from '@mui/icons-material/Map';
import StorageIcon from '@mui/icons-material/Storage';
import Layout from '../components/Layout';
import { NavLink, Outlet, useLocation } from 'react-router';
import { MeshCoreDataProvider } from './meshcore/MeshCoreData';
import type { NavLinkItem } from '../types/layout.types';
import './MeshCore.scss';
interface Props {
navLinks?: NavLinkItem[];
}
const MeshCore: React.FC<Props> = ({ navLinks = [] }) => {
const location = useLocation();
const viewButtons = (
<ButtonGroup className="meshcore-view-switch" size="sm" aria-label="MeshCore view switch">
<NavLink
to="/meshcore/packets"
className={`btn btn-sm meshcore-btn-icon ${location.pathname.startsWith('/meshcore/packets') ? 'btn-primary' : 'btn-outline-light'}`}
title="Packets"
>
<StorageIcon className="meshcore-icon" />
<span className="ms-1">Packets</span>
</NavLink>
<NavLink
to="/meshcore/groupchat"
className={`btn btn-sm meshcore-btn-icon ${location.pathname.startsWith('/meshcore/groupchat') ? 'btn-primary' : 'btn-outline-light'}`}
title="Group Chat"
>
<ChatIcon className="meshcore-icon" />
<span className="ms-1">Chat</span>
</NavLink>
<NavLink
to="/meshcore/map"
className={`btn btn-sm meshcore-btn-icon ${location.pathname.startsWith('/meshcore/map') ? 'btn-primary' : 'btn-outline-light'}`}
title="Map"
>
<MapIcon className="meshcore-icon" />
<span className="ms-1">Map</span>
</NavLink>
</ButtonGroup>
);
return (
<MeshCoreDataProvider>
<Layout navLinks={navLinks} buttonGroup={viewButtons}>
<Outlet />
</Layout>
</MeshCoreDataProvider>
);
};
export default MeshCore;

View File

@@ -0,0 +1,71 @@
.not-found-container {
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, var(--app-bg) 0%, var(--app-bg-elevated) 100%);
.not-found-content {
text-align: center;
animation: fadeIn 0.6s ease-in-out;
}
.not-found-code {
font-size: 7rem;
font-weight: 900;
background: linear-gradient(135deg, var(--app-accent-primary) 0%, var(--app-accent-blue) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
letter-spacing: -0.05em;
}
.not-found-title {
font-size: 3rem;
font-weight: 700;
color: var(--app-text);
margin-bottom: 1rem;
letter-spacing: -0.02em;
}
.not-found-message {
font-size: 1.25rem;
color: var(--app-text-muted);
margin-bottom: 2.5rem;
max-width: 500px;
margin-left: auto;
margin-right: auto;
line-height: 1.6;
}
.not-found-button {
background-color: var(--app-accent-primary);
border: none;
padding: 12px 48px;
font-size: 1rem;
font-weight: 600;
border-radius: 6px;
transition: all 0.3s ease;
&:hover {
background-color: var(--app-accent-blue);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(94, 158, 255, 0.3);
}
&:active {
transform: translateY(0);
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

31
ui/src/pages/NotFound.tsx Normal file
View File

@@ -0,0 +1,31 @@
import React from 'react';
import { useNavigate } from 'react-router';
import { Button } from 'react-bootstrap';
import Full from '../components/Full';
import './NotFound.scss';
const NotFound: React.FC = () => {
const navigate = useNavigate();
return (
<Full className="not-found-container">
<div className="not-found-content">
<div className="not-found-code">404</div>
<h1 className="not-found-title">Page Not Found</h1>
<p className="not-found-message">
Sorry, the page you're looking for doesn't exist or may have been moved.
</p>
<Button
variant="primary"
size="lg"
onClick={() => navigate('/')}
className="not-found-button"
>
Return to Home
</Button>
</div>
</Full>
);
};
export default NotFound;

View File

@@ -0,0 +1,50 @@
.overview-container {
padding: 24px;
height: 100%;
overflow-y: auto;
}
.overview-section {
margin-bottom: 32px;
}
.overview-title {
font-size: 24px;
font-weight: 700;
color: var(--app-text);
margin-bottom: 20px;
text-shadow: 0 0 12px rgba(142, 180, 255, 0.2);
}
.overview-loading,
.overview-empty,
.overview-error {
padding: 24px;
text-align: center;
border-radius: 8px;
font-size: 14px;
}
.overview-loading {
background: rgba(142, 180, 255, 0.1);
color: var(--app-accent);
}
.overview-empty {
background: rgba(142, 180, 255, 0.08);
color: var(--app-text-muted);
}
.overview-error {
background: rgba(239, 68, 68, 0.1);
color: #fca5a5;
}
.overview-radios-row {
gap: 16px;
}
.overview-radio-col {
display: flex;
margin-bottom: 16px;
}

17
ui/src/pages/Overview.tsx Normal file
View File

@@ -0,0 +1,17 @@
import type React from "react";
import Layout from "../components/Layout";
import type { NavLinkItem } from "../types/layout.types";
interface Props {
navLinks?: NavLinkItem[]
}
export const Overview: React.FC<Props> = ({ navLinks = [] }) => {
return (
<Layout navLinks={navLinks}>
Hi mom!
</Layout>
)
}
export default Overview

View File

@@ -0,0 +1,382 @@
.style-guide {
width: 100%;
background: var(--app-bg);
color: var(--app-text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', sans-serif;
line-height: 1.6;
}
/* Header */
.sg-header {
background: linear-gradient(135deg, rgba(7, 27, 58, 0.8) 0%, rgba(11, 39, 82, 0.6) 100%);
border-bottom: 1px solid var(--app-border-color);
padding: 60px 0;
margin-bottom: 40px;
.sg-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
}
.sg-title {
font-size: 40px;
font-weight: 700;
margin: 0 0 12px 0;
color: var(--app-text);
letter-spacing: -0.5px;
}
.sg-subtitle {
font-size: 16px;
color: var(--app-text-muted);
margin: 0;
font-weight: 400;
}
/* Container */
.sg-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px 60px 24px;
}
/* Section Structure */
.sg-section {
margin-bottom: 60px;
&.sg-footer {
margin-bottom: 40px;
padding-top: 40px;
border-top: 1px solid var(--app-divider);
}
}
.sg-section-title {
font-size: 28px;
font-weight: 700;
color: var(--app-text);
margin: 0 0 32px 0;
padding-bottom: 12px;
border-bottom: 2px solid var(--app-accent-primary);
display: inline-block;
}
.sg-subsection {
margin-bottom: 40px;
}
.sg-subsection-title {
font-size: 16px;
font-weight: 600;
color: var(--app-accent-primary);
margin: 0 0 20px 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Color Palette - Display Specific */
.sg-color-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.sg-color-card {
background: var(--app-bg-elevated);
border: 1px solid var(--app-border-color);
border-radius: 8px;
overflow: hidden;
transition: all 0.2s ease;
&:hover {
border-color: var(--app-border-color-active);
box-shadow: 0 4px 16px rgba(2, 10, 26, 0.4);
}
}
.sg-color-swatch {
width: 100%;
height: 140px;
border-bottom: 1px solid var(--app-divider);
}
.sg-color-info {
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.sg-color-name {
font-size: 14px;
font-weight: 600;
color: var(--app-text);
}
.sg-color-code,
.sg-color-var {
font-family: 'Courier New', monospace;
font-size: 12px;
color: var(--app-text-code);
background: rgba(255, 215, 0, 0.08);
padding: 4px 8px;
border-radius: 4px;
word-break: break-all;
}
.sg-color-var {
color: var(--app-accent-primary);
background: rgba(142, 180, 255, 0.08);
}
/* Typography Display Grid */
.sg-typography-grid {
display: flex;
flex-direction: column;
gap: 32px;
}
.sg-type-item {
background: var(--app-bg-elevated);
border-left: 3px solid var(--app-accent-primary);
padding: 24px;
border-radius: 4px;
}
/* Demo Grid & Sections */
.sg-demo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.sg-demo-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
background: var(--app-bg-elevated);
border: 1px solid var(--app-border-color);
border-radius: 8px;
}
.sg-demo-label {
font-size: 12px;
color: var(--app-text-muted);
text-align: center;
}
.sg-demo-inline {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 20px;
background: var(--app-bg-elevated);
border: 1px solid var(--app-border-color);
border-radius: 8px;
}
.sg-demo-block {
padding: 20px;
background: var(--app-bg-elevated);
border-left: 3px solid var(--app-accent-primary);
border-radius: 4px;
p {
margin: 12px 0;
font-size: 14px;
line-height: 1.6;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
}
/* Cards Display */
.sg-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
}
.sg-card {
border-radius: 8px;
transition: all 0.2s ease;
overflow: hidden;
&:hover {
box-shadow: 0 8px 24px rgba(2, 10, 26, 0.4);
}
}
.sg-card-default {
background: rgba(11, 39, 82, 0.5);
border: 1px solid var(--app-border-color);
}
.sg-card-elevated {
background: linear-gradient(135deg, rgba(11, 39, 82, 0.7) 0%, rgba(15, 48, 95, 0.5) 100%);
border: 1px solid var(--app-border-color-active);
box-shadow: 0 4px 16px rgba(2, 10, 26, 0.3);
}
.sg-card-accent {
background: rgba(11, 39, 82, 0.5);
border: 2px solid var(--app-accent-primary);
}
.sg-card-header {
padding: 16px;
border-bottom: 1px solid var(--app-divider);
h4 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--app-text);
}
}
.sg-card-body {
padding: 16px;
p {
margin: 0;
font-size: 14px;
color: var(--app-text-muted);
line-height: 1.6;
}
}
/* Form Display Grid */
.sg-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 24px;
}
.sg-form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Spacing & Effects Display */
.sg-spacing-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 24px;
}
.sg-spacing-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
span {
font-size: 12px;
color: var(--app-text-muted);
font-family: 'Courier New', monospace;
}
}
.sg-spacing-box {
width: 100%;
background: linear-gradient(135deg, var(--app-accent-primary) 0%, var(--app-accent-blue) 100%);
border-radius: 4px;
}
.sg-effects-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 20px;
}
.sg-effect-box {
padding: 40px 20px;
background: var(--app-bg-elevated);
border: 1px solid var(--app-border-color);
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 13px;
font-weight: 500;
color: var(--app-text-muted);
}
.sg-shadow-box {
padding: 40px 20px;
background: var(--app-bg-elevated);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 13px;
font-weight: 500;
color: var(--app-text);
}
.sg-shadow-sm {
box-shadow: 0 2px 8px rgba(2, 10, 26, 0.15);
}
.sg-shadow-md {
box-shadow: 0 4px 16px rgba(2, 10, 26, 0.3);
}
.sg-shadow-lg {
box-shadow: 0 8px 32px rgba(2, 10, 26, 0.5);
}
/* Footer */
.sg-footer-text {
font-size: 14px;
color: var(--app-text-muted);
margin: 0;
line-height: 1.6;
}
/* Responsive */
@media (max-width: 768px) {
.sg-container {
padding: 0 16px 40px 16px;
}
.sg-title {
font-size: 28px;
}
.sg-section-title {
font-size: 24px;
}
.sg-color-grid,
.sg-demo-grid,
.sg-card-grid,
.sg-form-grid,
.sg-spacing-grid,
.sg-effects-grid {
grid-template-columns: 1fr;
}
.sg-type-h1 {
font-size: 24px;
}
.sg-type-h2 {
font-size: 20px;
}
}

421
ui/src/pages/StyleGuide.tsx Normal file
View File

@@ -0,0 +1,421 @@
import React from 'react';
import './StyleGuide.scss';
const StyleGuide: React.FC = () => {
return (
<div className="style-guide">
{/* Header */}
<div className="sg-header">
<div className="sg-container">
<h1 className="sg-title">Visual Style Guide</h1>
<p className="sg-subtitle">Professional technical design system for HamView</p>
</div>
</div>
{/* Main Content */}
<div className="sg-container">
{/* Color Palette Section */}
<section className="sg-section">
<h2 className="sg-section-title">Color Palette</h2>
{/* Base Colors */}
<div className="sg-subsection">
<h3 className="sg-subsection-title">Base Colors</h3>
<div className="sg-color-grid">
<div className="sg-color-card">
<div className="sg-color-swatch" style={{ backgroundColor: '#071b3a' }}></div>
<div className="sg-color-info">
<span className="sg-color-name">Primary Background</span>
<code className="sg-color-code">#071b3a</code>
<code className="sg-color-var">--app-bg</code>
</div>
</div>
<div className="sg-color-card">
<div className="sg-color-swatch" style={{ backgroundColor: '#0b2752' }}></div>
<div className="sg-color-info">
<span className="sg-color-name">Elevated Background</span>
<code className="sg-color-code">#0b2752</code>
<code className="sg-color-var">--app-bg-elevated</code>
</div>
</div>
<div className="sg-color-card">
<div className="sg-color-swatch" style={{ backgroundColor: '#e8efff' }}></div>
<div className="sg-color-info">
<span className="sg-color-name">Primary Text</span>
<code className="sg-color-code">#e8efff</code>
<code className="sg-color-var">--app-text</code>
</div>
</div>
<div className="sg-color-card">
<div className="sg-color-swatch" style={{ backgroundColor: '#afc1e6' }}></div>
<div className="sg-color-info">
<span className="sg-color-name">Muted Text</span>
<code className="sg-color-code">#afc1e6</code>
<code className="sg-color-var">--app-text-muted</code>
</div>
</div>
</div>
</div>
{/* Accent Colors */}
<div className="sg-subsection">
<h3 className="sg-subsection-title">Accent Colors</h3>
<div className="sg-color-grid">
<div className="sg-color-card">
<div className="sg-color-swatch" style={{ backgroundColor: '#8eb4ff' }}></div>
<div className="sg-color-info">
<span className="sg-color-name">Primary Blue</span>
<code className="sg-color-code">#8eb4ff</code>
<code className="sg-color-var">--app-accent-primary</code>
</div>
</div>
<div className="sg-color-card">
<div className="sg-color-swatch" style={{ backgroundColor: '#5a9eff' }}></div>
<div className="sg-color-info">
<span className="sg-color-name">Secondary Blue</span>
<code className="sg-color-code">#5a9eff</code>
<code className="sg-color-var">--app-accent-blue</code>
</div>
</div>
<div className="sg-color-card">
<div className="sg-color-swatch" style={{ backgroundColor: '#ffd700' }}></div>
<div className="sg-color-info">
<span className="sg-color-name">Yellow Accent</span>
<code className="sg-color-code">#ffd700</code>
<code className="sg-color-var">--app-accent-yellow</code>
</div>
</div>
<div className="sg-color-card">
<div className="sg-color-swatch" style={{ backgroundColor: '#a8c5ff' }}></div>
<div className="sg-color-info">
<span className="sg-color-name">Light Blue</span>
<code className="sg-color-code">#a8c5ff</code>
<code className="sg-color-var">--app-blue-light</code>
</div>
</div>
</div>
</div>
{/* Status Colors */}
<div className="sg-subsection">
<h3 className="sg-subsection-title">Status Colors</h3>
<div className="sg-color-grid">
<div className="sg-color-card">
<div className="sg-color-swatch" style={{ backgroundColor: '#4ade80' }}></div>
<div className="sg-color-info">
<span className="sg-color-name">Success</span>
<code className="sg-color-code">#4ade80</code>
<code className="sg-color-var">--app-status-success</code>
</div>
</div>
<div className="sg-color-card">
<div className="sg-color-swatch" style={{ backgroundColor: '#facc15' }}></div>
<div className="sg-color-info">
<span className="sg-color-name">Warning</span>
<code className="sg-color-code">#facc15</code>
<code className="sg-color-var">--app-status-warning</code>
</div>
</div>
<div className="sg-color-card">
<div className="sg-color-swatch" style={{ backgroundColor: '#ef4444' }}></div>
<div className="sg-color-info">
<span className="sg-color-name">Error</span>
<code className="sg-color-code">#ef4444</code>
<code className="sg-color-var">--app-status-error</code>
</div>
</div>
<div className="sg-color-card">
<div className="sg-color-swatch" style={{ backgroundColor: '#60a5fa' }}></div>
<div className="sg-color-info">
<span className="sg-color-name">Info</span>
<code className="sg-color-code">#60a5fa</code>
<code className="sg-color-var">--app-status-info</code>
</div>
</div>
</div>
</div>
</section>
{/* Typography Section */}
<section className="sg-section">
<h2 className="sg-section-title">Typography</h2>
<div className="sg-subsection">
<h3 className="sg-subsection-title">Font Family: Inter, -apple-system, sans-serif</h3>
<div className="sg-typography-grid">
<div className="sg-type-item">
<h1 className="type-h1">Heading 1 - Page Title</h1>
<span className="type-meta">32px 700 Line Height: 1.2</span>
</div>
<div className="sg-type-item">
<h2 className="type-h2">Heading 2 - Section Title</h2>
<span className="type-meta">24px 700 Line Height: 1.3</span>
</div>
<div className="sg-type-item">
<h3 className="type-h3">Heading 3 - Component Title</h3>
<span className="type-meta">18px 600 Line Height: 1.4</span>
</div>
<div className="sg-type-item">
<p className="type-body">Body text - Regular paragraph content</p>
<span className="type-meta">14px 400 Line Height: 1.6</span>
</div>
<div className="sg-type-item">
<p className="type-small">Small text - Secondary information</p>
<span className="type-meta">12px 400 Line Height: 1.5</span>
</div>
<div className="sg-type-item">
<code className="type-code">const x = "monospace code";</code>
<span className="type-meta">13px 400 Courier New/Monospace</span>
</div>
</div>
</div>
</section>
{/* Buttons Section */}
<section className="sg-section">
<h2 className="sg-section-title">Buttons</h2>
<div className="sg-subsection">
<h3 className="sg-subsection-title">Button States</h3>
<div className="sg-demo-grid">
<div className="sg-demo-item">
<button className="btn btn-primary">Primary Button</button>
<span className="sg-demo-label">Primary</span>
</div>
<div className="sg-demo-item">
<button className="btn btn-primary" disabled>
Disabled
</button>
<span className="sg-demo-label">Primary Disabled</span>
</div>
<div className="sg-demo-item">
<button className="btn btn-secondary">Secondary Button</button>
<span className="sg-demo-label">Secondary</span>
</div>
<div className="sg-demo-item">
<button className="btn btn-tertiary">Tertiary Button</button>
<span className="sg-demo-label">Tertiary</span>
</div>
<div className="sg-demo-item">
<button className="btn btn-danger">Danger Button</button>
<span className="sg-demo-label">Danger</span>
</div>
<div className="sg-demo-item">
<button className="btn btn-success">Success Button</button>
<span className="sg-demo-label">Success</span>
</div>
</div>
</div>
</section>
{/* Badges Section */}
<section className="sg-section">
<h2 className="sg-section-title">Badges & Tags</h2>
<div className="sg-subsection">
<h3 className="sg-subsection-title">Status Badges</h3>
<div className="sg-demo-inline">
<span className="badge badge-success">Online</span>
<span className="badge badge-warning">Pending</span>
<span className="badge badge-error">Offline</span>
<span className="badge badge-info">Active</span>
</div>
</div>
<div className="sg-subsection">
<h3 className="sg-subsection-title">Technical Tags</h3>
<div className="sg-demo-inline">
<span className="tag tag-blue">TypeScript</span>
<span className="tag tag-blue">Component</span>
<span className="tag tag-yellow">Core</span>
<span className="tag tag-yellow">Radio</span>
</div>
</div>
</section>
{/* Cards Section */}
<section className="sg-section">
<h2 className="sg-section-title">Cards & Containers</h2>
<div className="sg-subsection">
<h3 className="sg-subsection-title">Card Styles</h3>
<div className="sg-card-grid">
<div className="sg-card sg-card-default">
<div className="sg-card-header">
<h4>Default Card</h4>
</div>
<div className="sg-card-body">
<p>Standard content area with consistent spacing and typography.</p>
</div>
</div>
<div className="sg-card sg-card-elevated">
<div className="sg-card-header">
<h4>Elevated Card</h4>
</div>
<div className="sg-card-body">
<p>Higher elevation for primary focus areas and important content.</p>
</div>
</div>
<div className="sg-card sg-card-accent">
<div className="sg-card-header">
<h4>Accent Card</h4>
</div>
<div className="sg-card-body">
<p>Emphasizes technical information with blue accent border.</p>
</div>
</div>
</div>
</div>
</section>
{/* Input Section */}
<section className="sg-section">
<h2 className="sg-section-title">Form Elements</h2>
<div className="sg-subsection">
<h3 className="sg-subsection-title">Input Fields</h3>
<div className="sg-form-grid">
<div className="sg-form-group">
<label className="form-label">Text Input</label>
<input
type="text"
className="form-input"
placeholder="Enter text here..."
/>
</div>
<div className="sg-form-group">
<label className="form-label">Search Input</label>
<input
type="text"
className="form-input form-input-search"
placeholder="Search..."
/>
</div>
<div className="sg-form-group">
<label className="form-label">Code Input</label>
<input
type="text"
className="form-input form-input-code"
value="const value = 'monospace';"
readOnly
/>
</div>
<div className="sg-form-group">
<label className="form-label">Select Dropdown</label>
<select className="form-select">
<option>Option 1</option>
<option>Option 2</option>
<option>Option 3</option>
</select>
</div>
</div>
</div>
</section>
{/* Code Block Section */}
<section className="sg-section">
<h2 className="sg-section-title">Code & Technical Elements</h2>
<div className="sg-subsection">
<h3 className="sg-subsection-title">Code Block</h3>
<div className="code-block">
<pre><code>{`/* CSS Variables - Easy Reuse */
:root {
--app-accent-primary: #8eb4ff;
--app-accent-yellow: #ffd700;
--app-status-success: #4ade80;
}
/* Usage Example */
.component {
color: var(--app-text);
background: var(--app-bg-elevated);
border: 1px solid var(--app-border-color);
}`}</code></pre>
</div>
</div>
<div className="sg-subsection">
<h3 className="sg-subsection-title">Inline Code & Emphasis</h3>
<div className="sg-demo-block">
<p>
Use <code className="inline-code">className</code> for CSS classes and <code className="inline-code">var(--app-accent-yellow)</code> for CSS variables.
</p>
<p>
Technical terms like <span className="highlight-tech">HamView</span> or <span className="highlight-tech">APRS</span> can be emphasized.
</p>
</div>
</div>
</section>
{/* Grid & Spacing Section */}
<section className="sg-section">
<h2 className="sg-section-title">Spacing & Layout</h2>
<div className="sg-subsection">
<h3 className="sg-subsection-title">Spacing Scale</h3>
<div className="sg-spacing-grid">
<div className="sg-spacing-item">
<div className="sg-spacing-box" style={{ padding: '4px' }}></div>
<span>4px (xs)</span>
</div>
<div className="sg-spacing-item">
<div className="sg-spacing-box" style={{ padding: '8px' }}></div>
<span>8px (sm)</span>
</div>
<div className="sg-spacing-item">
<div className="sg-spacing-box" style={{ padding: '12px' }}></div>
<span>12px (md)</span>
</div>
<div className="sg-spacing-item">
<div className="sg-spacing-box" style={{ padding: '16px' }}></div>
<span>16px (lg)</span>
</div>
<div className="sg-spacing-item">
<div className="sg-spacing-box" style={{ padding: '24px' }}></div>
<span>24px (xl)</span>
</div>
<div className="sg-spacing-item">
<div className="sg-spacing-box" style={{ padding: '32px' }}></div>
<span>32px (2xl)</span>
</div>
</div>
</div>
</section>
{/* Borders & Shadows Section */}
<section className="sg-section">
<h2 className="sg-section-title">Borders & Effects</h2>
<div className="sg-subsection">
<h3 className="sg-subsection-title">Border Radius</h3>
<div className="sg-effects-grid">
<div className="sg-effect-box" style={{ borderRadius: '0' }}>Square</div>
<div className="sg-effect-box" style={{ borderRadius: '4px' }}>4px</div>
<div className="sg-effect-box" style={{ borderRadius: '8px' }}>8px</div>
<div className="sg-effect-box" style={{ borderRadius: '12px' }}>12px</div>
</div>
</div>
<div className="sg-subsection">
<h3 className="sg-subsection-title">Shadow Elevations</h3>
<div className="sg-effects-grid">
<div className="sg-shadow-box sg-shadow-sm">Slight Elevation</div>
<div className="sg-shadow-box sg-shadow-md">Medium Elevation</div>
<div className="sg-shadow-box sg-shadow-lg">High Elevation</div>
</div>
</div>
</section>
{/* Footer Section */}
<section className="sg-section sg-footer">
<p className="sg-footer-text">
This style guide is a living document. All CSS variables are centralized in <code className="inline-code">App.scss</code> for easy customization and consistency across the application.
</p>
</section>
</div>
</div>
);
};
export default StyleGuide;

View File

@@ -0,0 +1,240 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import type { Frame } from '../../protocols/aprs';
import { parseFrame } from '../../protocols/aprs';
import API from '../../services/API';
import APRSServiceImpl, { type FetchedAPRSPacket } from '../../services/APRSService';
import APRSStream, { type APRSMessage } from '../../services/APRSStream';
export interface APRSPacketRecord {
timestamp: Date;
raw: string;
frame: Frame;
latitude?: number;
longitude?: number;
altitude?: number;
speed?: number;
course?: number;
comment?: string;
radioName?: string;
}
interface APRSDataContextValue {
packets: APRSPacketRecord[];
}
const APRSDataContext = createContext<APRSDataContextValue | null>(null);
export const useAPRSData = (): APRSDataContextValue => {
const context = useContext(APRSDataContext);
if (!context) {
throw new Error('useAPRSData must be used within APRSDataProvider');
}
return context;
};
const aprsService = new APRSServiceImpl(API);
const maxPacketCount = 500;
const fromNullableNumber = (value: unknown): number | undefined => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (value && typeof value === 'object') {
const candidate = value as { Float64?: number; Valid?: boolean };
if (candidate.Valid && typeof candidate.Float64 === 'number' && Number.isFinite(candidate.Float64)) {
return candidate.Float64;
}
}
return undefined;
};
const extractAPRSDetails = (frame: Frame) => {
const decoded = frame.decode();
let latitude: number | undefined;
let longitude: number | undefined;
let altitude: number | undefined;
let speed: number | undefined;
let course: number | undefined;
let comment: string | undefined;
if (decoded && typeof decoded === 'object') {
if ('position' in decoded && decoded.position) {
latitude = decoded.position.latitude;
longitude = decoded.position.longitude;
altitude = decoded.position.altitude;
comment = decoded.position.comment;
}
if ('altitude' in decoded && typeof decoded.altitude === 'number') {
altitude = decoded.altitude;
}
if ('speed' in decoded && typeof decoded.speed === 'number') {
speed = decoded.speed;
}
if ('course' in decoded && typeof decoded.course === 'number') {
course = decoded.course;
}
if ('text' in decoded && typeof decoded.text === 'string' && !comment) {
comment = decoded.text;
}
}
return {
latitude,
longitude,
altitude,
speed,
course,
comment,
};
};
const parseTimestamp = (input: unknown): Date => {
if (typeof input === 'string' && input.trim().length > 0) {
const parsed = new Date(input);
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
}
return new Date();
};
const mergePackets = (incoming: APRSPacketRecord[], current: APRSPacketRecord[]): APRSPacketRecord[] => {
const merged = [...incoming, ...current];
const byKey = new Map<string, APRSPacketRecord>();
merged.forEach((packet) => {
const key = `${packet.timestamp.toISOString()}|${packet.raw}|${packet.radioName ?? ''}`;
if (!byKey.has(key)) {
byKey.set(key, packet);
}
});
return Array.from(byKey.values())
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, maxPacketCount);
};
const buildRecord = ({
raw,
timestamp,
radioName,
comment,
latitude,
longitude,
}: {
raw: string;
timestamp: Date;
radioName?: string;
comment?: string;
latitude?: number;
longitude?: number;
}): APRSPacketRecord | null => {
try {
const frame = parseFrame(raw);
const details = extractAPRSDetails(frame);
return {
timestamp,
raw,
frame,
latitude: latitude ?? details.latitude,
longitude: longitude ?? details.longitude,
altitude: details.altitude,
speed: details.speed,
course: details.course,
comment: comment ?? details.comment,
radioName,
};
} catch {
return null;
}
};
const toRecordFromAPI = (packet: FetchedAPRSPacket): APRSPacketRecord | null => {
const raw = packet.raw ?? packet.payload;
if (!raw) {
return null;
}
return buildRecord({
raw,
timestamp: parseTimestamp(packet.received_at ?? packet.timestamp ?? packet.time ?? packet.created_at),
radioName: packet.radio?.name,
comment: packet.comment,
latitude: fromNullableNumber(packet.latitude),
longitude: fromNullableNumber(packet.longitude),
});
};
const toRecordFromStream = (message: APRSMessage): APRSPacketRecord | null => {
return buildRecord({
raw: message.raw,
timestamp: message.receivedAt,
radioName: message.radioName,
});
};
interface APRSDataProviderProps {
children: React.ReactNode;
}
export const APRSDataProvider: React.FC<APRSDataProviderProps> = ({ children }) => {
const [packets, setPackets] = useState<APRSPacketRecord[]>([]);
const stream = useMemo(() => new APRSStream(false), []);
useEffect(() => {
let isMounted = true;
const fetchPackets = async () => {
try {
const fetchedPackets = await aprsService.fetchPackets();
if (!isMounted) {
return;
}
const records = fetchedPackets
.map(toRecordFromAPI)
.filter((packet): packet is APRSPacketRecord => packet !== null);
setPackets((prev) => mergePackets(records, prev));
} catch (error) {
console.error('Failed to fetch APRS packets:', error);
}
};
fetchPackets();
stream.connect();
const unsubscribePackets = stream.subscribe<APRSMessage>('aprs/packet/#', (message) => {
const record = toRecordFromStream(message);
if (!record) {
return;
}
setPackets((prev) => mergePackets([record], prev));
});
return () => {
isMounted = false;
unsubscribePackets();
stream.disconnect();
};
}, [stream]);
const value = useMemo(
() => ({
packets,
}),
[packets]
);
return <APRSDataContext.Provider value={value}>{children}</APRSDataContext.Provider>;
};

View File

@@ -0,0 +1,221 @@
import React, { useMemo, useState } from 'react';
import { Badge, Card, Stack, Table, Alert } from 'react-bootstrap';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import { useSearchParams } from 'react-router';
import VerticalSplit from '../../components/VerticalSplit';
import HorizontalSplit from '../../components/HorizontalSplit';
import { useAPRSData } from './APRSData';
import type { APRSPacketRecord } from './APRSData';
const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div className="aprs-fact-row">
<span className="aprs-fact-label">{label}</span>
<span className="aprs-fact-value">{value}</span>
</div>
);
const APRSMapPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => {
const hasPosition = packet && packet.latitude && packet.longitude;
// Create bounds from center point (offset by ~2 degrees)
const createBoundsFromCenter = (lat: number, lng: number): [[number, number], [number, number]] => {
const offset = 2;
return [[lat - offset, lng - offset], [lat + offset, lng + offset]];
};
const bounds = hasPosition
? createBoundsFromCenter(packet.latitude as number, packet.longitude as number)
: createBoundsFromCenter(50.0, 5.0);
return (
<MapContainer
bounds={bounds}
style={{ height: '100%', width: '100%' }}
className="aprs-map"
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{hasPosition && (
<Marker position={[packet.latitude as number, packet.longitude as number]}>
<Popup>
<div>
<strong>{packet.frame.source.call}</strong>
{packet.frame.source.ssid && <span>-{packet.frame.source.ssid}</span>}
<br />
Lat: {packet.latitude?.toFixed(4)}, Lon: {packet.longitude?.toFixed(4)}
{packet.altitude && <div>Alt: {packet.altitude.toFixed(0)}m</div>}
{packet.speed && <div>Speed: {packet.speed}kt</div>}
</div>
</Popup>
</Marker>
)}
</MapContainer>
);
};
const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => {
if (!packet) {
return (
<Card body className="aprs-detail-card h-100">
<h6>Select a packet</h6>
<div>Click any packet in the list to view details and map.</div>
</Card>
);
}
return (
<Stack gap={2} className="h-100 aprs-detail-stack">
<Card body className="aprs-detail-card">
<Stack direction="horizontal" gap={2} className="mb-2">
<h6 className="mb-0">Packet Details</h6>
<Badge bg="primary">{packet.frame.source.call}</Badge>
</Stack>
<HeaderFact label="Timestamp" value={packet.timestamp.toLocaleTimeString()} />
<HeaderFact
label="Source"
value={
<>
{packet.frame.source.call}
{packet.frame.source.ssid && <span>-{packet.frame.source.ssid}</span>}
</>
}
/>
{packet.radioName && <HeaderFact label="Radio" value={packet.radioName} />}
<HeaderFact
label="Destination"
value={
<>
{packet.frame.destination.call}
{packet.frame.destination.ssid && <span>-{packet.frame.destination.ssid}</span>}
</>
}
/>
<HeaderFact label="Path" value={packet.frame.path.map((addr) => `${addr.call}${addr.ssid ? `-${addr.ssid}` : ''}${addr.isRepeated ? '*' : ''}`).join(', ') || 'None'} />
</Card>
{(packet.latitude || packet.longitude || packet.altitude || packet.speed || packet.course) && (
<Card body className="aprs-detail-card">
<h6 className="mb-2">Position Data</h6>
{packet.latitude && <HeaderFact label="Latitude" value={packet.latitude.toFixed(6)} />}
{packet.longitude && <HeaderFact label="Longitude" value={packet.longitude.toFixed(6)} />}
{packet.altitude && <HeaderFact label="Altitude" value={`${packet.altitude.toFixed(0)} m`} />}
{packet.speed !== undefined && <HeaderFact label="Speed" value={`${packet.speed} kt`} />}
{packet.course !== undefined && <HeaderFact label="Course" value={`${packet.course}°`} />}
</Card>
)}
{packet.comment && (
<Card body className="aprs-detail-card">
<h6 className="mb-2">Comment</h6>
<div>{packet.comment}</div>
</Card>
)}
<Card body className="aprs-detail-card">
<h6 className="mb-2">Raw Data</h6>
<code className="aprs-raw-code">{packet.raw}</code>
</Card>
</Stack>
);
};
const PacketTable: React.FC<{
packets: APRSPacketRecord[];
selectedIndex: number | null;
onSelect: (index: number) => void;
}> = ({ packets, selectedIndex, onSelect }) => {
const [searchParams, setSearchParams] = useSearchParams();
const radioFilter = searchParams.get('radio') || undefined;
// Filter packets by radio name if specified
const filteredPackets = useMemo(() => {
if (!radioFilter) return packets;
return packets.filter(packet => packet.radioName === radioFilter);
}, [packets, radioFilter]);
return (
<Card className="aprs-table-card h-100 d-flex flex-column">
<Card.Header className="aprs-table-header">APRS Packets</Card.Header>
<Card.Body className="aprs-table-body p-0">
{radioFilter && (
<Alert variant="info" className="m-2 mb-0 d-flex align-items-center justify-content-between" style={{ fontSize: '0.875rem', padding: '0.5rem 0.75rem' }}>
<span>Filtering by radio: <strong>{radioFilter}</strong></span>
<button
type="button"
className="btn-close btn-close-white"
style={{ fontSize: '0.7rem' }}
onClick={() => {
const newParams = new URLSearchParams(searchParams);
newParams.delete('radio');
setSearchParams(newParams);
}}
aria-label="Clear radio filter"
/>
</Alert>
)}
<div className="aprs-table-scroll">
<Table hover responsive className="aprs-table mb-0" size="sm">
<thead>
<tr>
<th>Time</th>
<th>Source</th>
<th>Destination</th>
<th>Position</th>
<th>Comment</th>
</tr>
</thead>
<tbody>
{filteredPackets.map((packet, index) => (
<tr
key={`${packet.frame.source.call}-${packet.timestamp.toISOString()}`}
className={selectedIndex === index ? 'is-selected' : ''}
onClick={() => onSelect(index)}
>
<td>{packet.timestamp.toLocaleTimeString()}</td>
<td>
{packet.frame.source.call}
{packet.frame.source.ssid && <span>-{packet.frame.source.ssid}</span>}
</td>
<td>
{packet.frame.destination.call}
{packet.frame.destination.ssid && <span>-{packet.frame.destination.ssid}</span>}
</td>
<td>{packet.latitude && packet.longitude ? '✓' : '-'}</td>
<td>{packet.comment || '-'}</td>
</tr>
))}
</tbody>
</Table>
</div>
</Card.Body>
</Card>
);
};
const APRSPacketsView: React.FC = () => {
const { packets } = useAPRSData();
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const selectedPacket = useMemo(() => {
if (selectedIndex === null || selectedIndex < 0 || selectedIndex >= packets.length) {
return null;
}
return packets[selectedIndex] ?? null;
}, [packets, selectedIndex]);
return (
<VerticalSplit
ratio="50/50"
left={<PacketTable packets={packets} selectedIndex={selectedIndex} onSelect={setSelectedIndex} />}
right={
<HorizontalSplit
top={<APRSMapPane packet={selectedPacket} />}
bottom={<PacketDetailsPane packet={selectedPacket} />}
/>
}
/>
);
};
export default APRSPacketsView;

View File

@@ -0,0 +1,50 @@
import { createContext, useContext } from 'react';
export interface MeshCorePacketRecord {
timestamp: Date;
hash: string;
nodeType: number;
payloadType: number;
routeType: number;
version: number;
path: Uint8Array;
raw: Uint8Array;
decodedPayload?: unknown;
payloadSummary: string;
radioName?: string;
}
export interface MeshCoreGroupChatRecord {
hash: string;
timestamp: Date;
channel: string;
sender: string;
message: string;
}
export interface MeshCoreNodePoint {
nodeId: string;
nodeType: number;
packetCount: number;
latitude: number;
longitude: number;
}
export interface MeshCoreDataContextValue {
packets: MeshCorePacketRecord[];
groupChats: MeshCoreGroupChatRecord[];
mapPoints: MeshCoreNodePoint[];
streamReady: boolean;
}
export const MeshCoreDataContext = createContext<MeshCoreDataContextValue | null>(null);
export const useMeshCoreData = (): MeshCoreDataContextValue => {
const ctx = useContext(MeshCoreDataContext);
if (!ctx) {
throw new Error('useMeshCoreData must be used within MeshCoreDataProvider');
}
return ctx;
};
export default MeshCoreDataContext;

View File

@@ -0,0 +1,385 @@
import React, { useEffect, useMemo, useState } from 'react';
import { bytesToHex } from '@noble/hashes/utils.js';
import { Packet } from '../../protocols/meshcore';
import { NodeType, PayloadType, RouteType } from '../../protocols/meshcore.types';
import { MeshCoreStream } from '../../services/MeshCoreStream';
import type { MeshCoreMessage } from '../../services/MeshCoreStream';
import type { Payload } from '../../protocols/meshcore.types';
import {
MeshCoreDataContext,
type MeshCoreDataContextValue,
type MeshCorePacketRecord,
type MeshCoreGroupChatRecord,
type MeshCoreNodePoint,
} from './MeshCoreContext';
export {
MeshCoreDataContext,
useMeshCoreData,
type MeshCoreDataContextValue,
type MeshCorePacketRecord,
type MeshCoreGroupChatRecord,
type MeshCoreNodePoint,
} from './MeshCoreContext';
export const payloadNameByValue = Object.fromEntries(
Object.entries(PayloadType).map(([name, value]) => [value, name])
) as Record<number, string>;
export const nodeTypeNameByValue = Object.fromEntries(
Object.entries(NodeType).map(([name, value]) => [value, name])
) as Record<number, string>;
export const routeTypeNameByValue = Object.fromEntries(
Object.entries(RouteType).map(([name, value]) => [value, name])
) as Record<number, string>;
export const payloadValueByName = Object.fromEntries(
Object.entries(PayloadType).map(([name, value]) => [name, value])
) as Record<string, number>;
export const nodeTypeValueByName = Object.fromEntries(
Object.entries(NodeType).map(([name, value]) => [name, value])
) as Record<string, number>;
export const routeTypeValueByName = Object.fromEntries(
Object.entries(RouteType).map(([name, value]) => [name, value])
) as Record<string, number>;
// Human-readable display names (for UI)
export const nodeTypeDisplayByValue: Record<number, string> = {
[NodeType.TYPE_UNKNOWN]: 'Unknown',
[NodeType.TYPE_CHAT_NODE]: 'Chat Node',
[NodeType.TYPE_REPEATER]: 'Repeater',
[NodeType.TYPE_ROOM_SERVER]: 'Room Server',
[NodeType.TYPE_SENSOR]: 'Sensor',
};
export const payloadDisplayByValue: Record<number, string> = Object.fromEntries(
Object.entries(PayloadType).map(([name, value]) => {
const cleanName = name.replace(/^PAYLOAD_TYPE_/, '').replace(/_/g, ' ');
return [value, cleanName.charAt(0) + cleanName.slice(1).toLowerCase()];
})
) as Record<number, string>;
export const routeDisplayByValue: Record<number, string> = Object.fromEntries(
Object.entries(RouteType).map(([name, value]) => {
const cleanName = name.replace(/^ROUTE_TYPE_/, '').replace(/_/g, ' ');
return [value, cleanName.charAt(0) + cleanName.slice(1).toLowerCase()];
})
) as Record<number, string>;
// URL-friendly names (lowercase, no TYPE_ prefix)
export const nodeTypeUrlByValue: Record<number, string> = {
[NodeType.TYPE_UNKNOWN]: 'unknown',
[NodeType.TYPE_CHAT_NODE]: 'chat_node',
[NodeType.TYPE_REPEATER]: 'repeater',
[NodeType.TYPE_ROOM_SERVER]: 'room_server',
[NodeType.TYPE_SENSOR]: 'sensor',
};
export const nodeTypeValueByUrl: Record<string, number> = {
'unknown': NodeType.TYPE_UNKNOWN,
'chat_node': NodeType.TYPE_CHAT_NODE,
'repeater': NodeType.TYPE_REPEATER,
'room_server': NodeType.TYPE_ROOM_SERVER,
'sensor': NodeType.TYPE_SENSOR,
};
export const payloadUrlByValue: Record<number, string> = Object.fromEntries(
Object.entries(PayloadType).map(([name, value]) => {
const urlName = name.replace(/^PAYLOAD_TYPE_/, '').toLowerCase();
return [value, urlName];
})
) as Record<number, string>;
export const payloadValueByUrl: Record<string, number> = Object.fromEntries(
Object.entries(PayloadType).map(([name, value]) => {
const urlName = name.replace(/^PAYLOAD_TYPE_/, '').toLowerCase();
return [urlName, value];
})
) as Record<string, number>;
export const routeUrlByValue: Record<number, string> = Object.fromEntries(
Object.entries(RouteType).map(([name, value]) => {
const urlName = name.replace(/^ROUTE_TYPE_/, '').toLowerCase();
return [value, urlName];
})
) as Record<number, string>;
export const routeValueByUrl: Record<string, number> = Object.fromEntries(
Object.entries(RouteType).map(([name, value]) => {
const urlName = name.replace(/^ROUTE_TYPE_/, '').toLowerCase();
return [urlName, value];
})
) as Record<string, number>;
export const asHex = (value: Uint8Array): string => bytesToHex(value);
const payloadTypeList = [
PayloadType.REQUEST,
PayloadType.RESPONSE,
PayloadType.TEXT,
PayloadType.ACK,
PayloadType.ADVERT,
PayloadType.GROUP_TEXT,
PayloadType.GROUP_DATA,
PayloadType.ANON_REQ,
PayloadType.PATH,
PayloadType.TRACE,
PayloadType.MULTIPART,
PayloadType.CONTROL,
PayloadType.RAW_CUSTOM,
] as const;
const nodeTypeList = [
NodeType.TYPE_CHAT_NODE,
NodeType.TYPE_REPEATER,
NodeType.TYPE_ROOM_SERVER,
NodeType.TYPE_SENSOR,
] as const;
const makePayloadBytes = (payloadType: number, seed: number): Uint8Array => {
switch (payloadType) {
case PayloadType.REQUEST:
return new Uint8Array([0x00, 0x12, 0x34, 0x56, 0x78, seed]);
case PayloadType.RESPONSE:
return new Uint8Array([0x01, 0x78, 0x56, 0x34, 0x12, seed]);
case PayloadType.TEXT:
return new Uint8Array([0xa1, 0xb2, 0x11, 0x22, 0x54, 0x58, 0x54, seed]);
case PayloadType.ACK:
return new Uint8Array([0x03, seed]);
case PayloadType.ADVERT:
return new Uint8Array([0x04, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, seed]);
case PayloadType.GROUP_TEXT:
return new Uint8Array([0xc4, 0x23, 0x99, 0x44, 0x55, 0x66, seed]);
case PayloadType.GROUP_DATA:
return new Uint8Array([0x34, 0x98, 0x76, 0x10, seed, 0xee]);
case PayloadType.ANON_REQ:
return new Uint8Array([0xfe, ...Array.from({ length: 32 }, (_, i) => (seed + i * 5) & 0xff), 0x55, 0xaa, 0x42, seed]);
case PayloadType.PATH:
return new Uint8Array([0x08, 0x11, 0x22, 0x33, 0x44, 0x55, seed]);
case PayloadType.TRACE:
return new Uint8Array([0x12, 0x31, 0x51, seed]);
case PayloadType.MULTIPART:
return new Uint8Array([0x01, seed, 0x02, 0x03, 0x04]);
case PayloadType.CONTROL:
return new Uint8Array([0x90, 0x01, 0x02, seed]);
case PayloadType.RAW_CUSTOM:
default:
return new Uint8Array([0xde, 0xad, 0xbe, 0xef, seed]);
}
};
const summarizePayload = (payloadType: number, decodedPayload: Payload | undefined, payloadBytes: Uint8Array): string => {
switch (payloadType) {
case PayloadType.REQUEST:
return `request len=${payloadBytes.length}`;
case PayloadType.RESPONSE:
return `response len=${payloadBytes.length}`;
case PayloadType.TEXT:
if (decodedPayload && 'dstHash' in decodedPayload && 'srcHash' in decodedPayload) {
return `text ${decodedPayload.srcHash}->${decodedPayload.dstHash}`;
}
return 'text message';
case PayloadType.ACK:
return 'acknowledgement';
case PayloadType.ADVERT:
return 'node advertisement';
case PayloadType.GROUP_TEXT:
if (decodedPayload && 'channelHash' in decodedPayload) {
return `group channel=${decodedPayload.channelHash}`;
}
return `group text len=${payloadBytes.length}`;
case PayloadType.GROUP_DATA:
return `group data len=${payloadBytes.length}`;
case PayloadType.ANON_REQ:
if (decodedPayload && 'dstHash' in decodedPayload) {
return `anon req dst=${decodedPayload.dstHash}`;
}
return 'anon request';
case PayloadType.PATH:
return `path len=${payloadBytes.length}`;
case PayloadType.TRACE:
return `trace len=${payloadBytes.length}`;
case PayloadType.MULTIPART:
return `multipart len=${payloadBytes.length}`;
case PayloadType.CONTROL:
if (decodedPayload && 'flags' in decodedPayload && typeof decodedPayload.flags === 'number') {
return `control flags=0x${decodedPayload.flags.toString(16)}`;
}
return `control raw=${asHex(payloadBytes.slice(0, 4))}`;
case PayloadType.RAW_CUSTOM:
default:
return `raw=${asHex(payloadBytes.slice(0, Math.min(6, payloadBytes.length)))}`;
}
};
const createMockRecord = (index: number): MeshCorePacketRecord => {
const payloadType = payloadTypeList[index % payloadTypeList.length];
const nodeType = nodeTypeList[index % nodeTypeList.length];
const version = 1;
const routeType = RouteType.FLOOD;
const path = new Uint8Array([(0x11 + index) & 0xff, (0x90 + index) & 0xff, (0xa0 + index) & 0xff]);
const payload = makePayloadBytes(payloadType, index);
const header = ((version & 0x03) << 6) | ((payloadType & 0x0f) << 2) | (routeType & 0x03);
const pathLength = path.length & 0x3f;
const raw = new Uint8Array([header, pathLength, ...path, ...payload]);
const packet = new Packet();
packet.parse(raw);
let decodedPayload: Payload | undefined;
try {
decodedPayload = packet.decode();
} catch {
decodedPayload = undefined;
}
return {
timestamp: new Date(Date.now() - index * 75_000),
hash: asHex(packet.hash()),
nodeType,
payloadType,
routeType,
version,
path,
raw,
decodedPayload,
payloadSummary: summarizePayload(payloadType, decodedPayload, payload),
};
};
const createMockData = (count = 48): MeshCorePacketRecord[] => {
return Array.from({ length: count }, (_, index) => createMockRecord(index)).sort(
(a, b) => b.timestamp.getTime() - a.timestamp.getTime()
);
};
const toGroupChats = (packets: MeshCorePacketRecord[]): MeshCoreGroupChatRecord[] => {
return packets
.filter((packet) => packet.payloadType === PayloadType.GROUP_TEXT || packet.payloadType === PayloadType.TEXT)
.map((packet, index) => {
const payload = packet.decodedPayload as Record<string, unknown> | undefined;
const channel = (payload && typeof payload === 'object' && 'channelHash' in payload
? (payload.channelHash as string)
: 'general') as string;
const sender = (payload && typeof payload === 'object' && 'srcHash' in payload
? (payload.srcHash as string)
: packet.path[0].toString(16).padStart(2, '0')) as string;
return {
hash: packet.hash,
timestamp: packet.timestamp,
channel,
sender,
message: `Mock message #${index + 1} (${packet.payloadSummary})`,
};
});
};
const toMapPoints = (packets: MeshCorePacketRecord[]): MeshCoreNodePoint[] => {
const byNode = new Map<string, MeshCoreNodePoint>();
packets.forEach((packet) => {
const nodeId = packet.path[0].toString(16).padStart(2, '0');
const existing = byNode.get(nodeId);
if (existing) {
existing.packetCount += 1;
return;
}
const byte = packet.path[0];
byNode.set(nodeId, {
nodeId,
nodeType: packet.nodeType,
packetCount: 1,
latitude: 52.0 + ((byte % 20) - 10) * 0.02,
longitude: 5.0 + (((byte >> 1) % 20) - 10) * 0.03,
});
});
return Array.from(byNode.values());
};
export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [packets, setPackets] = useState<MeshCorePacketRecord[]>(() => createMockData());
const [streamReady, setStreamReady] = useState(false);
const stream = useMemo(() => new MeshCoreStream(false), []);
useEffect(() => {
stream.connect();
const unsubscribeState = stream.subscribeToState((state) => {
setStreamReady(state.isConnected);
});
const unsubscribePackets = stream.subscribe<MeshCoreMessage>(
'meshcore/packet/#',
(message) => {
setPackets((prev) => {
const packet: MeshCorePacketRecord = {
timestamp: message.receivedAt,
hash: message.hash,
nodeType: 0, // Default; would be extracted from payload if needed
payloadType: 0,
routeType: 0,
version: 1,
path: new Uint8Array(),
raw: message.raw,
decodedPayload: message.decodedPayload,
payloadSummary: '',
radioName: message.radioName,
};
// Extract details from raw packet
try {
const p = new Packet();
p.parse(message.raw);
packet.version = (message.raw[0] >> 6) & 0x03;
packet.payloadType = (message.raw[0] >> 2) & 0x0f;
packet.routeType = message.raw[0] & 0x03;
const pathLength = message.raw[1] & 0x3f;
packet.path = message.raw.slice(2, 2 + pathLength);
// Summarize payload
const payloadBytes = message.raw.slice(2 + pathLength);
packet.payloadSummary = summarizePayload(packet.payloadType, message.decodedPayload, payloadBytes);
} catch (error) {
console.error('Failed to parse packet:', error);
packet.payloadSummary = `raw=${bytesToHex(packet.raw.slice(0, Math.min(6, packet.raw.length)))}`;
}
// Add to front of list, keeping last 500 packets
return [packet, ...prev].slice(0, 500);
});
}
);
return () => {
unsubscribeState();
unsubscribePackets();
stream.disconnect();
};
}, [stream]);
const groupChats = useMemo(() => toGroupChats(packets), [packets]);
const mapPoints = useMemo(() => toMapPoints(packets), [packets]);
const value = useMemo<MeshCoreDataContextValue>(() => {
return {
packets,
groupChats,
mapPoints,
streamReady,
};
}, [packets, groupChats, mapPoints, streamReady]);
return <MeshCoreDataContext.Provider value={value}>{children}</MeshCoreDataContext.Provider>;
};

View File

@@ -0,0 +1,254 @@
import React, { useEffect, useState, useMemo, useRef } from 'react';
import { Badge, Card, Stack, Spinner, Alert } from 'react-bootstrap';
import { useMeshCoreData } from './MeshCoreContext';
import VerticalSplit from '../../components/VerticalSplit';
import StreamStatus from '../../components/StreamStatus';
import MeshCoreServiceImpl, { type MeshCoreGroupRecord } from '../../services/MeshCoreService';
import API from '../../services/API';
import type { MeshCoreGroupChatRecord } from './MeshCoreContext';
import { KeyManager, Packet } from '../../protocols/meshcore';
const meshCoreService = new MeshCoreServiceImpl(API);
const GroupList: React.FC<{
groups: MeshCoreGroupRecord[];
selectedGroupId: number | null;
onSelectGroup: (group: MeshCoreGroupRecord) => void;
isLoading: boolean;
error: string | null;
streamReady: boolean;
}> = ({ groups, selectedGroupId, onSelectGroup, isLoading, error, streamReady }) => {
return (
<Card className="meshcore-table-card h-100 d-flex flex-column">
<Card.Header className="meshcore-table-header d-flex justify-content-between align-items-center">
<span>Groups</span>
<div className="d-flex align-items-center gap-2">
{isLoading && <Spinner animation="border" size="sm" style={{ width: '1rem', height: '1rem' }} />}
<StreamStatus ready={streamReady} />
</div>
</Card.Header>
<Card.Body className="meshcore-table-body p-0 d-flex flex-column">
{error && <Alert variant="danger" className="m-2 mb-0">{error}</Alert>}
{groups.length === 0 && !isLoading && (
<div className="p-3 text-secondary text-center">
{error ? 'Failed to load groups' : 'No groups available'}
</div>
)}
<div className="meshcore-table-scroll">
{groups.map((group) => (
<div
key={group.id}
onClick={() => onSelectGroup(group)}
className={`list-item ${selectedGroupId === group.id ? 'is-selected' : ''}`}
>
<div className="list-item-title">{group.name}</div>
<div className="list-item-meta">
<small>ID: {group.id}</small>
{group.isPublic && <Badge bg="info" className="ms-2">public</Badge>}
</div>
</div>
))}
</div>
</Card.Body>
</Card>
);
};
const GroupMessagesPane: React.FC<{
group: MeshCoreGroupRecord | null;
messages: MeshCoreGroupChatRecord[];
isLoading: boolean;
error: string | null;
streamReady: boolean;
}> = ({ group, messages, isLoading, error, streamReady }) => {
if (!group) {
return (
<Card body className="meshcore-detail-card h-100 d-flex flex-column justify-content-center align-items-center">
<h6 className="mb-2">Select a group</h6>
<div className="text-secondary text-center">Click a group on the left to view messages</div>
</Card>
);
}
return (
<Card className="meshcore-table-card h-100 d-flex flex-column">
<Card.Header className="meshcore-table-header d-flex justify-content-between align-items-center">
<span>{group.name}</span>
<div className="d-flex align-items-center gap-2">
{isLoading && <Spinner animation="border" size="sm" style={{ width: '1rem', height: '1rem' }} />}
<StreamStatus ready={streamReady} />
</div>
</Card.Header>
<Card.Body className="meshcore-table-body p-0 d-flex flex-column">
{error && <Alert variant="danger" className="m-2 mb-0">{error}</Alert>}
{messages.length === 0 && !isLoading && (
<div className="p-3 text-secondary text-center">No messages in this group</div>
)}
<div className="meshcore-table-scroll">
<Stack gap={3} className="p-3">
{messages.map((message) => (
<div key={message.hash + message.timestamp.toISOString()} className="meshcore-message-item">
<div className="d-flex justify-content-between align-items-center mb-1">
<strong className="meshcore-message-sender">{message.sender}</strong>
<small className="text-secondary">{message.timestamp.toLocaleTimeString()}</small>
</div>
<div className="meshcore-message-text mb-2">{message.message}</div>
<div className="d-flex justify-content-between align-items-center">
<Badge bg="primary">#{message.channel}</Badge>
<code className="meshcore-hash-code">{message.hash}</code>
</div>
</div>
))}
</Stack>
</div>
</Card.Body>
</Card>
);
};
const MeshCoreGroupChatView: React.FC = () => {
const { groupChats, streamReady } = useMeshCoreData();
const [groups, setGroups] = useState<MeshCoreGroupRecord[]>([]);
const [selectedGroup, setSelectedGroup] = useState<MeshCoreGroupRecord | null>(null);
const [isLoadingGroups, setIsLoadingGroups] = useState(false);
const [groupsError, setGroupsError] = useState<string | null>(null);
const [isLoadingPackets, setIsLoadingPackets] = useState(false);
const [packetsError, setPacketsError] = useState<string | null>(null);
const [decryptedMessages, setDecryptedMessages] = useState<MeshCoreGroupChatRecord[]>([]);
const keyManagerRef = useRef<KeyManager>(new KeyManager());
// Fetch groups on mount
useEffect(() => {
const loadGroups = async () => {
setIsLoadingGroups(true);
setGroupsError(null);
try {
const fetchedGroups = await meshCoreService.fetchGroups();
setGroups(fetchedGroups);
// Add groups to key manager
const keyManager = keyManagerRef.current;
for (const group of fetchedGroups) {
try {
keyManager.addGroup(group.name, group.secret.toBytes());
} catch (err) {
console.warn(`Failed to add group ${group.name} to key manager:`, err);
}
}
if (fetchedGroups.length > 0 && !selectedGroup) {
setSelectedGroup(fetchedGroups[0]);
}
} catch (err) {
setGroupsError(err instanceof Error ? err.message : 'Failed to load groups');
} finally {
setIsLoadingGroups(false);
}
};
loadGroups();
}, []);
// Fetch and decrypt packets for selected group
useEffect(() => {
if (!selectedGroup) {
setDecryptedMessages([]);
return;
}
const loadGroupPackets = async () => {
setIsLoadingPackets(true);
setPacketsError(null);
try {
const channelHash = selectedGroup.secret.toHash();
const packets = await meshCoreService.fetchGroupPackets(channelHash);
const messages: MeshCoreGroupChatRecord[] = [];
for (const packet of packets) {
try {
const p = new Packet(packet.raw);
const payload = p.decode();
if (payload && 'cipherText' in payload && 'cipherMAC' in payload && 'channelHash' in payload) {
const decrypted = keyManagerRef.current.decryptGroup(
packet.channel_hash,
payload.cipherText as Uint8Array,
payload.cipherMAC as Uint8Array
);
messages.push({
hash: packet.hash,
timestamp: decrypted.timestamp,
channel: selectedGroup.name,
sender: decrypted.message.split(':')[0] || 'Unknown',
message: decrypted.message.split(':').slice(1).join(':').trim(),
});
}
} catch (err) {
console.warn('Failed to decrypt packet:', err);
}
}
setDecryptedMessages(messages.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()));
} catch (err) {
setPacketsError(err instanceof Error ? err.message : 'Failed to load packets');
} finally {
setIsLoadingPackets(false);
}
};
loadGroupPackets();
}, [selectedGroup]);
// Combine stream messages and decrypted messages
const filteredMessages = useMemo(() => {
if (!selectedGroup) return [];
// Get stream messages for this group
const streamMessages = groupChats.filter(
(chat) => chat.channel === selectedGroup.secret.toHash() || chat.channel === selectedGroup.name
);
// Combine with decrypted messages and deduplicate by hash
const combined = [...decryptedMessages, ...streamMessages];
const seen = new Set<string>();
const unique = combined.filter((msg) => {
if (seen.has(msg.hash)) return false;
seen.add(msg.hash);
return true;
});
return unique.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
}, [groupChats, selectedGroup, decryptedMessages]);
const handleSelectGroup = (group: MeshCoreGroupRecord) => {
setSelectedGroup(group);
};
return (
<VerticalSplit
ratio="25/70"
left={
<GroupList
groups={groups}
selectedGroupId={selectedGroup?.id ?? null}
onSelectGroup={handleSelectGroup}
isLoading={isLoadingGroups}
error={groupsError}
streamReady={streamReady}
/>
}
right={
<GroupMessagesPane
group={selectedGroup}
messages={filteredMessages}
isLoading={isLoadingPackets}
error={packetsError}
streamReady={streamReady}
/>
}
/>
);
};
export default MeshCoreGroupChatView;

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { Badge, Card, Table } from 'react-bootstrap';
import Full from '../../components/Full';
import { nodeTypeNameByValue } from './MeshCoreData';
import { useMeshCoreData } from './MeshCoreContext';
const MeshCoreMapView: React.FC = () => {
const { mapPoints, streamReady } = useMeshCoreData();
return (
<Full className="meshcore-map-view">
<Card className="meshcore-table-card h-100 d-flex flex-column">
<Card.Header className="meshcore-table-header d-flex justify-content-between align-items-center">
<span>Node Map</span>
<Badge bg={streamReady ? 'success' : 'secondary'}>{streamReady ? 'stream ready' : 'stream offline'}</Badge>
</Card.Header>
<Card.Body className="meshcore-table-body d-flex flex-column gap-3">
<div className="meshcore-map-canvas">
{mapPoints.map((point) => (
<div
key={point.nodeId}
className="meshcore-map-dot"
style={{
left: `${((point.longitude - 4.4) / 1.2) * 100}%`,
top: `${100 - ((point.latitude - 51.6) / 0.8) * 100}%`,
}}
title={`Node ${point.nodeId}`}
/>
))}
</div>
<div className="meshcore-table-scroll">
<Table responsive size="sm" className="meshcore-table mb-0">
<thead>
<tr>
<th>Node</th>
<th>Type</th>
<th>Packets</th>
<th>Latitude</th>
<th>Longitude</th>
</tr>
</thead>
<tbody>
{mapPoints.map((point) => (
<tr key={point.nodeId}>
<td>{point.nodeId}</td>
<td>{nodeTypeNameByValue[point.nodeType] ?? point.nodeType}</td>
<td>{point.packetCount}</td>
<td>{point.latitude.toFixed(4)}</td>
<td>{point.longitude.toFixed(4)}</td>
</tr>
))}
</tbody>
</Table>
</div>
</Card.Body>
</Card>
</Full>
);
};
export default MeshCoreMapView;

View File

@@ -0,0 +1,590 @@
import React, { useMemo, useState, useEffect } from 'react';
import { useSearchParams } from 'react-router';
import { Badge, Card, Dropdown, Form, Stack, Table } from 'react-bootstrap';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import PersonIcon from '@mui/icons-material/Person';
import SensorsIcon from '@mui/icons-material/Sensors';
import SignalCellularAltIcon from '@mui/icons-material/SignalCellularAlt';
import StorageIcon from '@mui/icons-material/Storage';
import StreamStatus from '../../components/StreamStatus';
import { useRadiosByProtocol } from '../../contexts/RadiosContext';
import { NodeType, PayloadType } from '../../protocols/meshcore.types';
import type { Payload } from '../../protocols/meshcore.types';
import type { MeshCorePacketRecord } from './MeshCoreContext';
import VerticalSplit from '../../components/VerticalSplit';
import {
asHex,
nodeTypeDisplayByValue,
nodeTypeUrlByValue,
nodeTypeValueByUrl,
payloadDisplayByValue,
payloadUrlByValue,
payloadValueByUrl,
routeDisplayByValue,
routeUrlByValue,
routeValueByUrl,
} from './MeshCoreData';
import { useMeshCoreData } from './MeshCoreContext';
const getNodeTypeIcon = (nodeType: number) => {
switch (nodeType) {
case NodeType.TYPE_CHAT_NODE:
return <PersonIcon className="meshcore-node-icon" />;
case NodeType.TYPE_REPEATER:
return <SignalCellularAltIcon className="meshcore-node-icon" />;
case NodeType.TYPE_ROOM_SERVER:
return <StorageIcon className="meshcore-node-icon" />;
case NodeType.TYPE_SENSOR:
return <SensorsIcon className="meshcore-node-icon" />;
default:
return null;
}
};
const getPayloadTypeColor = (payloadType: number): string => {
switch (payloadType) {
case PayloadType.TEXT:
case PayloadType.GROUP_TEXT:
case PayloadType.TRACE:
case PayloadType.PATH:
return 'meshcore-packet-green';
case PayloadType.ADVERT:
return 'meshcore-packet-purple';
case PayloadType.REQUEST:
case PayloadType.RESPONSE:
case PayloadType.CONTROL:
return 'meshcore-packet-amber';
default:
return '';
}
};
const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div className="meshcore-fact-row">
<span className="meshcore-fact-label">{label}</span>
<span className="meshcore-fact-value">{value}</span>
</div>
);
const PayloadDetails: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet }) => {
const payload = packet.decodedPayload;
if (!payload) {
return (
<Card body className="meshcore-detail-card">
<h6 className="mb-2">Payload</h6>
<div>Unable to decode payload; showing raw bytes only.</div>
<code>{asHex(packet.raw)}</code>
</Card>
);
}
if ('flags' in payload && 'data' in payload) {
return (
<Card body className="meshcore-detail-card">
<h6 className="mb-2">CONTROL Payload</h6>
<HeaderFact label="Flags" value={`0x${payload.flags.toString(16)}`} />
<HeaderFact label="Data Length" value={payload.data.length} />
<HeaderFact label="Data" value={<code>{asHex(payload.data)}</code>} />
</Card>
);
}
if ('channelHash' in payload) {
return (
<Card body className="meshcore-detail-card">
<h6 className="mb-2">GROUP Payload</h6>
<HeaderFact label="Channel Hash" value={payload.channelHash} />
<HeaderFact label="Cipher Text Length" value={payload.cipherText.length} />
<HeaderFact label="Cipher MAC" value={<code>{asHex(payload.cipherMAC as Uint8Array)}</code>} />
</Card>
);
}
if ('dstHash' in payload && 'srcHash' in payload) {
return (
<Card body className="meshcore-detail-card">
<h6 className="mb-2">Encrypted Payload</h6>
<HeaderFact label="Destination" value={payload.dstHash} />
<HeaderFact label="Source" value={payload.srcHash} />
<HeaderFact label="Cipher Text Length" value={payload.cipherText.length} />
<HeaderFact label="Cipher MAC" value={<code>{asHex(payload.cipherMAC as Uint8Array)}</code>} />
</Card>
);
}
if ('data' in payload) {
return (
<Card body className="meshcore-detail-card">
<h6 className="mb-2">Raw Payload</h6>
<HeaderFact label="Data Length" value={payload.data.length} />
<HeaderFact label="Data" value={<code>{asHex(payload.data)}</code>} />
</Card>
);
}
return (
<Card body className="meshcore-detail-card">
<h6 className="mb-2">Payload</h6>
<code>{JSON.stringify(payload as Payload)}</code>
</Card>
);
};
const FilterDropdown: React.FC<{
label: string;
options: number[];
selectedValues: Set<number>;
getLabelForValue: (value: number) => string;
onToggle: (value: number, isChecked: boolean) => void;
onSelectAll: () => void;
}> = ({ label, options, selectedValues, getLabelForValue, onToggle, onSelectAll }) => {
const isAllSelected = selectedValues.size === 0;
const displayLabel = isAllSelected ? `${label}: All` : `${label}: ${selectedValues.size} selected`;
return (
<Dropdown className="meshcore-filter-dropdown">
<Dropdown.Toggle variant="outline-light" size="sm" className="meshcore-dropdown-toggle">
{displayLabel}
<ExpandMoreIcon style={{ fontSize: '1rem', marginLeft: '0.25rem' }} />
</Dropdown.Toggle>
<Dropdown.Menu className="meshcore-dropdown-menu">
<Dropdown.Item as="label" className="meshcore-dropdown-item">
<Form.Check
type="checkbox"
label="All"
checked={isAllSelected}
onChange={() => onSelectAll()}
className="mb-0"
onClick={(e) => e.stopPropagation()}
/>
</Dropdown.Item>
<Dropdown.Divider />
{options.map((option) => (
<Dropdown.Item key={option} as="label" className="meshcore-dropdown-item">
<Form.Check
type="checkbox"
label={getLabelForValue(option)}
checked={selectedValues.has(option)}
onChange={(e) => onToggle(option, e.target.checked)}
className="mb-0"
onClick={(e) => e.stopPropagation()}
/>
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
};
const StringFilterDropdown: React.FC<{
label: string;
options: string[];
selectedValues: Set<string>;
onToggle: (value: string, isChecked: boolean) => void;
onSelectAll: () => void;
}> = ({ label, options, selectedValues, onToggle, onSelectAll }) => {
const isAllSelected = selectedValues.size === 0;
const displayLabel = isAllSelected ? `${label}: All` : `${label}: ${selectedValues.size} selected`;
return (
<Dropdown className="meshcore-filter-dropdown">
<Dropdown.Toggle variant="outline-light" size="sm" className="meshcore-dropdown-toggle">
{displayLabel}
<ExpandMoreIcon style={{ fontSize: '1rem', marginLeft: '0.25rem' }} />
</Dropdown.Toggle>
<Dropdown.Menu className="meshcore-dropdown-menu">
<Dropdown.Item as="label" className="meshcore-dropdown-item">
<Form.Check
type="checkbox"
label="All"
checked={isAllSelected}
onChange={() => onSelectAll()}
className="mb-0"
onClick={(e) => e.stopPropagation()}
/>
</Dropdown.Item>
<Dropdown.Divider />
{options.map((option) => (
<Dropdown.Item key={option} as="label" className="meshcore-dropdown-item">
<Form.Check
type="checkbox"
label={option}
checked={selectedValues.has(option)}
onChange={(e) => onToggle(option, e.target.checked)}
className="mb-0"
onClick={(e) => e.stopPropagation()}
/>
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
};
const PacketTable: React.FC<{
packets: MeshCorePacketRecord[];
selectedHash: string | null;
onSelect: (packet: MeshCorePacketRecord) => void;
streamReady: boolean;
}> = ({ packets, selectedHash, onSelect, streamReady }) => {
const [searchParams, setSearchParams] = useSearchParams();
const radios = useRadiosByProtocol('meshcore');
// Initialize filter states from URL params (using human-readable names)
const [filterNodeTypes, setFilterNodeTypes] = useState<Set<number>>(() => {
const nodes = searchParams.get('nodes');
if (!nodes) return new Set();
return new Set(nodes.split(',').map((urlName) => nodeTypeValueByUrl[urlName]).filter((v) => v !== undefined));
});
const [filterPayloadTypes, setFilterPayloadTypes] = useState<Set<number>>(() => {
const payloads = searchParams.get('payloads');
if (!payloads) return new Set();
return new Set(payloads.split(',').map((urlName) => payloadValueByUrl[urlName]).filter((v) => v !== undefined));
});
const [filterRouteTypes, setFilterRouteTypes] = useState<Set<number>>(() => {
const routes = searchParams.get('routes');
if (!routes) return new Set();
return new Set(routes.split(',').map((urlName) => routeValueByUrl[urlName]).filter((v) => v !== undefined));
});
// Initialize radio filter from URL params
const [filterRadios, setFilterRadios] = useState<Set<string>>(() => {
const radiosParam = searchParams.get('radios');
if (!radiosParam) return new Set();
return new Set(radiosParam.split(',').map(decodeURIComponent));
});
// Track expanded state for duplicate packets
const [expandedHashes, setExpandedHashes] = useState<Set<string>>(new Set());
// Sync state to URL params (using URL-friendly lowercase names)
useEffect(() => {
const newParams = new URLSearchParams(searchParams);
if (filterNodeTypes.size > 0) {
newParams.set('nodes', Array.from(filterNodeTypes).map((v) => nodeTypeUrlByValue[v]).join(','));
} else {
newParams.delete('nodes');
}
if (filterPayloadTypes.size > 0) {
newParams.set('payloads', Array.from(filterPayloadTypes).map((v) => payloadUrlByValue[v]).join(','));
} else {
newParams.delete('payloads');
}
if (filterRouteTypes.size > 0) {
newParams.set('routes', Array.from(filterRouteTypes).map((v) => routeUrlByValue[v]).join(','));
} else {
newParams.delete('routes');
}
if (filterRadios.size > 0) {
newParams.set('radios', Array.from(filterRadios).map(encodeURIComponent).join(','));
} else {
newParams.delete('radios');
}
setSearchParams(newParams, { replace: true });
}, [filterNodeTypes, filterPayloadTypes, filterRouteTypes, filterRadios, searchParams, setSearchParams]);
// Derive unique values for each filter
const uniqueNodeTypes = useMemo(() => {
const types = new Set(packets.map((p) => p.nodeType));
return Array.from(types).sort((a, b) => a - b);
}, [packets]);
const uniquePayloadTypes = useMemo(() => {
const types = new Set(packets.map((p) => p.payloadType));
return Array.from(types).sort((a, b) => a - b);
}, [packets]);
const uniqueRouteTypes = useMemo(() => {
const types = new Set(packets.map((p) => p.routeType));
return Array.from(types).sort((a, b) => a - b);
}, [packets]);
// Get unique radio names from packets and radios API
const uniqueRadioNames = useMemo(() => {
const namesFromPackets = new Set(packets.map((p) => p.radioName).filter((name): name is string => !!name));
const namesFromAPI = new Set(radios.map((r) => r.name));
// Combine both sources
const allNames = new Set([...namesFromPackets, ...namesFromAPI]);
return Array.from(allNames).sort();
}, [packets, radios]);
// Group packets by hash and filter
const groupedPackets = useMemo(() => {
const groups = new Map<string, MeshCorePacketRecord[]>();
// Filter and group packets
packets.forEach((packet) => {
if (filterNodeTypes.size > 0 && !filterNodeTypes.has(packet.nodeType)) return;
if (filterPayloadTypes.size > 0 && !filterPayloadTypes.has(packet.payloadType)) return;
if (filterRouteTypes.size > 0 && !filterRouteTypes.has(packet.routeType)) return;
if (filterRadios.size > 0 && packet.radioName && !filterRadios.has(packet.radioName)) return;
const existing = groups.get(packet.hash);
if (existing) {
existing.push(packet);
} else {
groups.set(packet.hash, [packet]);
}
});
// Convert to array and sort by most recent timestamp in each group
return Array.from(groups.entries())
.map(([hash, packets]) => ({
hash,
packets: packets.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()),
mostRecent: packets.reduce((latest, p) =>
p.timestamp > latest.timestamp ? p : latest
),
}))
.sort((a, b) => b.mostRecent.timestamp.getTime() - a.mostRecent.timestamp.getTime());
}, [packets, filterNodeTypes, filterPayloadTypes, filterRouteTypes, filterRadios]);
const handleNodeTypeToggle = (value: number, isChecked: boolean) => {
const newSet = new Set(filterNodeTypes);
if (isChecked) {
newSet.add(value);
} else {
newSet.delete(value);
}
setFilterNodeTypes(newSet);
};
const handlePayloadTypeToggle = (value: number, isChecked: boolean) => {
const newSet = new Set(filterPayloadTypes);
if (isChecked) {
newSet.add(value);
} else {
newSet.delete(value);
}
setFilterPayloadTypes(newSet);
};
const handleRouteTypeToggle = (value: number, isChecked: boolean) => {
const newSet = new Set(filterRouteTypes);
if (isChecked) {
newSet.add(value);
} else {
newSet.delete(value);
}
setFilterRouteTypes(newSet);
};
const handleRadioToggle = (value: string, isChecked: boolean) => {
const newSet = new Set(filterRadios);
if (isChecked) {
newSet.add(value);
} else {
newSet.delete(value);
}
setFilterRadios(newSet);
};
const toggleExpanded = (hash: string) => {
const newExpanded = new Set(expandedHashes);
if (newExpanded.has(hash)) {
newExpanded.delete(hash);
} else {
newExpanded.add(hash);
}
setExpandedHashes(newExpanded);
};
return (
<Card className="meshcore-table-card h-100 d-flex flex-column">
<Card.Header className="meshcore-table-header d-flex justify-content-between align-items-center">
<span>MeshCore Packets</span>
<div className="d-flex align-items-center gap-2">
<StreamStatus ready={streamReady} />
</div>
</Card.Header>
<Card.Body className="meshcore-table-body p-0 d-flex flex-column">
<div className="meshcore-filters p-2 border-bottom border-secondary-subtle">
<Stack direction="horizontal" gap={2}>
<FilterDropdown
label="Node Type"
options={uniqueNodeTypes}
selectedValues={filterNodeTypes}
getLabelForValue={(v) => nodeTypeDisplayByValue[v] ?? `0x${v.toString(16)}`}
onToggle={handleNodeTypeToggle}
onSelectAll={() => setFilterNodeTypes(new Set())}
/>
<FilterDropdown
label="Payload Type"
options={uniquePayloadTypes}
selectedValues={filterPayloadTypes}
getLabelForValue={(v) => payloadDisplayByValue[v] ?? `0x${v.toString(16)}`}
onToggle={handlePayloadTypeToggle}
onSelectAll={() => setFilterPayloadTypes(new Set())}
/>
<StringFilterDropdown
label="Radio"
options={uniqueRadioNames}
selectedValues={filterRadios}
onToggle={handleRadioToggle}
onSelectAll={() => setFilterRadios(new Set())}
/>
<FilterDropdown
label="Route Type"
options={uniqueRouteTypes}
selectedValues={filterRouteTypes}
getLabelForValue={(v) => routeDisplayByValue[v] ?? `0x${v.toString(16)}`}
onToggle={handleRouteTypeToggle}
onSelectAll={() => setFilterRouteTypes(new Set())}
/>
</Stack>
</div>
<div className="meshcore-table-scroll">
<Table hover responsive className="meshcore-table mb-0" size="sm">
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th style={{ width: '100px' }}>Time</th>
<th style={{ width: '80px' }}>Hash</th>
<th style={{ width: '50px' }}>Node</th>
<th style={{ width: '100px' }}>Payload</th>
<th>Info</th>
</tr>
</thead>
<tbody>
{groupedPackets.map((group) => {
const isExpanded = expandedHashes.has(group.hash);
const hasDuplicates = group.packets.length > 1;
const packet = group.mostRecent;
return (
<React.Fragment key={group.hash}>
<tr className={`${getPayloadTypeColor(packet.payloadType)} ${selectedHash === packet.hash ? 'is-selected' : ''}`}>
<td>
{hasDuplicates && (
<button
type="button"
className="meshcore-expand-button"
onClick={() => toggleExpanded(group.hash)}
aria-label={isExpanded ? 'Collapse duplicates' : 'Expand duplicates'}
>
{isExpanded ? <ExpandMoreIcon style={{ fontSize: '1rem' }} /> : <ChevronRightIcon style={{ fontSize: '1rem' }} />}
</button>
)}
</td>
<td>
{packet.timestamp.toLocaleTimeString()}
{hasDuplicates && (
<span className="meshcore-duplicate-badge" title={`${group.packets.length} instances`}>
{' '}×{group.packets.length}
</span>
)}
</td>
<td>
<button type="button" className="meshcore-hash-button" onClick={() => onSelect(packet)}>
{packet.hash}
</button>
</td>
<td className="meshcore-node-type-cell" title={nodeTypeDisplayByValue[packet.nodeType] ?? `0x${packet.nodeType.toString(16)}`}>
<span className="meshcore-node-type-icon">{getNodeTypeIcon(packet.nodeType)}</span>
</td>
<td>{payloadDisplayByValue[packet.payloadType] ?? `0x${packet.payloadType.toString(16)}`}</td>
<td>{packet.payloadSummary}</td>
</tr>
{isExpanded && group.packets.map((dupPacket, idx) => (
<tr key={`${group.hash}-${idx}`} className={`meshcore-duplicate-row ${getPayloadTypeColor(dupPacket.payloadType)} ${selectedHash === dupPacket.hash ? 'is-selected' : ''}`}>
<td></td>
<td>{dupPacket.timestamp.toLocaleTimeString()}</td>
<td>
<button type="button" className="meshcore-hash-button" onClick={() => onSelect(dupPacket)}>
{dupPacket.hash}
</button>
</td>
<td className="meshcore-node-type-cell" title={nodeTypeDisplayByValue[dupPacket.nodeType] ?? `0x${dupPacket.nodeType.toString(16)}`}>
<span className="meshcore-node-type-icon">{getNodeTypeIcon(dupPacket.nodeType)}</span>
</td>
<td>{payloadDisplayByValue[dupPacket.payloadType] ?? `0x${dupPacket.payloadType.toString(16)}`}</td>
<td>
{dupPacket.payloadSummary}
{dupPacket.radioName && <span className="text-muted ms-2">({dupPacket.radioName})</span>}
</td>
</tr>
))}
</React.Fragment>
);
})}
</tbody>
</Table>
</div>
</Card.Body>
</Card>
);
};
const PacketDetailsPane: React.FC<{ packet: MeshCorePacketRecord | null; streamReady: boolean }> = ({ packet, streamReady }) => {
if (!packet) {
return (
<Card body className="meshcore-detail-card h-100">
<h6>Select a packet</h6>
<div>Click any hash in the table to inspect MeshCore header and payload details.</div>
<div className="mt-2 text-secondary">Stream prepared: {streamReady ? 'yes' : 'no'}</div>
</Card>
);
}
return (
<Stack gap={2} className="h-100 meshcore-detail-stack">
<Card body className="meshcore-detail-card">
<Stack direction="horizontal" gap={2} className="mb-2">
<h6 className="mb-0">Packet Header</h6>
<Badge bg="primary">{payloadDisplayByValue[packet.payloadType] ?? packet.payloadType}</Badge>
</Stack>
<HeaderFact label="Time" value={packet.timestamp.toISOString()} />
<HeaderFact label="Hash" value={<code>{packet.hash}</code>} />
{packet.radioName && <HeaderFact label="Radio" value={packet.radioName} />}
<HeaderFact label="Version" value={packet.version} />
<HeaderFact label="Route Type" value={routeDisplayByValue[packet.routeType] ?? packet.routeType} />
<HeaderFact label="Node Type" value={nodeTypeDisplayByValue[packet.nodeType] ?? packet.nodeType} />
<HeaderFact label="Path" value={<code>{asHex(packet.path)}</code>} />
<HeaderFact label="Raw Packet" value={<code>{asHex(packet.raw)}</code>} />
</Card>
<PayloadDetails packet={packet} />
<Card body className="meshcore-detail-card">
<h6 className="mb-2">Stream Preparation</h6>
<div>MeshCore stream service is initialized and ready for topic subscriptions.</div>
<div className="text-secondary">Ready: {streamReady ? 'yes' : 'no'}</div>
</Card>
</Stack>
);
};
const MeshCorePacketsView: React.FC = () => {
const { packets, streamReady } = useMeshCoreData();
const [selectedHash, setSelectedHash] = useState<string | null>(null);
const selectedPacket = useMemo(() => {
if (!selectedHash) {
return null;
}
return packets.find((packet) => packet.hash === selectedHash) ?? null;
}, [packets, selectedHash]);
return (
<VerticalSplit
ratio="75/25"
left={<PacketTable packets={packets} selectedHash={selectedHash} onSelect={(packet) => setSelectedHash(packet.hash)} streamReady={streamReady} />}
right={<PacketDetailsPane packet={selectedPacket} streamReady={streamReady} />}
/>
);
};
export default MeshCorePacketsView;

View File

@@ -0,0 +1,964 @@
import { describe, expect, it } from 'vitest';
import { parseAddress, parseFrame, Timestamp } from './aprs';
describe('parseAddress', () => {
it('should parse callsign without SSID', () => {
const result = parseAddress('NOCALL');
expect(result).toEqual({
call: 'NOCALL',
ssid: '',
isRepeated: false,
});
});
it('should parse callsign with SSID', () => {
const result = parseAddress('NOCALL-1');
expect(result).toEqual({
call: 'NOCALL',
ssid: '1',
isRepeated: false,
});
});
it('should parse repeated address', () => {
const result = parseAddress('WA1PLE-4*');
expect(result).toEqual({
call: 'WA1PLE',
ssid: '4',
isRepeated: true,
});
});
it('should parse address without SSID but with repeat marker', () => {
const result = parseAddress('WIDE1*');
expect(result).toEqual({
call: 'WIDE1',
ssid: '',
isRepeated: true,
});
});
});
describe('parseFrame', () => {
it('should parse APRS position frame (test vector 1)', () => {
const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const result = parseFrame(data);
expect(result.source).toEqual({
call: 'NOCALL',
ssid: '1',
isRepeated: false,
});
expect(result.destination).toEqual({
call: 'APRS',
ssid: '',
isRepeated: false,
});
expect(result.path).toHaveLength(1);
expect(result.path[0]).toEqual({
call: 'WIDE1',
ssid: '1',
isRepeated: false,
});
expect(result.payload).toBe('@092345z/:*E";qZ=OMRC/A=088132Hello World!');
});
it('should parse APRS Mic-E frame with repeated digipeater (test vector 2)', () => {
const data = 'N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&\'/\'"G:} KJ6TMS|!:&0\'p|!w#f!|3';
const result = parseFrame(data);
expect(result.source).toEqual({
call: 'N83MZ',
ssid: '',
isRepeated: false,
});
expect(result.destination).toEqual({
call: 'T2TQ5U',
ssid: '',
isRepeated: false,
});
expect(result.path).toHaveLength(1);
expect(result.path[0]).toEqual({
call: 'WA1PLE',
ssid: '4',
isRepeated: true,
});
expect(result.payload).toBe('`c.l+@&\'/\'"G:} KJ6TMS|!:&0\'p|!w#f!|3');
});
it('should parse frame with multiple path elements', () => {
const data = 'KB1ABC-5>APRS,WIDE1-1,WIDE2-2*,IGATE:!4903.50N/07201.75W-Test';
const result = parseFrame(data);
expect(result.source).toEqual({
call: 'KB1ABC',
ssid: '5',
isRepeated: false,
});
expect(result.destination).toEqual({
call: 'APRS',
ssid: '',
isRepeated: false,
});
expect(result.path).toHaveLength(3);
expect(result.path[0]).toEqual({
call: 'WIDE1',
ssid: '1',
isRepeated: false,
});
expect(result.path[1]).toEqual({
call: 'WIDE2',
ssid: '2',
isRepeated: true,
});
expect(result.path[2]).toEqual({
call: 'IGATE',
ssid: '',
isRepeated: false,
});
expect(result.payload).toBe('!4903.50N/07201.75W-Test');
});
it('should parse frame with no path', () => {
const data = 'W1AW>APRS::STATUS:Testing';
const result = parseFrame(data);
expect(result.source).toEqual({
call: 'W1AW',
ssid: '',
isRepeated: false,
});
expect(result.destination).toEqual({
call: 'APRS',
ssid: '',
isRepeated: false,
});
expect(result.path).toHaveLength(0);
expect(result.payload).toBe(':STATUS:Testing');
});
it('should throw error for frame without route separator', () => {
const data = 'NOCALL-1>APRS';
expect(() => parseFrame(data)).toThrow('APRS: invalid frame, no route separator found');
});
it('should throw error for frame with invalid addresses', () => {
const data = 'NOCALL:payload';
expect(() => parseFrame(data)).toThrow('APRS: invalid addresses in route');
});
});
describe('Frame class', () => {
it('should return a Frame instance from parseFrame', () => {
const data = 'W1AW>APRS:>Status message';
const result = parseFrame(data);
expect(result).toBeInstanceOf(Object);
expect(typeof result.decode).toBe('function');
expect(typeof result.getDataTypeIdentifier).toBe('function');
});
it('should get correct data type identifier for position', () => {
const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = parseFrame(data);
expect(frame.getDataTypeIdentifier()).toBe('@');
});
it('should get correct data type identifier for Mic-E', () => {
const data = 'N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&\'/\'"G:} KJ6TMS|!:&0\'p|!w#f!|3';
const frame = parseFrame(data);
expect(frame.getDataTypeIdentifier()).toBe('`');
});
it('should get correct data type identifier for message', () => {
const data = 'W1AW>APRS::KB1ABC-5 :Hello World';
const frame = parseFrame(data);
expect(frame.getDataTypeIdentifier()).toBe(':');
});
it('should get correct data type identifier for status', () => {
const data = 'W1AW>APRS:>Status message';
const frame = parseFrame(data);
expect(frame.getDataTypeIdentifier()).toBe('>');
});
it('should call decode method and return position data', () => {
const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = parseFrame(data);
const decoded = frame.decode();
// The test vector actually decodes to position data now that we've implemented it
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('position');
});
it('should handle various data type identifiers in decode', () => {
const testCases = [
{ data: 'CALL>APRS:!4903.50N/07201.75W-', type: '!' },
{ data: 'CALL>APRS:=4903.50N/07201.75W-', type: '=' },
{ data: 'CALL>APRS:/092345z4903.50N/07201.75W>', type: '/' },
{ data: 'CALL>APRS:>Status Text', type: '>' },
{ data: 'CALL>APRS::ADDRESS :Message', type: ':' },
{ data: 'CALL>APRS:;OBJECT *092345z4903.50N/07201.75W>', type: ';' },
{ data: 'CALL>APRS:)ITEM!4903.50N/07201.75W-', type: ')' },
{ data: 'CALL>APRS:?APRS?', type: '?' },
{ data: 'CALL>APRS:T#001,123,456,789', type: 'T' },
{ data: 'CALL>APRS:_10090556c...', type: '_' },
{ data: 'CALL>APRS:$GPRMC,...', type: '$' },
{ data: 'CALL>APRS:<IGATE,MSG_CNT', type: '<' },
{ data: 'CALL>APRS:{01', type: '{' },
{ data: 'CALL>APRS:}W1AW>APRS:test', type: '}' },
];
for (const testCase of testCases) {
const frame = parseFrame(testCase.data);
expect(frame.getDataTypeIdentifier()).toBe(testCase.type);
// decode() returns null for now since implementations are TODO
expect(() => frame.decode()).not.toThrow();
}
});
describe('Position decoding', () => {
it('should decode position with timestamp and compressed format (test vector 1)', () => {
const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('position');
if (decoded && decoded.type === 'position') {
expect(decoded.messaging).toBe(true);
expect(decoded.timestamp).toBeDefined();
expect(decoded.timestamp?.day).toBe(9);
expect(decoded.timestamp?.hours).toBe(23);
expect(decoded.timestamp?.minutes).toBe(45);
expect(decoded.timestamp?.format).toBe('DHM');
expect(decoded.timestamp?.zulu).toBe(true);
expect(decoded.position).toBeDefined();
expect(typeof decoded.position.latitude).toBe('number');
expect(typeof decoded.position.longitude).toBe('number');
expect(decoded.position.symbol).toBeDefined();
expect(decoded.position.altitude).toBeCloseTo(88132 * 0.3048, 1); // feet to meters
expect(decoded.position.comment).toContain('Hello World!');
}
});
it('should decode uncompressed position without timestamp', () => {
const data = 'KB1ABC>APRS:!4903.50N/07201.75W-Test message';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('position');
if (decoded && decoded.type === 'position') {
expect(decoded.messaging).toBe(false);
expect(decoded.timestamp).toBeUndefined();
// 49 degrees + 3.50 minutes = 49.0583
expect(decoded.position.latitude).toBeCloseTo(49 + 3.50/60, 3);
// -72 degrees - 1.75 minutes = -72.0292
expect(decoded.position.longitude).toBeCloseTo(-(72 + 1.75/60), 3);
expect(decoded.position.symbol?.table).toBe('/');
expect(decoded.position.symbol?.code).toBe('-');
expect(decoded.position.comment).toBe('Test message');
}
});
it('should decode position with messaging capability', () => {
const data = 'W1AW>APRS:=4903.50N/07201.75W-';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('position');
if (decoded && decoded.type === 'position') {
expect(decoded.messaging).toBe(true);
expect(decoded.timestamp).toBeUndefined();
}
});
it('should decode position with timestamp (HMS format)', () => {
const data = 'CALL>APRS:/234517h4903.50N/07201.75W>';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('position');
if (decoded && decoded.type === 'position') {
expect(decoded.messaging).toBe(false);
expect(decoded.timestamp).toBeDefined();
expect(decoded.timestamp?.hours).toBe(23);
expect(decoded.timestamp?.minutes).toBe(45);
expect(decoded.timestamp?.seconds).toBe(17);
expect(decoded.timestamp?.format).toBe('HMS');
}
});
it('should extract altitude from comment', () => {
const data = 'CALL>APRS:!4903.50N/07201.75W>/A=001234';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.altitude).toBeCloseTo(1234 * 0.3048, 1);
}
});
it('should handle southern and western hemispheres', () => {
const data = 'CALL>APRS:!3345.67S/15112.34E-';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
// 33 degrees + 45.67 minutes = 33.7612 degrees South = -33.7612
expect(decoded.position.latitude).toBeLessThan(0);
expect(decoded.position.longitude).toBeGreaterThan(0);
expect(decoded.position.latitude).toBeCloseTo(-(33 + 45.67/60), 3);
// 151 degrees + 12.34 minutes = 151.2057 degrees East
expect(decoded.position.longitude).toBeCloseTo(151 + 12.34/60, 3);
}
});
it('should return null for invalid position data', () => {
const data = 'CALL>APRS:!invalid';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).toBeNull();
});
it('should handle position with ambiguity level 1 (1 space)', () => {
// Spaces mask the rightmost digits for privacy
// 4903.5 means ambiguity level 1 (±0.05 minute)
const data = 'CALL>APRS:!4903.5 N/07201.75W-';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.ambiguity).toBe(1);
// Spaces are replaced with 0 for parsing, so 4903.5 becomes 4903.50
expect(decoded.position.latitude).toBeCloseTo(49 + 3.5/60, 3);
}
});
it('should handle position with ambiguity level 2 (2 spaces)', () => {
// 4903. means ambiguity level 2 (±0.5 minutes) - spaces replace last 2 decimal digits
const data = 'CALL>APRS:!4903. N/07201.75W-';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.ambiguity).toBe(2);
expect(decoded.position.latitude).toBeCloseTo(49 + 3/60, 3);
}
});
it('should handle position with ambiguity level 1 in both lat and lon', () => {
// Both lat and lon have 1 space for ambiguity 1 (±0.05 minute)
const data = 'CALL>APRS:!4903.5 N/07201.7 W-';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.ambiguity).toBe(1);
expect(decoded.position.latitude).toBeCloseTo(49 + 3.5/60, 3);
expect(decoded.position.longitude).toBeCloseTo(-(72 + 1.7/60), 3);
}
});
it('should handle position with ambiguity level 2 in both lat and lon', () => {
// Both lat and lon have 2 spaces for ambiguity 2 (±0.5 minutes)
const data = 'CALL>APRS:!4903. N/07201. W-';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.ambiguity).toBe(2);
expect(decoded.position.latitude).toBeCloseTo(49 + 3/60, 3);
expect(decoded.position.longitude).toBeCloseTo(-(72 + 1/60), 3);
}
});
it('should have toDate method on timestamp', () => {
const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position' && decoded.timestamp) {
expect(typeof decoded.timestamp.toDate).toBe('function');
const date = decoded.timestamp.toDate();
expect(date).toBeInstanceOf(Date);
expect(date.getUTCDate()).toBe(9);
expect(date.getUTCHours()).toBe(23);
expect(date.getUTCMinutes()).toBe(45);
}
});
});
});
describe('Timestamp class', () => {
it('should create DHM timestamp and convert to Date', () => {
const ts = new Timestamp(14, 30, 'DHM', { day: 15, zulu: true });
expect(ts.hours).toBe(14);
expect(ts.minutes).toBe(30);
expect(ts.day).toBe(15);
expect(ts.format).toBe('DHM');
expect(ts.zulu).toBe(true);
const date = ts.toDate();
expect(date).toBeInstanceOf(Date);
expect(date.getUTCDate()).toBe(15);
expect(date.getUTCHours()).toBe(14);
expect(date.getUTCMinutes()).toBe(30);
});
it('should create HMS timestamp and convert to Date', () => {
const ts = new Timestamp(12, 45, 'HMS', { seconds: 30, zulu: true });
expect(ts.hours).toBe(12);
expect(ts.minutes).toBe(45);
expect(ts.seconds).toBe(30);
expect(ts.format).toBe('HMS');
const date = ts.toDate();
expect(date).toBeInstanceOf(Date);
expect(date.getUTCHours()).toBe(12);
expect(date.getUTCMinutes()).toBe(45);
expect(date.getUTCSeconds()).toBe(30);
});
it('should create MDHM timestamp and convert to Date', () => {
const ts = new Timestamp(16, 20, 'MDHM', { month: 3, day: 5, zulu: false });
expect(ts.hours).toBe(16);
expect(ts.minutes).toBe(20);
expect(ts.month).toBe(3);
expect(ts.day).toBe(5);
expect(ts.format).toBe('MDHM');
expect(ts.zulu).toBe(false);
const date = ts.toDate();
expect(date).toBeInstanceOf(Date);
expect(date.getMonth()).toBe(2); // 0-indexed
expect(date.getDate()).toBe(5);
expect(date.getHours()).toBe(16);
expect(date.getMinutes()).toBe(20);
});
it('should handle DHM timestamp that is in the future (use previous month)', () => {
const now = new Date();
const futureDay = now.getUTCDate() + 5;
const ts = new Timestamp(12, 0, 'DHM', { day: futureDay, zulu: true });
const date = ts.toDate();
// Should be in the past or very close to now
expect(date <= now).toBe(true);
});
it('should handle HMS timestamp that is in the future (use yesterday)', () => {
const now = new Date();
const futureHours = now.getUTCHours() + 2;
if (futureHours < 24) {
const ts = new Timestamp(futureHours, 0, 'HMS', { seconds: 0, zulu: true });
const date = ts.toDate();
// Should be in the past
expect(date <= now).toBe(true);
}
});
it('should handle MDHM timestamp that is in the future (use last year)', () => {
const now = new Date();
const futureMonth = now.getMonth() + 2;
if (futureMonth < 12) {
const ts = new Timestamp(12, 0, 'MDHM', {
month: futureMonth + 1,
day: 1,
zulu: false
});
const date = ts.toDate();
// Should be in the past
expect(date <= now).toBe(true);
}
});
});
describe('Mic-E decoding', () => {
describe('Basic Mic-E frames', () => {
it('should decode a basic Mic-E packet (current format)', () => {
// Destination: T2TQ5U encodes latitude ~42.3456N
// T=1, 2=2, T=1, Q=1, 5=5, U=1 (digits for latitude)
// Information field encodes longitude, speed, course, and symbols
const data = 'N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&\'/\'"G:} KJ6TMS|!:&0\'p|!w#f!|3';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('position');
if (decoded && decoded.type === 'position') {
expect(decoded.messaging).toBe(true);
expect(decoded.position).toBeDefined();
expect(typeof decoded.position.latitude).toBe('number');
expect(typeof decoded.position.longitude).toBe('number');
expect(decoded.position.symbol).toBeDefined();
expect(decoded.micE).toBeDefined();
expect(decoded.micE?.messageType).toBeDefined();
}
});
it('should decode a Mic-E packet with old format (single quote)', () => {
// Similar to above but with single quote (') data type identifier for old Mic-E
const data = 'CALL>T2TQ5U:\'c.l+@&\'/\'"G:}';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('position');
});
});
describe('Latitude decoding from destination', () => {
it('should decode latitude from numeric digits (0-9)', () => {
// Destination: 123456 -> 12°34.56'N with specific message bits
const data = 'CALL>123456:`c.l+@&\'/\'"G:}';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
// 12 degrees + 34.56 minutes = 12.576 degrees
expect(decoded.position.latitude).toBeCloseTo(12 + 34.56/60, 3);
}
});
it('should decode latitude from letter digits (A-J)', () => {
// A=0, B=1, C=2, D=3, E=4, F=5, G=6, H=7, I=8, J=9
// ABC0EF -> 012045 -> 01°20.45' (using 0 at position 3 for North)
const data = 'CALL>ABC0EF:`c.l+@&\'/\'"G:}';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
// 01 degrees + 20.45 minutes = 1.340833 degrees North
expect(decoded.position.latitude).toBeCloseTo(1 + 20.45/60, 3);
}
});
it('should decode latitude with mixed digits and letters', () => {
// 4AB2DE -> 401234 -> 40°12.34'N (using 2 at position 3 for North)
const data = 'CALL>4AB2DE:`c.l+@&\'/\'"G:}';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
// 40 degrees + 12.34 minutes
expect(decoded.position.latitude).toBeCloseTo(40 + 12.34/60, 3);
}
});
it('should decode latitude for southern hemisphere', () => {
// When messageBits[3] == 1, it's southern hemisphere
// For 'P' char at position 3 (msgBit=1 for south), combined appropriately
const data = 'CALL>4A0P0U:`c.l+@&\'/\'"G:}';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
// Should be negative for south
expect(decoded.position.latitude).toBeLessThan(0);
}
});
});
describe('Longitude decoding from information field', () => {
it('should decode longitude from information field', () => {
// Mic-E info field bytes encode longitude degrees, minutes, hundredths
// Testing with constructed values
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(typeof decoded.position.longitude).toBe('number');
// Longitude should be within valid range
expect(decoded.position.longitude).toBeGreaterThanOrEqual(-180);
expect(decoded.position.longitude).toBeLessThanOrEqual(180);
}
});
it('should handle eastern hemisphere longitude', () => {
// When messageBits[4] == 1, it's eastern hemisphere (positive)
// Need specific destination encoding for this
const data = 'CALL>4ABPDE:`c.l+@&\'/\'"G:}';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
// Eastern hemisphere could be positive (depends on other flags)
expect(typeof decoded.position.longitude).toBe('number');
}
});
it('should handle longitude offset +100', () => {
// When messageBits[5] == 1, add 100 to longitude degrees
// Need 'P' or similar at position 5
const data = 'CALL>4ABCDP:`c.l+@&\'/\'"G:}';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(typeof decoded.position.longitude).toBe('number');
// Longitude offset should be applied (100+ degrees)
expect(Math.abs(decoded.position.longitude)).toBeGreaterThan(90);
}
});
});
describe('Speed and course decoding', () => {
it('should decode speed from information field', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}Speed test';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
// Speed might be 0 or present
if (decoded.position.speed !== undefined) {
expect(decoded.position.speed).toBeGreaterThanOrEqual(0);
// Speed should be in km/h
expect(typeof decoded.position.speed).toBe('number');
}
}
});
it('should decode course from information field', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
// Course might be 0 or present
if (decoded.position.course !== undefined) {
expect(decoded.position.course).toBeGreaterThanOrEqual(0);
expect(decoded.position.course).toBeLessThan(360);
}
}
});
it('should not include zero speed in result', () => {
// When speed is 0, it should not be included in the result
const data = 'CALL>4ABCDE:`\x1c\x1c\x1c\x1c\x1c\x1c/>}';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
// Speed of 0 should not be set
expect(decoded.position.speed).toBeUndefined();
}
});
it('should not include zero or 360+ course in result', () => {
const data = 'CALL>4ABCDE:`c.l\x1c\x1c\x1c/>}';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
// Course of 0 or >= 360 should not be set
if (decoded.position.course !== undefined) {
expect(decoded.position.course).toBeGreaterThan(0);
expect(decoded.position.course).toBeLessThan(360);
}
}
});
});
describe('Symbol decoding', () => {
it('should decode symbol table and code', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.symbol).toBeDefined();
expect(decoded.position.symbol.table).toBeDefined();
expect(decoded.position.symbol.code).toBeDefined();
expect(typeof decoded.position.symbol.table).toBe('string');
expect(typeof decoded.position.symbol.code).toBe('string');
}
});
});
describe('Altitude decoding', () => {
it('should decode altitude from /A=NNNNNN format', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}/A=001234';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
// 1234 feet = 1234 * 0.3048 meters
expect(decoded.position.altitude).toBeCloseTo(1234 * 0.3048, 1);
}
});
it('should decode altitude from base-91 format }abc', () => {
// Base-91 altitude format: }xyz where xyz is base-91 encoded altitude
// The altitude encoding is (altitude in feet + 10000) in base-91
const data = 'CALL>4AB2DE:`c.l+@&\'/\'"G:}}S^X';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
// Base-91 altitude should be decoded (using valid base-91 characters)
// Even if altitude calculation results in negative value, it should be defined
if (decoded.position.comment?.startsWith('}')) {
// Altitude should be extracted from base-91 format
expect(decoded.position.altitude).toBeDefined();
}
}
});
it('should prefer /A= format over base-91 when both present', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}}!!!/A=005000';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
// Should use /A= format (5000 feet)
expect(decoded.position.altitude).toBeCloseTo(5000 * 0.3048, 1);
}
});
it('should handle comment without altitude', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}Just a comment';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.altitude).toBeUndefined();
expect(decoded.position.comment).toContain('Just a comment');
}
});
});
describe('Message type decoding', () => {
it('should decode message type M0 (Off Duty)', () => {
// Message bits 0,1,2 = 0,0,0 -> M0: Off Duty
// Use digits 0-9 for message bit 0
const data = 'CALL>012345:`c.l+@&\'/\'"G:}';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.micE?.messageType).toBe('M0: Off Duty');
}
});
it('should decode message type M7 (Emergency)', () => {
// Message bits 0,1,2 = 1,1,1 -> M7: Emergency
// Use letters A-J for message bit 1
const data = 'CALL>ABCDEF:`c.l+@&\'/\'"G:}';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
// Should contain a message type
expect(decoded.micE?.messageType).toBeDefined();
expect(typeof decoded.micE?.messageType).toBe('string');
}
});
it('should decode standard vs custom message indicator', () => {
const data = 'CALL>ABCDEF:`c.l+@&\'/\'"G:}';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.micE?.isStandard).toBeDefined();
expect(typeof decoded.micE?.isStandard).toBe('boolean');
}
});
});
describe('Comment and telemetry', () => {
it('should extract comment from remaining data', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}This is a test comment';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
expect(decoded.position.comment).toContain('This is a test comment');
}
});
it('should handle empty comment', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
// Empty comment should still be defined but empty
expect(decoded.position.comment).toBeDefined();
}
});
});
describe('Error handling', () => {
it('should return null for destination address too short', () => {
const data = 'CALL>SHORT:`c.l+@&\'/\'"G:}';
const frame = parseFrame(data);
const decoded = frame.decode();
// Should fail because destination is too short
expect(decoded).toBeNull();
});
it('should return null for payload too short', () => {
const data = 'CALL>4ABCDE:`short';
const frame = parseFrame(data);
const decoded = frame.decode();
// Should fail because payload is too short (needs at least 9 bytes with data type)
expect(decoded).toBeNull();
});
it('should return null for invalid destination characters', () => {
const data = 'CALL>4@BC#E:`c.l+@&\'/\'"G:}';
const frame = parseFrame(data);
const decoded = frame.decode();
// Should fail because destination contains invalid characters
expect(decoded).toBeNull();
});
it('should handle exceptions gracefully', () => {
// Malformed data that might cause exceptions
const data = 'CALL>4ABCDE:`\x00\x00\x00\x00\x00\x00\x00\x00';
const frame = parseFrame(data);
// Should not throw, just return null
expect(() => frame.decode()).not.toThrow();
const decoded = frame.decode();
// Might be null or might decode with weird values
expect(decoded === null || decoded?.type === 'position').toBe(true);
});
});
describe('Real-world test vectors', () => {
it('should decode real Mic-E packet from test vector 2', () => {
// From the existing test: N83MZ>T2TQ5U with specific encoding
const data = 'N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&\'/\'"G:} KJ6TMS|!:&0\'p|!w#f!|3';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe('position');
if (decoded && decoded.type === 'position') {
expect(decoded.messaging).toBe(true);
expect(decoded.position.latitude).toBeDefined();
expect(decoded.position.longitude).toBeDefined();
expect(decoded.position.symbol).toBeDefined();
expect(decoded.micE).toBeDefined();
// Verify reasonable coordinate ranges
expect(Math.abs(decoded.position.latitude)).toBeLessThanOrEqual(90);
expect(Math.abs(decoded.position.longitude)).toBeLessThanOrEqual(180);
}
});
});
describe('Messaging capability', () => {
it('should always set messaging to true for Mic-E', () => {
const data = 'CALL>4ABCDE:`c.l+@&\'/\'"G:}';
const frame = parseFrame(data);
const decoded = frame.decode();
expect(decoded).not.toBeNull();
if (decoded && decoded.type === 'position') {
// Mic-E always has messaging capability
expect(decoded.messaging).toBe(true);
}
});
});
});

745
ui/src/protocols/aprs.ts Normal file
View File

@@ -0,0 +1,745 @@
import type { Address, Frame as IFrame, DecodedPayload, Timestamp as ITimestamp } from "./aprs.types"
import { base91ToNumber } from "../libs/base91"
export class Timestamp implements ITimestamp {
day?: number;
month?: number;
hours: number;
minutes: number;
seconds?: number;
format: 'DHM' | 'HMS' | 'MDHM';
zulu?: boolean;
constructor(
hours: number,
minutes: number,
format: 'DHM' | 'HMS' | 'MDHM',
options: {
day?: number;
month?: number;
seconds?: number;
zulu?: boolean;
} = {}
) {
this.hours = hours;
this.minutes = minutes;
this.format = format;
this.day = options.day;
this.month = options.month;
this.seconds = options.seconds;
this.zulu = options.zulu;
}
/**
* Convert APRS timestamp to JavaScript Date object
* Note: APRS timestamps don't include year, so we use current year
* For DHM format, we find the most recent occurrence of that day
* For HMS format, we use current date
* For MDHM format, we use the specified month/day in current year
*/
toDate(): Date {
const now = new Date();
if (this.format === 'DHM') {
// Day-Hour-Minute format (UTC)
// Find the most recent occurrence of this day
const currentYear = this.zulu ? now.getUTCFullYear() : now.getFullYear();
const currentMonth = this.zulu ? now.getUTCMonth() : now.getMonth();
let date: Date;
if (this.zulu) {
date = new Date(Date.UTC(currentYear, currentMonth, this.day!, this.hours, this.minutes, 0, 0));
} else {
date = new Date(currentYear, currentMonth, this.day!, this.hours, this.minutes, 0, 0);
}
// If the date is in the future, it's from last month
if (date > now) {
if (this.zulu) {
date = new Date(Date.UTC(currentYear, currentMonth - 1, this.day!, this.hours, this.minutes, 0, 0));
} else {
date = new Date(currentYear, currentMonth - 1, this.day!, this.hours, this.minutes, 0, 0);
}
}
return date;
} else if (this.format === 'HMS') {
// Hour-Minute-Second format (UTC)
// Use current date
if (this.zulu) {
const date = new Date();
date.setUTCHours(this.hours, this.minutes, this.seconds || 0, 0);
// If time is in the future, it's from yesterday
if (date > now) {
date.setUTCDate(date.getUTCDate() - 1);
}
return date;
} else {
const date = new Date();
date.setHours(this.hours, this.minutes, this.seconds || 0, 0);
if (date > now) {
date.setDate(date.getDate() - 1);
}
return date;
}
} else {
// MDHM format: Month-Day-Hour-Minute (local time)
const currentYear = now.getFullYear();
let date = new Date(currentYear, (this.month || 1) - 1, this.day!, this.hours, this.minutes, 0, 0);
// If date is in the future, it's from last year
if (date > now) {
date = new Date(currentYear - 1, (this.month || 1) - 1, this.day!, this.hours, this.minutes, 0, 0);
}
return date;
}
}
}
export const parseAddress = (addr: string): Address => {
const isRepeated = addr.endsWith('*');
const baseAddr = isRepeated ? addr.slice(0, -1) : addr;
const parts = baseAddr.split('-');
const call = parts[0];
const ssid = parts.length > 1 ? parts[1] : '';
return {call, ssid, isRepeated};
}
export class Frame implements IFrame {
source: Address;
destination: Address;
path: Address[];
payload: string;
constructor(source: Address, destination: Address, path: Address[], payload: string) {
this.source = source;
this.destination = destination;
this.path = path;
this.payload = payload;
}
/**
* Get the data type identifier (first character of payload)
*/
getDataTypeIdentifier(): string {
return this.payload.charAt(0);
}
/**
* Decode the APRS payload based on its data type identifier
*/
decode(): DecodedPayload | null {
if (!this.payload) {
return null;
}
const dataType = this.getDataTypeIdentifier();
// TODO: Implement full decoding logic for each payload type
switch (dataType) {
case '!': // Position without timestamp, no messaging
case '=': // Position without timestamp, with messaging
case '/': // Position with timestamp, no messaging
case '@': // Position with timestamp, with messaging
return this.decodePosition(dataType);
case '`': // Mic-E current
case "'": // Mic-E old
return this.decodeMicE(dataType);
case ':': // Message
return this.decodeMessage();
case ';': // Object
return this.decodeObject();
case ')': // Item
return this.decodeItem();
case '>': // Status
return this.decodeStatus();
case '?': // Query
return this.decodeQuery();
case 'T': // Telemetry
return this.decodeTelemetry();
case '_': // Weather without position
return this.decodeWeather();
case '$': // Raw GPS
return this.decodeRawGPS();
case '<': // Station capabilities
return this.decodeCapabilities();
case '{': // User-defined
return this.decodeUserDefined();
case '}': // Third-party
return this.decodeThirdParty();
default:
return null;
}
}
private decodePosition(dataType: string): DecodedPayload | null {
try {
const hasTimestamp = dataType === '/' || dataType === '@';
const messaging = dataType === '=' || dataType === '@';
let offset = 1; // Skip data type identifier
let timestamp: Timestamp | undefined = undefined;
// Parse timestamp if present (7 characters: DDHHMMz or HHMMSSh or MMDDHHMM)
if (hasTimestamp) {
if (this.payload.length < 8) return null;
const timeStr = this.payload.substring(offset, offset + 7);
timestamp = this.parseTimestamp(timeStr);
offset += 7;
}
if (this.payload.length < offset + 19) return null;
// Check if compressed format
const isCompressed = this.isCompressedPosition(this.payload.substring(offset));
let position: any;
let comment = '';
if (isCompressed) {
// Compressed format: /YYYYXXXX$csT
const compressed = this.parseCompressedPosition(this.payload.substring(offset));
if (!compressed) return null;
position = {
latitude: compressed.latitude,
longitude: compressed.longitude,
symbol: compressed.symbol,
};
if (compressed.altitude !== undefined) {
position.altitude = compressed.altitude;
}
offset += 13; // Compressed position is 13 chars
comment = this.payload.substring(offset);
} else {
// Uncompressed format: DDMMmmH/DDDMMmmH$
const uncompressed = this.parseUncompressedPosition(this.payload.substring(offset));
if (!uncompressed) return null;
position = {
latitude: uncompressed.latitude,
longitude: uncompressed.longitude,
symbol: uncompressed.symbol,
};
if (uncompressed.ambiguity !== undefined) {
position.ambiguity = uncompressed.ambiguity;
}
offset += 19; // Uncompressed position is 19 chars
comment = this.payload.substring(offset);
}
// Parse altitude from comment if present (format: /A=NNNNNN)
const altMatch = comment.match(/\/A=(\d{6})/);
if (altMatch) {
position.altitude = parseInt(altMatch[1], 10) * 0.3048; // feet to meters
}
if (comment) {
position.comment = comment;
}
return {
type: 'position',
timestamp,
position,
messaging,
};
} catch (e) {
return null;
}
}
private parseTimestamp(timeStr: string): Timestamp | undefined {
if (timeStr.length !== 7) return undefined;
const timeType = timeStr.charAt(6);
if (timeType === 'z') {
// DHM format: Day-Hour-Minute (UTC)
return new Timestamp(
parseInt(timeStr.substring(2, 4), 10),
parseInt(timeStr.substring(4, 6), 10),
'DHM',
{
day: parseInt(timeStr.substring(0, 2), 10),
zulu: true,
}
);
} else if (timeType === 'h') {
// HMS format: Hour-Minute-Second (UTC)
return new Timestamp(
parseInt(timeStr.substring(0, 2), 10),
parseInt(timeStr.substring(2, 4), 10),
'HMS',
{
seconds: parseInt(timeStr.substring(4, 6), 10),
zulu: true,
}
);
} else if (timeType === '/') {
// MDHM format: Month-Day-Hour-Minute (local)
return new Timestamp(
parseInt(timeStr.substring(4, 6), 10),
parseInt(timeStr.substring(6, 8), 10),
'MDHM',
{
month: parseInt(timeStr.substring(0, 2), 10),
day: parseInt(timeStr.substring(2, 4), 10),
zulu: false,
}
);
}
return undefined;
}
private isCompressedPosition(data: string): boolean {
if (data.length < 13) return false;
// Uncompressed format has / at position 8 (symbol table separator)
// Format: DDMMmmH/DDDMMmmH$ where / is at position 8
if (data.length >= 19 && data.charAt(8) === '/') {
return false; // It's uncompressed
}
// For compressed format, check if the position part looks like base-91 encoded data
// Compressed format: STYYYYXXXXcsT where ST is symbol table/code
// Base-91 chars are in range 33-124 (! to |)
const lat1 = data.charCodeAt(1);
const lat2 = data.charCodeAt(2);
const lon1 = data.charCodeAt(5);
const lon2 = data.charCodeAt(6);
return lat1 >= 33 && lat1 <= 124 &&
lat2 >= 33 && lat2 <= 124 &&
lon1 >= 33 && lon1 <= 124 &&
lon2 >= 33 && lon2 <= 124;
}
private parseCompressedPosition(data: string): { latitude: number; longitude: number; symbol: any; altitude?: number } | null {
if (data.length < 13) return null;
const symbolTable = data.charAt(0);
const symbolCode = data.charAt(9);
// Extract base-91 encoded position (4 characters each)
const latStr = data.substring(1, 5);
const lonStr = data.substring(5, 9);
try {
// Decode base-91 encoded latitude and longitude
const latBase91 = base91ToNumber(latStr);
const lonBase91 = base91ToNumber(lonStr);
// Convert to degrees
const latitude = 90 - (latBase91 / 380926);
const longitude = -180 + (lonBase91 / 190463);
const result: any = {
latitude,
longitude,
symbol: {
table: symbolTable,
code: symbolCode,
},
};
// Check for compressed altitude (csT format)
const cs = data.charAt(10);
const t = data.charCodeAt(11);
if (cs === ' ' && t >= 33 && t <= 124) {
// Compressed altitude: altitude = 1.002^(t-33) feet
const altFeet = Math.pow(1.002, t - 33);
result.altitude = altFeet * 0.3048; // Convert to meters
}
return result;
} catch (e) {
return null;
}
}
private parseUncompressedPosition(data: string): { latitude: number; longitude: number; symbol: any; ambiguity?: number } | null {
if (data.length < 19) return null;
// Format: DDMMmmH/DDDMMmmH$ where H is hemisphere, $ is symbol code
// Positions: 0-7 (latitude), 8 (symbol table), 9-17 (longitude), 18 (symbol code)
// Spaces may replace rightmost digits for ambiguity/privacy
const latStr = data.substring(0, 8); // DDMMmmH (8 chars: 49 03.50 N)
const symbolTable = data.charAt(8);
const lonStr = data.substring(9, 18); // DDDMMmmH (9 chars: 072 01.75 W)
const symbolCode = data.charAt(18);
// Count and handle ambiguity (spaces in minutes part replace rightmost digits)
let ambiguity = 0;
const latSpaceCount = (latStr.match(/ /g) || []).length;
const lonSpaceCount = (lonStr.match(/ /g) || []).length;
if (latSpaceCount > 0 || lonSpaceCount > 0) {
// Use the maximum space count (they should be the same, but be defensive)
ambiguity = Math.max(latSpaceCount, lonSpaceCount);
}
// Replace spaces with zeros for parsing
const latStrNormalized = latStr.replace(/ /g, '0');
const lonStrNormalized = lonStr.replace(/ /g, '0');
// Parse latitude
const latDeg = parseInt(latStrNormalized.substring(0, 2), 10);
const latMin = parseFloat(latStrNormalized.substring(2, 7));
const latHem = latStrNormalized.charAt(7);
if (isNaN(latDeg) || isNaN(latMin)) return null;
if (latHem !== 'N' && latHem !== 'S') return null;
let latitude = latDeg + (latMin / 60);
if (latHem === 'S') latitude = -latitude;
// Parse longitude
const lonDeg = parseInt(lonStrNormalized.substring(0, 3), 10);
const lonMin = parseFloat(lonStrNormalized.substring(3, 8));
const lonHem = lonStrNormalized.charAt(8);
if (isNaN(lonDeg) || isNaN(lonMin)) return null;
if (lonHem !== 'E' && lonHem !== 'W') return null;
let longitude = lonDeg + (lonMin / 60);
if (lonHem === 'W') longitude = -longitude;
const result: any = {
latitude,
longitude,
symbol: {
table: symbolTable,
code: symbolCode,
},
};
if (ambiguity > 0) {
result.ambiguity = ambiguity;
}
return result;
}
private decodeMicE(dataType: string): DecodedPayload | null {
try {
// Mic-E encodes position in both destination address and information field
const dest = this.destination.call;
if (dest.length < 6) return null;
if (this.payload.length < 9) return null; // Need at least data type + 8 bytes
// Decode latitude from destination address (6 characters)
const latResult = this.decodeMicELatitude(dest);
if (!latResult) return null;
const { latitude, messageType, longitudeOffset, isWest, isStandard } = latResult;
// Parse information field (skip data type identifier at position 0)
let offset = 1;
// Longitude: 3 bytes (degrees, minutes, hundredths)
const lonDegRaw = this.payload.charCodeAt(offset) - 28;
const lonMinRaw = this.payload.charCodeAt(offset + 1) - 28;
const lonHunRaw = this.payload.charCodeAt(offset + 2) - 28;
offset += 3;
// Apply longitude offset and hemisphere
let lonDeg = lonDegRaw;
if (longitudeOffset) {
lonDeg += 100;
}
if (lonDeg >= 180 && lonDeg <= 189) {
lonDeg -= 80;
} else if (lonDeg >= 190 && lonDeg <= 199) {
lonDeg -= 190;
}
let longitude = lonDeg + (lonMinRaw / 60.0) + (lonHunRaw / 6000.0);
if (isWest) {
longitude = -longitude;
}
// Speed and course: 3 bytes
const sp = this.payload.charCodeAt(offset) - 28;
const dc = this.payload.charCodeAt(offset + 1) - 28;
const se = this.payload.charCodeAt(offset + 2) - 28;
offset += 3;
let speed = (sp * 10) + Math.floor(dc / 10); // Speed in knots
let course = ((dc % 10) * 100) + se; // Course in degrees
if (course >= 400) course -= 400;
if (speed >= 800) speed -= 800;
// Convert speed from knots to km/h
const speedKmh = speed * 1.852;
// Symbol code and table
if (this.payload.length < offset + 2) return null;
const symbolCode = this.payload.charAt(offset);
const symbolTable = this.payload.charAt(offset + 1);
offset += 2;
// Parse remaining data (altitude, comment, telemetry)
const remaining = this.payload.substring(offset);
let altitude: number | undefined = undefined;
let comment = remaining;
// Check for altitude in various formats
// Format 1: }xyz where xyz is altitude in base-91 (obsolete)
// Format 2: /A=NNNNNN where NNNNNN is altitude in feet
const altMatch = remaining.match(/\/A=(\d{6})/);
if (altMatch) {
altitude = parseInt(altMatch[1], 10) * 0.3048; // feet to meters
} else if (remaining.startsWith('}')) {
// Base-91 altitude (3 characters after })
if (remaining.length >= 4) {
try {
const altBase91 = remaining.substring(1, 4);
const altFeet = base91ToNumber(altBase91) - 10000;
altitude = altFeet * 0.3048; // feet to meters
} catch (e) {
// Ignore altitude parsing errors
}
}
}
const result: any = {
type: 'position',
position: {
latitude,
longitude,
symbol: {
table: symbolTable,
code: symbolCode,
},
},
messaging: true, // Mic-E is always messaging-capable
micE: {
messageType,
isStandard,
},
};
if (speed > 0) {
result.position.speed = speedKmh;
}
if (course > 0 && course < 360) {
result.position.course = course;
}
if (altitude !== undefined) {
result.position.altitude = altitude;
}
if (comment) {
result.position.comment = comment;
}
return result;
} catch (e) {
return null;
}
}
private decodeMicELatitude(dest: string): {
latitude: number;
messageType: string;
longitudeOffset: boolean;
isWest: boolean;
isStandard: boolean;
} | null {
if (dest.length < 6) return null;
// Each destination character encodes a latitude digit and message bits
const digits: number[] = [];
const messageBits: number[] = [];
for (let i = 0; i < 6; i++) {
const char = dest.charAt(i);
const code = dest.charCodeAt(i);
let digit: number;
let msgBit: number;
if (code >= 48 && code <= 57) {
// '0'-'9'
digit = code - 48;
msgBit = 0;
} else if (code >= 65 && code <= 74) {
// 'A'-'J' (A=0, B=1, ... J=9)
digit = code - 65;
msgBit = 1;
} else if (code === 75) {
// 'K' means space (used for ambiguity)
digit = 0;
msgBit = 1;
} else if (code === 76) {
// 'L' means space
digit = 0;
msgBit = 0;
} else if (code >= 80 && code <= 89) {
// 'P'-'Y' custom message types (P=0, Q=1, R=2, ... Y=9)
digit = code - 80;
msgBit = 1;
} else if (code === 90) {
// 'Z' means space
digit = 0;
msgBit = 1;
} else {
return null; // Invalid character
}
digits.push(digit);
messageBits.push(msgBit);
}
// Decode latitude: format is DDMM.HH (degrees, minutes, hundredths)
const latDeg = digits[0] * 10 + digits[1];
const latMin = digits[2] * 10 + digits[3];
const latHun = digits[4] * 10 + digits[5];
let latitude = latDeg + (latMin / 60.0) + (latHun / 6000.0);
// Message bits determine hemisphere and other flags
// Bit 3 (messageBits[3]): 0 = North, 1 = South
// Bit 4 (messageBits[4]): 0 = West, 1 = East
// Bit 5 (messageBits[5]): 0 = longitude offset +0, 1 = longitude offset +100
const isNorth = messageBits[3] === 0;
const isWest = messageBits[4] === 0;
const longitudeOffset = messageBits[5] === 1;
if (!isNorth) {
latitude = -latitude;
}
// Decode message type from bits 0, 1, 2
const msgValue = messageBits[0] * 4 + messageBits[1] * 2 + messageBits[2];
const messageTypes = [
'M0: Off Duty',
'M1: En Route',
'M2: In Service',
'M3: Returning',
'M4: Committed',
'M5: Special',
'M6: Priority',
'M7: Emergency',
];
const messageType = messageTypes[msgValue] || 'Unknown';
// Standard vs custom message indicator
const isStandard = messageBits[0] === 1;
return {
latitude,
messageType,
longitudeOffset,
isWest,
isStandard,
};
}
private decodeMessage(): DecodedPayload | null {
// TODO: Implement message decoding
return null;
}
private decodeObject(): DecodedPayload | null {
// TODO: Implement object decoding
return null;
}
private decodeItem(): DecodedPayload | null {
// TODO: Implement item decoding
return null;
}
private decodeStatus(): DecodedPayload | null {
// TODO: Implement status decoding
return null;
}
private decodeQuery(): DecodedPayload | null {
// TODO: Implement query decoding
return null;
}
private decodeTelemetry(): DecodedPayload | null {
// TODO: Implement telemetry decoding
return null;
}
private decodeWeather(): DecodedPayload | null {
// TODO: Implement weather decoding
return null;
}
private decodeRawGPS(): DecodedPayload | null {
// TODO: Implement raw GPS decoding
return null;
}
private decodeCapabilities(): DecodedPayload | null {
// TODO: Implement capabilities decoding
return null;
}
private decodeUserDefined(): DecodedPayload | null {
// TODO: Implement user-defined decoding
return null;
}
private decodeThirdParty(): DecodedPayload | null {
// TODO: Implement third-party decoding
return null;
}
}
export const parseFrame = (data: string): Frame => {
const routeSepIndex = data.indexOf(':');
if (routeSepIndex === -1) {
throw new Error('APRS: invalid frame, no route separator found');
}
const route = data.slice(0, routeSepIndex);
const payload = data.slice(routeSepIndex + 1);
const parts = route.split('>');
if (parts.length < 2) {
throw new Error('APRS: invalid addresses in route');
}
const source = parseAddress(parts[0]);
const destinationAndPath = parts[1].split(',');
const destination = parseAddress(destinationAndPath[0]);
const path = destinationAndPath.slice(1).map(addr => parseAddress(addr));
return new Frame(source, destination, path, payload);
}

View File

@@ -0,0 +1,302 @@
export interface Address {
call: string;
ssid: string;
isRepeated: boolean;
}
export interface Frame {
source: Address;
destination: Address;
path: Address[];
payload: string;
}
// APRS Data Type Identifiers (first character of payload)
export const DataTypeIdentifier = {
// Position Reports
PositionNoTimestampNoMessaging: '!',
PositionNoTimestampWithMessaging: '=',
PositionWithTimestampNoMessaging: '/',
PositionWithTimestampWithMessaging: '@',
// Mic-E
MicECurrent: '`',
MicEOld: "'",
// Messages and Bulletins
Message: ':',
// Objects and Items
Object: ';',
Item: ')',
// Status
Status: '>',
// Query
Query: '?',
// Telemetry
TelemetryData: 'T',
// Weather
WeatherReportNoPosition: '_',
// Raw GPS Data
RawGPS: '$',
// Station Capabilities
StationCapabilities: '<',
// User-Defined
UserDefined: '{',
// Third-Party Traffic
ThirdParty: '}',
// Invalid/Test Data
InvalidOrTest: ',',
} as const;
export type DataTypeIdentifier = typeof DataTypeIdentifier[keyof typeof DataTypeIdentifier];
// Position data common to multiple formats
export interface Position {
latitude: number; // Decimal degrees
longitude: number; // Decimal degrees
altitude?: number; // Meters
symbol?: {
table: string; // Symbol table identifier
code: string; // Symbol code
};
comment?: string;
}
export interface Timestamp {
day?: number; // Day of month (DHM format)
month?: number; // Month (MDHM format)
hours: number;
minutes: number;
seconds?: number;
format: 'DHM' | 'HMS' | 'MDHM'; // Day-Hour-Minute, Hour-Minute-Second, Month-Day-Hour-Minute
zulu?: boolean; // Is UTC/Zulu time
toDate(): Date; // Convert to Date object respecting timezone
}
// Position Report Payload
export interface PositionPayload {
type: 'position';
timestamp?: Timestamp;
position: Position;
messaging: boolean; // Whether APRS messaging is enabled
ambiguity?: number; // Position ambiguity (0-4)
}
// Compressed Position Format
export interface CompressedPosition {
latitude: number;
longitude: number;
symbol: {
table: string;
code: string;
};
course?: number; // Degrees
speed?: number; // Knots
range?: number; // Miles
altitude?: number; // Feet
radioRange?: number; // Miles
compression: 'old' | 'current';
}
// Mic-E Payload (compressed in destination address)
export interface MicEPayload {
type: 'mic-e';
position: Position;
course?: number;
speed?: number;
altitude?: number;
messageType?: string; // Standard Mic-E message
telemetry?: number[]; // Optional telemetry channels
status?: string;
}
// Message Payload
export interface MessagePayload {
type: 'message';
addressee: string; // 9 character padded callsign
text: string; // Message text
messageNumber?: string; // Message ID for acknowledgment
ack?: string; // Acknowledgment of message ID
reject?: string; // Rejection of message ID
}
// Bulletin/Announcement (variant of message)
export interface BulletinPayload {
type: 'bulletin';
bulletinId: string; // Bulletin identifier (BLN#)
text: string;
group?: string; // Optional group bulletin
}
// Object Payload
export interface ObjectPayload {
type: 'object';
name: string; // 9 character object name
timestamp: Timestamp;
alive: boolean; // True if object is active, false if killed
position: Position;
course?: number;
speed?: number;
}
// Item Payload
export interface ItemPayload {
type: 'item';
name: string; // 3-9 character item name
alive: boolean; // True if item is active, false if killed
position: Position;
}
// Status Payload
export interface StatusPayload {
type: 'status';
timestamp?: Timestamp;
text: string;
maidenhead?: string; // Optional Maidenhead grid locator
symbol?: {
table: string;
code: string;
};
}
// Query Payload
export interface QueryPayload {
type: 'query';
queryType: string; // e.g., 'APRSD', 'APRST', 'PING'
target?: string; // Target callsign or area
}
// Telemetry Data Payload
export interface TelemetryDataPayload {
type: 'telemetry-data';
sequence: number;
analog: number[]; // Up to 5 analog channels
digital: number; // 8-bit digital value
}
// Telemetry Parameter Names
export interface TelemetryParameterPayload {
type: 'telemetry-parameters';
names: string[]; // Parameter names
}
// Telemetry Unit/Label
export interface TelemetryUnitPayload {
type: 'telemetry-units';
units: string[]; // Units for each parameter
}
// Telemetry Coefficients
export interface TelemetryCoefficientsPayload {
type: 'telemetry-coefficients';
coefficients: {
a: number[]; // a coefficients
b: number[]; // b coefficients
c: number[]; // c coefficients
};
}
// Telemetry Bit Sense/Project Name
export interface TelemetryBitSensePayload {
type: 'telemetry-bitsense';
sense: number; // 8-bit sense value
projectName?: string;
}
// Weather Report Payload
export interface WeatherPayload {
type: 'weather';
timestamp?: Timestamp;
position?: Position;
windDirection?: number; // Degrees
windSpeed?: number; // MPH
windGust?: number; // MPH
temperature?: number; // Fahrenheit
rainLastHour?: number; // Hundredths of inch
rainLast24Hours?: number; // Hundredths of inch
rainSinceMidnight?: number; // Hundredths of inch
humidity?: number; // Percent
pressure?: number; // Tenths of millibar
luminosity?: number; // Watts per square meter
snowfall?: number; // Inches
rawRain?: number; // Raw rain counter
software?: string; // Weather software type
weatherUnit?: string; // Weather station type
}
// Raw GPS Payload (NMEA sentences)
export interface RawGPSPayload {
type: 'raw-gps';
sentence: string; // Raw NMEA sentence
}
// Station Capabilities Payload
export interface StationCapabilitiesPayload {
type: 'capabilities';
capabilities: string[];
}
// User-Defined Payload
export interface UserDefinedPayload {
type: 'user-defined';
userPacketType: string;
data: string;
}
// Third-Party Traffic Payload
export interface ThirdPartyPayload {
type: 'third-party';
header: string; // Source path of third-party packet
payload: string; // Nested APRS packet
}
// DF Report Payload
export interface DFReportPayload {
type: 'df-report';
timestamp?: Timestamp;
position: Position;
course?: number;
bearing?: number; // Direction finding bearing
quality?: number; // Signal quality
strength?: number; // Signal strength
height?: number; // Antenna height
gain?: number; // Antenna gain
directivity?: string; // Antenna directivity pattern
}
// Union type for all decoded payload types
export type DecodedPayload =
| PositionPayload
| MicEPayload
| MessagePayload
| BulletinPayload
| ObjectPayload
| ItemPayload
| StatusPayload
| QueryPayload
| TelemetryDataPayload
| TelemetryParameterPayload
| TelemetryUnitPayload
| TelemetryCoefficientsPayload
| TelemetryBitSensePayload
| WeatherPayload
| RawGPSPayload
| StationCapabilitiesPayload
| UserDefinedPayload
| ThirdPartyPayload
| DFReportPayload;
// Extended Frame with decoded payload
export interface DecodedFrame extends Frame {
decoded?: DecodedPayload;
}

View File

@@ -0,0 +1,654 @@
import { describe, expect, it } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import { GroupSecret, Packet } from './meshcore';
import type {
AdvertPayload,
AnonReqPayload,
ControlPayload,
GroupDataPayload,
GroupTextPayload,
MultipartPayload,
PathPayload,
RawCustomPayload,
RequestPayload,
ResponsePayload,
TextPayload,
TracePayload,
} from './meshcore.types';
import { AdvertisementFlags, PayloadType, RouteType } from './meshcore.types';
describe('GroupSecret', () => {
describe('fromName', () => {
it('should generate correct hash for "#test"', () => {
const secret = GroupSecret.fromName('#test');
const bytes = secret.toBytes();
// Test vector: '#test' should produce '9cd8fcf22a47333b591d96a2b848b73f' (first 16 bytes)
const hex = bytesToHex(bytes.slice(0, 16));
expect(hex).toBe('9cd8fcf22a47333b591d96a2b848b73f');
});
it('should generate a 32-byte key', () => {
const secret = GroupSecret.fromName('#test');
const bytes = secret.toBytes();
expect(bytes.length).toBe(32);
});
it('should generate consistent hashes for same input', () => {
const secret1 = GroupSecret.fromName('mygroup');
const secret2 = GroupSecret.fromName('mygroup');
expect(secret1.toString()).toBe(secret2.toString());
});
it('should generate different hashes for different inputs', () => {
const secret1 = GroupSecret.fromName('group1');
const secret2 = GroupSecret.fromName('group2');
expect(secret1.toString()).not.toBe(secret2.toString());
});
it('should generate valid channel hash', () => {
const secret = GroupSecret.fromName('#test');
const hash = secret.toHash();
// Channel hash should be 2 hex characters
expect(hash).toMatch(/^[0-9a-f]{2}$/);
});
});
describe('constructor', () => {
it('should accept 32-byte hex string', () => {
const hexKey = '9cd8fcf22a47333b591d96a2b848b73f9cd8fcf22a47333b591d96a2b848b73f';
const secret = new GroupSecret(hexKey);
expect(secret.toString()).toBe(hexKey);
});
it('should accept 32-byte Uint8Array', () => {
const bytes = new Uint8Array(32).fill(0x42);
const secret = new GroupSecret(bytes);
expect(secret.toBytes()).toEqual(bytes);
});
it('should accept and pad 16-byte hex string', () => {
const hexKey = '9cd8fcf22a47333b591d96a2b848b73f';
const secret = new GroupSecret(hexKey);
const bytes = secret.toBytes();
expect(bytes.length).toBe(32);
// First 16 bytes should be the input
expect(bytesToHex(bytes.slice(0, 16))).toBe(hexKey);
// Last 16 bytes should be zeros
expect(bytes.slice(16, 32)).toEqual(new Uint8Array(16));
});
it('should accept and pad 16-byte Uint8Array', () => {
const input = new Uint8Array(16).fill(0x42);
const secret = new GroupSecret(input);
const bytes = secret.toBytes();
expect(bytes.length).toBe(32);
// First 16 bytes should be the input
expect(bytes.slice(0, 16)).toEqual(input);
// Last 16 bytes should be zeros
expect(bytes.slice(16, 32)).toEqual(new Uint8Array(16));
});
it('should throw error for invalid key length', () => {
expect(() => new GroupSecret('abcd')).toThrow('invalid group secret');
});
});
describe('toHash', () => {
it('should return first byte as 2-char hex string', () => {
const bytes = new Uint8Array(32);
bytes[0] = 0x9c; // First byte from test vector
Object.defineProperty(bytes, 'length', { value: 32 }); // Ensure length is 32
for (let i = 1; i < 16; i++) {
bytes[i] = 0;
}
bytes.set(new Uint8Array([
0x9c, 0xd8, 0xfc, 0xf2, 0x2a, 0x47, 0x33, 0x3b,
0x59, 0x1d, 0x96, 0xa2, 0xb8, 0x48, 0xb7, 0x3f
]), 16);
const secret = new GroupSecret(bytes);
expect(secret.toHash()).toBe('9c');
});
it('should pad single-digit hex with zero', () => {
const bytes = new Uint8Array(32);
bytes[0] = 0x05; // Should become '05', not '5'
const secret = new GroupSecret(bytes);
expect(secret.toHash()).toBe('05');
});
});
describe('specific group test vectors', () => {
describe('Public group', () => {
const publicKey = '8b3387e9c5cdea6ac9e5edbaa115cd72';
it('should create GroupSecret with 16-byte key for Public group', () => {
const secret = new GroupSecret(publicKey);
const bytes = secret.toBytes();
expect(bytes.length).toBe(32);
// First 16 bytes should match the input key
expect(bytesToHex(bytes.slice(0, 16))).toBe(publicKey);
});
it('should pad Public group key with 16 trailing zero bytes', () => {
const secret = new GroupSecret(publicKey);
const bytes = secret.toBytes();
// Last 16 bytes should be zeros
expect(bytes.slice(16, 32)).toEqual(new Uint8Array(16));
});
it('should generate correct channel hash for Public group', () => {
const secret = new GroupSecret(publicKey);
const hash = secret.toHash();
// First byte of the key (0x8b) should be '8b'
expect(hash).toBe('8b');
});
it('should NOT match key derived from name "Public"', () => {
const secretFromKey = new GroupSecret(publicKey);
const secretFromName = GroupSecret.fromName('Public');
// These should be different since the key can't be derived from the name
expect(secretFromKey.toString()).not.toBe(secretFromName.toString());
});
it('should have different channel hash than fromName("Public")', () => {
const secretFromKey = new GroupSecret(publicKey);
const secretFromName = GroupSecret.fromName('Public');
expect(secretFromKey.toHash()).not.toBe(secretFromName.toHash());
});
});
});
});
describe('Packet', () => {
// Helper function to create a minimal valid packet
const createPacketData = (routeType: number, payloadType: number, payloadData: Uint8Array): Uint8Array => {
const header = (routeType & 0x03) | ((payloadType & 0x0F) << 2) | (0x01 << 6); // version = 1
const pathLength = 0; // No path for simplicity
// Check if this route type needs transport codes
const needsTransportCodes = routeType === RouteType.TRANSPORT_FLOOD || routeType === RouteType.TRANSPORT_DIRECT;
if (needsTransportCodes) {
// Add transport codes (4 bytes: two uint16 LE)
const transportCodes = new Uint8Array([0x00, 0x00, 0x00, 0x00]);
return new Uint8Array([header, ...transportCodes, pathLength, ...payloadData]);
}
return new Uint8Array([header, pathLength, ...payloadData]);
};
describe('constructor and parsing', () => {
it('should create empty packet with no data', () => {
const packet = new Packet();
expect(packet).toBeInstanceOf(Packet);
});
it('should parse packet from Uint8Array', () => {
const payloadData = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.TRACE, payloadData);
const packet = new Packet(packetData);
expect(packet.decode()).toBeDefined();
});
it('should parse packet from base64 string', () => {
const payloadData = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.TRACE, payloadData);
// Convert to base64
const base64 = btoa(String.fromCharCode(...packetData));
const packet = new Packet(base64);
expect(packet.decode()).toBeDefined();
});
it('should throw error for packet without payload', () => {
const data = new Uint8Array([0x04, 0x00]); // header + pathLength, no payload
expect(() => new Packet(data)).toThrow();
});
it('should throw error for invalid path length', () => {
// pathLength with hashSize=4 (reserved) and hashCount=1
const data = new Uint8Array([0x04, 0xC1, 0x00]); // header, invalid pathLength, dummy payload
expect(() => new Packet(data)).toThrow();
});
});
describe('decode - REQUEST payload', () => {
it('should decode REQUEST payload', () => {
// REQUEST: dstHash(1) + srcHash(1) + cipherMAC(2) + cipherText(n)
const payloadData = new Uint8Array([
0xAB, // dstHash
0xCD, // srcHash
0x12, 0x34, // cipherMAC (LE)
0x01, 0x02, 0x03, 0x04 // cipherText
]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.REQUEST, payloadData);
const packet = new Packet(packetData);
const decoded = packet.decode() as RequestPayload;
expect(decoded.payloadType).toBe(PayloadType.REQUEST);
expect(decoded.dstHash).toBe('ab');
expect(decoded.srcHash).toBe('cd');
expect(decoded.cipherMAC).toBe(0x3412);
expect(decoded.cipherText).toEqual(new Uint8Array([0x01, 0x02, 0x03, 0x04]));
});
});
describe('decode - RESPONSE payload', () => {
it('should decode RESPONSE payload', () => {
const payloadData = new Uint8Array([
0x11, // dstHash
0x22, // srcHash
0xAB, 0xCD, // cipherMAC (LE)
0x05, 0x06, 0x07 // cipherText
]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.RESPONSE, payloadData);
const packet = new Packet(packetData);
const decoded = packet.decode() as ResponsePayload;
expect(decoded.payloadType).toBe(PayloadType.RESPONSE);
expect(decoded.dstHash).toBe('11');
expect(decoded.srcHash).toBe('22');
});
});
describe('decode - TEXT payload', () => {
it('should decode TEXT payload', () => {
const payloadData = new Uint8Array([
0x33, // dstHash
0x44, // srcHash
0x11, 0x22, // cipherMAC (LE)
0x48, 0x65, 0x6C, 0x6C, 0x6F // "Hello" encrypted
]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.TEXT, payloadData);
const packet = new Packet(packetData);
const decoded = packet.decode() as TextPayload;
expect(decoded.payloadType).toBe(PayloadType.TEXT);
expect(decoded.dstHash).toBe('33');
expect(decoded.srcHash).toBe('44');
});
});
describe('decode - ACK payload', () => {
it('should decode ACK payload', () => {
const payloadData = new Uint8Array([
0x55, // dstHash
0x66, // srcHash
0xFF, 0xFF, // cipherMAC (LE)
0xAA, 0xBB // cipherText
]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.ACK, payloadData);
const packet = new Packet(packetData);
// Note: ACK uses the same encrypted structure as REQUEST/RESPONSE/TEXT
const decoded = packet.decode() as RequestPayload;
expect(decoded.payloadType).toBe(PayloadType.ACK);
expect(decoded.dstHash).toBe('55');
expect(decoded.srcHash).toBe('66');
});
});
describe('decode - ADVERT payload', () => {
it('should decode ADVERT payload with no optional fields', () => {
const publicKey = new Uint8Array(32).fill(0x42);
const signature = new Uint8Array(64).fill(0xAA);
const timestamp = 1234567890; // Unix timestamp in seconds
const flags = 0x00; // No optional fields
const payloadData = new Uint8Array([
...publicKey,
timestamp & 0xFF, (timestamp >> 8) & 0xFF, (timestamp >> 16) & 0xFF, (timestamp >> 24) & 0xFF, // timestamp LE
...signature,
flags
]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.ADVERT, payloadData);
const packet = new Packet(packetData);
const decoded = packet.decode() as AdvertPayload;
expect(decoded.payloadType).toBe(PayloadType.ADVERT);
expect(decoded.publicKey).toEqual(publicKey);
expect(decoded.signature).toEqual(signature);
expect(decoded.timestamp.getTime()).toBe(timestamp);
expect(decoded.appdata.flags).toBe(flags);
});
it('should decode ADVERT payload with location', () => {
const publicKey = new Uint8Array(32).fill(0x42);
const signature = new Uint8Array(64).fill(0xAA);
const timestamp = 1234567890;
const flags = AdvertisementFlags.HAS_LOCATION; // 0x10
const latitude = 37500000; // 37.5 degrees
const longitude = -122400000; // -122.4 degrees
const payloadData = new Uint8Array([
...publicKey,
timestamp & 0xFF, (timestamp >> 8) & 0xFF, (timestamp >> 16) & 0xFF, (timestamp >> 24) & 0xFF,
...signature,
flags,
latitude & 0xFF, (latitude >> 8) & 0xFF, (latitude >> 16) & 0xFF, (latitude >> 24) & 0xFF, // lat LE
longitude & 0xFF, (longitude >> 8) & 0xFF, (longitude >> 16) & 0xFF, (longitude >> 24) & 0xFF // lon LE
]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.ADVERT, payloadData);
const packet = new Packet(packetData);
const decoded = packet.decode() as AdvertPayload;
expect(decoded.payloadType).toBe(PayloadType.ADVERT);
expect(decoded.appdata.flags).toBe(flags);
expect(decoded.appdata.latitude).toBeCloseTo(37.5, 6);
expect(decoded.appdata.longitude).toBeCloseTo(-122.4, 6);
});
it('should decode ADVERT payload with name', () => {
const publicKey = new Uint8Array(32).fill(0x42);
const signature = new Uint8Array(64).fill(0xAA);
const timestamp = 1234567890;
const flags = AdvertisementFlags.HAS_NAME; // 0x80
const name = 'TestNode';
const nameBytes = new TextEncoder().encode(name);
const payloadData = new Uint8Array([
...publicKey,
timestamp & 0xFF, (timestamp >> 8) & 0xFF, (timestamp >> 16) & 0xFF, (timestamp >> 24) & 0xFF,
...signature,
flags,
...nameBytes
]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.ADVERT, payloadData);
const packet = new Packet(packetData);
const decoded = packet.decode() as AdvertPayload;
expect(decoded.payloadType).toBe(PayloadType.ADVERT);
expect(decoded.appdata.name).toBe(name);
});
});
describe('decode - GROUP_TEXT payload', () => {
it('should decode GROUP_TEXT payload', () => {
const payloadData = new Uint8Array([
0x9C, // channelHash
0xAA, 0xBB, // cipherMAC
0x01, 0x02, 0x03, 0x04 // cipherText
]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.GROUP_TEXT, payloadData);
const packet = new Packet(packetData);
const decoded = packet.decode() as GroupTextPayload;
expect(decoded.payloadType).toBe(PayloadType.GROUP_TEXT);
expect(decoded.channelHash).toBe('9c');
expect(decoded.cipherMAC).toEqual(new Uint8Array([0xAA, 0xBB]));
expect(decoded.cipherText).toEqual(new Uint8Array([0x01, 0x02, 0x03, 0x04]));
});
});
describe('decode - GROUP_DATA payload', () => {
it('should decode GROUP_DATA payload', () => {
const payloadData = new Uint8Array([
0x8B, // channelHash
0xFF, 0xEE, // cipherMAC
0x10, 0x20, 0x30 // cipherText
]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.GROUP_DATA, payloadData);
const packet = new Packet(packetData);
const decoded = packet.decode() as GroupDataPayload;
expect(decoded.payloadType).toBe(PayloadType.GROUP_DATA);
expect(decoded.channelHash).toBe('8b');
expect(decoded.cipherMAC).toEqual(new Uint8Array([0xFF, 0xEE]));
});
});
describe('decode - ANON_REQ payload', () => {
it('should decode ANON_REQ payload', () => {
const publicKey = new Uint8Array(32).fill(0x99);
const payloadData = new Uint8Array([
0x77, // dstHash
...publicKey,
0x12, 0x34, // cipherMAC
0xAA, 0xBB, 0xCC // cipherText
]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.ANON_REQ, payloadData);
const packet = new Packet(packetData);
const decoded = packet.decode() as AnonReqPayload;
expect(decoded.payloadType).toBe(PayloadType.ANON_REQ);
expect(decoded.dstHash).toBe('77');
expect(decoded.publicKey).toEqual(publicKey);
expect(decoded.cipherMAC).toEqual(new Uint8Array([0x12, 0x34]));
expect(decoded.cipherText).toEqual(new Uint8Array([0xAA, 0xBB, 0xCC]));
});
});
describe('decode - PATH payload', () => {
it('should decode PATH payload', () => {
const payloadData = new Uint8Array([
0x88, // dstHash
0x99, // srcHash
0xAA, 0xBB, // cipherMAC (LE)
0x01, 0x02, 0x03 // cipherText
]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.PATH, payloadData);
const packet = new Packet(packetData);
const decoded = packet.decode() as PathPayload;
expect(decoded.payloadType).toBe(PayloadType.PATH);
expect(decoded.dstHash).toBe('88');
expect(decoded.srcHash).toBe('99');
});
});
describe('decode - TRACE payload', () => {
it('should decode TRACE payload', () => {
const payloadData = new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.TRACE, payloadData);
const packet = new Packet(packetData);
const decoded = packet.decode() as TracePayload;
expect(decoded.payloadType).toBe(PayloadType.TRACE);
expect(decoded.data).toEqual(payloadData);
});
});
describe('decode - MULTIPART payload', () => {
it('should decode MULTIPART payload', () => {
const payloadData = new Uint8Array([0xAA, 0xBB, 0xCC, 0xDD]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.MULTIPART, payloadData);
const packet = new Packet(packetData);
const decoded = packet.decode() as MultipartPayload;
expect(decoded.payloadType).toBe(PayloadType.MULTIPART);
expect(decoded.data).toEqual(payloadData);
});
});
describe('decode - CONTROL payload', () => {
it('should decode CONTROL payload', () => {
const flags = 0x05;
const data = new Uint8Array([0x11, 0x22, 0x33]);
const payloadData = new Uint8Array([flags, ...data]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.CONTROL, payloadData);
const packet = new Packet(packetData);
const decoded = packet.decode() as ControlPayload;
expect(decoded.payloadType).toBe(PayloadType.CONTROL);
expect(decoded.flags).toBe(flags);
expect(decoded.data).toEqual(data);
});
});
describe('decode - RAW_CUSTOM payload', () => {
it('should decode RAW_CUSTOM payload', () => {
const payloadData = new Uint8Array([0xFF, 0xEE, 0xDD, 0xCC, 0xBB]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.RAW_CUSTOM, payloadData);
const packet = new Packet(packetData);
const decoded = packet.decode() as RawCustomPayload;
expect(decoded.payloadType).toBe(PayloadType.RAW_CUSTOM);
expect(decoded.data).toEqual(payloadData);
});
});
describe('decode - all payload types', () => {
it('should successfully decode all defined payload types', () => {
const payloadTypes = [
PayloadType.REQUEST,
PayloadType.RESPONSE,
PayloadType.TEXT,
PayloadType.ACK,
PayloadType.ADVERT,
PayloadType.GROUP_TEXT,
PayloadType.GROUP_DATA,
PayloadType.ANON_REQ,
PayloadType.PATH,
PayloadType.TRACE,
PayloadType.MULTIPART,
PayloadType.CONTROL,
PayloadType.RAW_CUSTOM
];
payloadTypes.forEach(type => {
let payloadData: Uint8Array;
// Create appropriate payload data for each type
switch (type) {
case PayloadType.ADVERT: {
const publicKey = new Uint8Array(32).fill(0x42);
const signature = new Uint8Array(64).fill(0xAA);
const timestamp = 1234567890;
payloadData = new Uint8Array([
...publicKey,
timestamp & 0xFF, (timestamp >> 8) & 0xFF, (timestamp >> 16) & 0xFF, (timestamp >> 24) & 0xFF,
...signature,
0x00 // flags
]);
break;
}
case PayloadType.ANON_REQ: {
const publicKey = new Uint8Array(32).fill(0x99);
payloadData = new Uint8Array([
0x77, // dstHash
...publicKey,
0x12, 0x34, // cipherMAC
0xAA // cipherText
]);
break;
}
case PayloadType.GROUP_TEXT:
case PayloadType.GROUP_DATA:
payloadData = new Uint8Array([
0x9C, // channelHash
0xAA, 0xBB, // cipherMAC
0x01 // cipherText
]);
break;
case PayloadType.REQUEST:
case PayloadType.RESPONSE:
case PayloadType.TEXT:
case PayloadType.ACK:
case PayloadType.PATH:
payloadData = new Uint8Array([
0xAB, // dstHash
0xCD, // srcHash
0x12, 0x34, // cipherMAC
0x01 // cipherText
]);
break;
case PayloadType.CONTROL:
payloadData = new Uint8Array([0x05, 0x01, 0x02]);
break;
default:
payloadData = new Uint8Array([0x01, 0x02, 0x03]);
}
const packetData = createPacketData(RouteType.FLOOD, type, payloadData);
const packet = new Packet(packetData);
expect(() => packet.decode()).not.toThrow();
const decoded = packet.decode();
expect(decoded.payloadType).toBe(type);
});
});
it('should throw error for unknown payload type', () => {
const unknownType = 0x0E; // Not defined in PayloadType
const payloadData = new Uint8Array([0x01, 0x02]);
const packetData = createPacketData(RouteType.FLOOD, unknownType, payloadData);
const packet = new Packet(packetData);
expect(() => packet.decode()).toThrow(`can't decode payload ${unknownType}`);
});
});
describe('hash', () => {
it('should compute hash for non-TRACE packet', () => {
const payloadData = new Uint8Array([0x01, 0x02, 0x03]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.RAW_CUSTOM, payloadData);
const packet = new Packet(packetData);
const hash = packet.hash();
expect(hash).toBeInstanceOf(Uint8Array);
expect(hash.length).toBe(8);
});
it('should compute hash for TRACE packet including path', () => {
const payloadData = new Uint8Array([0x01, 0x02, 0x03]);
const packetData = createPacketData(RouteType.FLOOD, PayloadType.TRACE, payloadData);
const packet = new Packet(packetData);
const hash = packet.hash();
expect(hash).toBeInstanceOf(Uint8Array);
expect(hash.length).toBe(8);
});
});
describe('path handling', () => {
it('should parse packet with path', () => {
const header = (RouteType.FLOOD & 0x03) | ((PayloadType.TRACE & 0x0F) << 2) | (0x01 << 6);
const pathLength = 0x02; // 2 hashes, 1 byte each (hashSize=1, hashCount=2)
const path = new Uint8Array([0xAA, 0xBB]);
const payloadData = new Uint8Array([0x01, 0x02]);
const packetData = new Uint8Array([header, pathLength, ...path, ...payloadData]);
const packet = new Packet(packetData);
expect(packet.getPathHashCount()).toBe(2);
expect(packet.getPathHashSize()).toBe(1);
expect(packet.getPathBytesLength()).toBe(2);
});
});
});

View File

@@ -0,0 +1,541 @@
// Third-party libraries
import { ecb } from '@noble/ciphers/aes.js';
import { ed25519, x25519 } from '@noble/curves/ed25519.js';
import { hmac } from '@noble/hashes/hmac.js';
import { sha256, sha512 } from '@noble/hashes/sha2.js';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
// Local types
import type {
AckPayload,
AnonReqPayload,
AdvertPayload,
BasePrivateKey,
BasePublicKey,
BaseSharedSecret,
ControlPayload,
DecryptedGroupMessage,
Group,
GroupDataPayload,
GroupSecretValue,
GroupTextPayload,
MultipartPayload,
NodeHash,
PathPayload,
Payload,
PublicKeyValue,
RawCustomPayload,
RequestPayload,
ResponsePayload,
TextPayload,
TracePayload,
} from './meshcore.types';
// Local imports
import { base64ToBytes, BufferReader, constantTimeEqual } from '../util';
import {
AdvertisementFlags,
BaseGroupSecret,
BasePacket,
PayloadType,
RouteType,
} from './meshcore.types';
const MAX_PATH_SIZE = 64;
export const isValidRouteType = (value: number): value is typeof RouteType[keyof typeof RouteType] => {
return Object.values(RouteType).includes(value as any);
}
export const isValidPayloadType = (value: number): value is typeof PayloadType[keyof typeof PayloadType] => {
return Object.values(PayloadType).includes(value as any);
}
export const hasTransportCodes = (routeType: RouteType): boolean => {
return routeType === RouteType.TRANSPORT_FLOOD || routeType === RouteType.TRANSPORT_DIRECT;
}
export class Packet extends BasePacket {
constructor(data?: Uint8Array | string) {
super();
if (typeof data !== 'undefined') {
if (typeof data === 'string') {
data = base64ToBytes(data);
}
this.parse(data);
}
}
public parse(data: Uint8Array) {
const header = data[0];
this.routeType = (header >> 0) & 0x03;
this.payloadType = (header >> 2) & 0x0F;
this.version = (header >> 6) & 0x03;
let index = 1;
if (hasTransportCodes(this.routeType)) {
const view = new DataView(data.buffer, index, index + 4);
this.transportCodes = new Uint16Array(2);
this.transportCodes[0] = view.getUint16(0, true);
this.transportCodes[1] = view.getUint16(2, true);
index += 4;
}
this.pathLength = data[index];
index++;
if (!this.isValidPathLength()) {
throw new Error(`MeshCore: invalid path length ${this.pathLength}`)
}
const pathBytesLength = this.getPathBytesLength();
this.path = new Uint8Array(pathBytesLength);
for (let i = 0; i < pathBytesLength; i++) {
this.path[i] = data[index];
index++;
}
if (index >= data.length) {
throw new Error('MeshCore: invalid packet: no payload');
}
const payloadBytesLength = data.length - index;
this.payload = new Uint8Array(payloadBytesLength);
for (let i = 0; i < payloadBytesLength; i++) {
this.payload[i] = data[index];
index++;
}
}
public parseBase64(data: string) {
return this.parse(base64ToBytes(data));
}
private isValidPathLength(): boolean {
const hashCount = this.getPathHashCount();
const hashSize = this.getPathHashSize();
if (hashSize === 4) return false; // reserved
return hashCount * hashSize <= MAX_PATH_SIZE;
}
public getPathHashSize(): number {
return (this.pathLength >> 6) + 1;
}
public getPathHashCount(): number {
return this.pathLength & 63;
}
public getPathBytesLength(): number {
return this.getPathHashCount() * this.getPathHashSize();
}
public hash(): Uint8Array {
let data = new Uint8Array([this.payloadType]);
if (this.payloadType === PayloadType.TRACE) {
data = new Uint8Array([...data, ...this.path]);
}
data = new Uint8Array([...data, ...this.payload]);
const hash = sha256.create().update(data).digest();
return hash.slice(0, 8);
}
public decode(): Payload {
switch (this.payloadType) {
case PayloadType.REQUEST:
return this.decodeRequest();
case PayloadType.RESPONSE:
return this.decodeResponse();
case PayloadType.TEXT:
return this.decodeText();
case PayloadType.ACK:
return this.decodeAck();
case PayloadType.ADVERT:
return this.decodeAdvert();
case PayloadType.GROUP_TEXT:
return this.decodeGroupText();
case PayloadType.GROUP_DATA:
return this.decodeGroupData();
case PayloadType.ANON_REQ:
return this.decodeAnonReq();
case PayloadType.PATH:
return this.decodePath();
case PayloadType.TRACE:
return this.decodeTrace();
case PayloadType.MULTIPART:
return this.decodeMultipart();
case PayloadType.CONTROL:
return this.decodeControl();
case PayloadType.RAW_CUSTOM:
return this.decodeRawCustom();
default:
throw new Error(`MeshCore: can't decode payload ${this.payloadType}`)
}
}
private decodeEncrypted<T>(kind: PayloadType): T {
const buffer = new BufferReader(this.payload);
return {
payloadType: kind,
dstHash: buffer.readUint8().toString(16).padStart(2, '0'),
srcHash: buffer.readUint8().toString(16).padStart(2, '0'),
cipherMAC: buffer.readUint16LE(),
cipherText: buffer.readBytes()
} as T
}
private decodeRequest(): RequestPayload {
return this.decodeEncrypted<RequestPayload>(PayloadType.REQUEST);
}
private decodeResponse(): ResponsePayload {
return this.decodeEncrypted<ResponsePayload>(PayloadType.RESPONSE);
}
private decodeText(): TextPayload {
return this.decodeEncrypted<TextPayload>(PayloadType.TEXT);
}
private decodeAck(): AckPayload {
return this.decodeEncrypted<AckPayload>(PayloadType.ACK);
}
private decodeAdvert(): AdvertPayload {
const buffer = new BufferReader(this.payload);
let payload: AdvertPayload = {
payloadType: PayloadType.ADVERT,
publicKey: buffer.readBytes(32),
timestamp: new Date(buffer.readUint32LE()),
signature: buffer.readBytes(64),
appdata: {
flags: buffer.readUint8(),
}
}
if (payload.appdata.flags & AdvertisementFlags.HAS_LOCATION) {
payload.appdata.latitude = buffer.readUint32LE() / 1000000.0
payload.appdata.longitude = buffer.readUint32LE() / 1000000.0
}
if (payload.appdata.flags & AdvertisementFlags.HAS_FEATURE_1) {
payload.appdata.feature1 = buffer.readUint16LE()
}
if (payload.appdata.flags & AdvertisementFlags.HAS_FEATURE_2) {
payload.appdata.feature2 = buffer.readUint16LE()
}
if (payload.appdata.flags & AdvertisementFlags.HAS_NAME) {
payload.appdata.name = new TextDecoder().decode(buffer.readBytes()).toString()
}
return payload;
}
private decodeGroupEncrypted<T>(kind: PayloadType): T {
const buffer = new BufferReader(this.payload);
return {
payloadType: kind,
channelHash: buffer.readUint8().toString(16).padStart(2, '0'),
cipherMAC: buffer.readBytes(2),
cipherText: buffer.readBytes()
} as T;
}
private decodeGroupText(): GroupTextPayload {
return this.decodeGroupEncrypted<GroupTextPayload>(PayloadType.GROUP_TEXT);
}
private decodeGroupData(): GroupDataPayload {
return this.decodeGroupEncrypted<GroupDataPayload>(PayloadType.GROUP_DATA);
}
private decodeAnonReq(): AnonReqPayload {
const buffer = new BufferReader(this.payload);
return {
payloadType: PayloadType.ANON_REQ,
dstHash: buffer.readUint8().toString(16).padStart(2, '0'),
publicKey: buffer.readBytes(32),
cipherMAC: buffer.readBytes(2),
cipherText: buffer.readBytes()
}
}
private decodePath(): PathPayload {
return this.decodeEncrypted<PathPayload>(PayloadType.PATH);
}
private decodeTrace(): TracePayload {
const buffer = new BufferReader(this.payload);
return {
payloadType: PayloadType.TRACE,
data: buffer.readBytes()
}
}
private decodeMultipart(): MultipartPayload {
const buffer = new BufferReader(this.payload);
return {
payloadType: PayloadType.MULTIPART,
data: buffer.readBytes()
}
}
private decodeControl(): ControlPayload {
const buffer = new BufferReader(this.payload);
return {
payloadType: PayloadType.CONTROL,
flags: buffer.readUint8(),
data: buffer.readBytes()
}
}
private decodeRawCustom(): RawCustomPayload {
const buffer = new BufferReader(this.payload);
return {
payloadType: PayloadType.RAW_CUSTOM,
data: buffer.readBytes()
}
}
}
export class PublicKey implements BasePublicKey {
private pub: Uint8Array;
constructor(key: PublicKeyValue) {
if (key instanceof Uint8Array) {
this.pub = Uint8Array.from(key);
} else {
this.pub = hexToBytes(key);
}
if (this.pub.length !== 8 && this.pub.length !== 32) {
throw new Error(`invalid public key, expected 8 or 32 bytes, got ${this.pub.length}`);
}
}
toBytes(): Uint8Array {
return this.pub.slice();
}
toString(): string {
return bytesToHex(this.pub);
}
verify(message: Uint8Array, signature: Uint8Array): boolean {
return ed25519.verify(signature, message, this.pub);
}
}
export class PrivateKey implements BasePrivateKey {
private seed: Uint8Array;
private pub: Uint8Array = new Uint8Array(32);
private prefix: Uint8Array = new Uint8Array(32);
constructor(seed: PublicKeyValue) {
if (seed instanceof Uint8Array) {
this.seed = Uint8Array.from(seed);
} else {
this.seed = hexToBytes(seed);
}
if (this.seed.length !== 32) {
throw new Error(`invalid public key, expected 32 bytes seed, got ${this.seed.length}`);
}
this.precompute();
}
private precompute() {
const hash = sha512.create().update(this.seed).digest();
const h32 = hash.slice(0, 32);
const clampedScalar = this.clampScalar(h32);
const publicKey = ed25519.getPublicKey(clampedScalar);
this.pub.set(publicKey, 32);
this.prefix.set(hash.slice(32, 64));
}
private clampScalar(scalar: Uint8Array): Uint8Array {
const clamped = new Uint8Array(scalar);
clamped[0] &= 248; // Clear lowest 3 bits
clamped[31] &= 127; // Clear highest bit
clamped[31] |= 64; // Set second highest bit
return clamped;
}
toBytes(): Uint8Array {
return this.seed.slice();
}
toString(): string {
return bytesToHex(this.seed);
}
toPublicKey(): PublicKey {
return new PublicKey(this.seed.slice(32, 64));
}
sign(message: Uint8Array): Uint8Array {
return ed25519.sign(message, this.seed);
}
verify(message: Uint8Array, signature: Uint8Array): boolean {
return ed25519.verify(signature, message, this.pub);
}
}
export class SharedSecret implements BaseSharedSecret {
private bytes: Uint8Array;
constructor(secret: string | Uint8Array) {
if (typeof secret === 'string') {
this.bytes = hexToBytes(secret);
} else {
this.bytes = Uint8Array.from(secret);
}
if (this.bytes.length !== 32) {
throw new Error(`invalid shared secret size ${this.bytes.length}`)
}
}
decrypt(cipherText: Uint8Array, cipherMAC: Uint8Array): Uint8Array {
const ourMAC = hmac(sha256, this.bytes, cipherText).slice(0, 2);
if (!constantTimeEqual(cipherMAC, ourMAC)) {
throw new Error(`invalid MAC`)
}
const block = ecb(this.bytes);
const plain = block.decrypt(cipherText);
return plain;
}
}
export class StaticSecret {
private bytes: Uint8Array;
constructor(secret: string | Uint8Array) {
if (secret instanceof Uint8Array) {
this.bytes = Uint8Array.from(secret);
} else {
this.bytes = hexToBytes(secret);
}
if (this.bytes.length !== 32) {
throw new Error(`invalid shared secret size ${this.bytes.length}`)
}
}
diffieHellman(other: PublicKey): SharedSecret {
const bytes = x25519.getSharedSecret(this.bytes, other.toBytes());
return new SharedSecret(bytes);
}
publicKey(): PublicKey {
const bytes = x25519.getPublicKey(this.bytes);
return new PublicKey(bytes);
}
}
export class GroupSecret implements BaseGroupSecret {
private bytes: Uint8Array;
constructor(secret: GroupSecretValue) {
if (secret instanceof Uint8Array) {
this.bytes = Uint8Array.from(secret);
} else {
this.bytes = hexToBytes(secret);
}
if (this.bytes.length === 16) {
const padded = new Uint8Array(32);
padded.set(this.bytes, 0);
this.bytes = padded;
}
if (this.bytes.length !== 32) {
throw new Error(`invalid group secret, expected 32 bytes key, got ${this.bytes.length}`);
}
}
toBytes(): Uint8Array {
return this.bytes.slice();
}
toString(): string {
return bytesToHex(this.bytes);
}
toHash(): string {
return this.bytes[0].toString(16).padStart(2, '0')
}
decrypt(cipherText: Uint8Array, cipherMAC: Uint8Array): DecryptedGroupMessage {
const ourMAC = hmac(sha256, this.bytes, cipherText)
if (!constantTimeEqual(cipherMAC, ourMAC)) {
throw new Error('invalid MAC');
}
const block = ecb(this.bytes);
const plain = block.decrypt(cipherText);
if (plain.length < 5) {
throw new Error('invalid payload');
}
const reader = new BufferReader(plain);
const timestamp = new Date(reader.readUint32LE());
const flags = reader.readUint8();
let message = new TextDecoder('utf-8').decode(reader.readBytes());
const nullPos = message.indexOf('\0')
if (nullPos >= 0) {
message = message.substring(0, nullPos);
}
return {
timestamp,
flags,
message
}
}
static fromName(name: string): GroupSecret {
const hash = sha256.create().update(new TextEncoder().encode(name)).digest();
return new GroupSecret(hash.slice(0, 32));
}
}
export interface DecryptedGroupMessageResult {
success: boolean;
result?: DecryptedGroupMessage;
error?: string;
}
export class KeyManager {
protected groups: Map<string, Group[]> = new Map();
public addGroup(name: string, secret?: string | Uint8Array) {
let group: Group;
if (secret) {
group = {
name: name,
secret: new GroupSecret(secret)
}
} else {
group = {
name: name,
secret: GroupSecret.fromName(name)
}
}
const hash = group.secret.toHash();
this.groups.set(hash, [...this.groups.get(hash) || [], group]);
}
public decryptGroup(channelHash: NodeHash, cipherText: Uint8Array, cipherMAC: Uint8Array): DecryptedGroupMessage {
if (!this.groups.has(channelHash)) {
throw new Error(`no keys found for channel hash ${channelHash}`);
}
let error: any;
for (const group of this.groups.get(channelHash) || []) {
try {
return {
...group.secret.decrypt(cipherText, cipherMAC),
group: group.name
}
} catch (e) {
error = e;
}
}
throw error;
}
}

View File

@@ -0,0 +1,388 @@
export type NodeHash = string; // first byte of the hash
export const RouteType = {
TRANSPORT_FLOOD: 0x00,
FLOOD: 0x01,
DIRECT: 0x02,
TRANSPORT_DIRECT: 0x03,
} as const;
export type RouteType = typeof RouteType[keyof typeof RouteType] | number;
export const PayloadType = {
REQUEST: 0x00, // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
RESPONSE: 0x01, // response to REQ or ANON_REQ (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
TEXT: 0x02, // a plain text message (prefixed with dest/src hashes, MAC) (enc data: timestamp, text)
ACK: 0x03, // a simple ack
ADVERT: 0x04, // a node advertising its Identity
GROUP_TEXT: 0x05, // an (unverified) group text message (prefixed with channel hash, MAC) (enc data: timestamp, "name: msg")
GROUP_DATA: 0x06, // an (unverified) group datagram (prefixed with channel hash, MAC) (enc data: timestamp, blob)
ANON_REQ: 0x07, // generic request (prefixed with dest_hash, ephemeral pub_key, MAC) (enc data: ...)
PATH: 0x08, // returned path (prefixed with dest/src hashes, MAC) (enc data: path, extra)
TRACE: 0x09, // trace a path, collecting SNI for each hop
MULTIPART: 0x0A, // packet is one of a set of packets
CONTROL: 0x0B, // a control/discovery packet
RAW_CUSTOM: 0x0F, // custom packet as raw bytes, for applications with custom encryption, payloads, etc
} as const;
export type PayloadTypeValue = typeof PayloadType[keyof typeof PayloadType];
export type PayloadType = PayloadTypeValue | number;
export interface Packet {
version: number;
transportCodes?: Uint16Array;
routeType: RouteType;
payloadType: PayloadType;
path: Uint8Array;
payload: Uint8Array;
}
export abstract class BasePacket {
protected version: number = 1;
protected transportCodes?: Uint16Array;
protected routeType: number = 0;
protected payloadType: number = 0;
protected pathLength: number = 0;
protected path: Uint8Array = new Uint8Array();
protected payload: Uint8Array = new Uint8Array();
abstract getPathHashSize(): number;
abstract getPathHashCount(): number;
abstract hash(): Uint8Array;
abstract decode(): Payload;
}
export type Payload =
| RequestPayload
| ResponsePayload
| TextPayload
| AckPayload
| AdvertPayload
| GroupTextPayload
| GroupDataPayload
| AnonReqPayload
| PathPayload
| TracePayload
| MultipartPayload
| ControlPayload
| RawCustomPayload;
export abstract class BasePayload<T extends Payload> {
protected type: PayloadType;
protected raw: Uint8Array;
constructor(type: PayloadType, raw: Uint8Array) {
this.type = type;
this.raw = raw;
}
abstract decode(): T;
}
export interface EncryptedPayload {
cipherMAC: Uint8Array;
cipherText: Uint8Array;
}
export const RequestType = {
GET_STATS: 0x01,
KEEPALIVE: 0x02, // deprecated
GET_TELEMETRY: 0x03,
GET_MIN_MAX_AVG: 0x04, // sensor nodes
GET_ACCESS_LIST: 0x05,
GET_NEIGHBORS: 0x06,
GET_OWNER_INFO: 0x07
} as const;
export type RequestType = typeof RequestType[keyof typeof RequestType];
export interface RequestPayload extends EncryptedPayload {
readonly payloadType: typeof PayloadType.REQUEST;
dstHash: NodeHash;
srcHash: NodeHash;
}
export interface DecryptedRequest {
timestamp: Date;
requestType: RequestType;
requestData: Uint8Array;
}
export interface StatsRequest extends DecryptedRequest {
requestType: typeof RequestType.GET_STATS;
}
export interface TelemetryRequest extends DecryptedRequest {
requestType: typeof RequestType.GET_TELEMETRY;
}
export interface MinMaxAvgRequest extends DecryptedRequest {
requestType: typeof RequestType.GET_MIN_MAX_AVG;
}
export interface AccessListRequest extends DecryptedRequest {
requestType: typeof RequestType.GET_ACCESS_LIST;
}
export interface NeighborsRequest extends DecryptedRequest {
requestType: typeof RequestType.GET_NEIGHBORS;
}
export interface OwnerInfoRequest extends DecryptedRequest {
requestType: typeof RequestType.GET_OWNER_INFO;
}
export interface ResponsePayload extends EncryptedPayload {
readonly payloadType: typeof PayloadType.RESPONSE;
dstHash: NodeHash;
srcHash: NodeHash;
}
export interface DecryptedResponse {
tag: number; // 4 bytes, LE (TODO in spec)
content: Uint8Array;
}
export const TextMessageType = {
PLAIN_TEXT: 0x00,
CLI_COMMAND: 0x01,
SIGNED_PLAIN_TEXT: 0x02
} as const;
export type TextMessageType = typeof TextMessageType[keyof typeof TextMessageType];
export interface TextPayload extends EncryptedPayload {
readonly payloadType: typeof PayloadType.TEXT;
dstHash: NodeHash;
srcHash: NodeHash;
}
export interface DecryptedTextMessage {
timestamp: Date; // 4 bytes, LE
txtTypeAndAttempt: number; // Upper 6 bits: txt_type, lower 2 bits: attempt (0-3)
message: Uint8Array | string; // Message content
}
export interface SignedTextMessage extends DecryptedTextMessage {
senderPubkeyPrefix: Uint8Array; // First 4 bytes of sender pubkey (when txt_type = 0x02)
}
export interface AckPayload {
readonly payloadType: typeof PayloadType.ACK;
checksum: number; // 4 bytes, LE - CRC of message timestamp, text, and sender pubkey
}
export const AdvertisementFlags = {
IS_CHAT_NODE: 0x01,
IS_REPEATER: 0x02,
IS_ROOM_SERVER: 0x03,
IS_SENSOR: 0x04,
HAS_LOCATION: 0x10,
HAS_FEATURE_1: 0x20,
HAS_FEATURE_2: 0x40,
HAS_NAME: 0x80
} as const;
export type AdvertisementFlags = typeof AdvertisementFlags[keyof typeof AdvertisementFlags];
export interface AdvertisementAppData {
flags: number;
latitude?: number; // decimal latitude * 1000000, integer
longitude?: number; // decimal longitude * 1000000, integer
feature1?: number; // reserved
feature2?: number; // reserved
name?: string; // node name
}
export interface AdvertPayload {
readonly payloadType: typeof PayloadType.ADVERT;
publicKey: Uint8Array; // 32 bytes Ed25519 public key
timestamp: Date; // Unix timestamp (4 bytes, LE)
signature: Uint8Array; // 64 bytes Ed25519 signature
appdata: AdvertisementAppData;
}
export interface EncryptedGroupPayload extends EncryptedPayload {
channelHash: NodeHash;
}
export interface GroupTextPayload extends EncryptedGroupPayload {
readonly payloadType: typeof PayloadType.GROUP_TEXT;
}
export interface GroupDataPayload extends EncryptedGroupPayload {
readonly payloadType: typeof PayloadType.GROUP_DATA;
}
export interface DecryptedGroupMessage {
timestamp: Date; // 4 bytes, LE
flags: number; // Usually 0x00 for plain text
message: string; // Format: "<sender name>: <message body>"
group?: string;
}
export interface AnonReqPayload extends EncryptedPayload {
readonly payloadType: typeof PayloadType.ANON_REQ;
dstHash: NodeHash; // first byte of destination node public key
publicKey: Uint8Array; // 32 bytes - sender's ephemeral Ed25519 public key
}
export const AnonRequestSubType = {
ROOM_LOGIN: 0x00,
REPEATER_LOGIN: 0x01,
REGIONS_REQUEST: 0x01,
OWNER_INFO_REQUEST: 0x02,
CLOCK_STATUS_REQUEST: 0x03
} as const;
export type AnonRequestSubType = typeof AnonRequestSubType[keyof typeof AnonRequestSubType];
export interface RoomServerLogin {
timestamp: number; // 4 bytes, LE
syncTimestamp: number; // 4 bytes, LE - "sync messages SINCE x"
password: string;
}
export interface RepeaterSensorLogin {
timestamp: number; // 4 bytes, LE
password: string;
}
export interface RepeaterRegionsRequest {
timestamp: Date; // 4 bytes, LE
reqType: typeof AnonRequestSubType.REPEATER_LOGIN;
replyPathLen: number; // 1 byte
replyPath: NodeHash[]; // Variable length
}
export interface RepeaterOwnerInfoRequest {
timestamp: Date; // 4 bytes, LE
reqType: typeof AnonRequestSubType.OWNER_INFO_REQUEST;
replyPathLen: number;
replyPath: NodeHash[];
}
export interface RepeaterClockStatusRequest {
timestamp: Date; // 4 bytes, LE
reqType: typeof AnonRequestSubType.CLOCK_STATUS_REQUEST;
replyPathLen: number;
replyPath: NodeHash[];
}
export interface PathPayload extends EncryptedPayload {
readonly payloadType: typeof PayloadType.PATH;
dstHash: NodeHash;
srcHash: NodeHash;
}
export interface DecryptedPath {
pathLength: number; // 1 byte
path: NodeHash[]; // List of node hashes (1 byte each)
extraType: PayloadType; // Bundled payload type (e.g., ACK)
extra: Uint8Array; // Bundled payload content
}
export interface TracePayload {
readonly payloadType: typeof PayloadType.TRACE;
// Format not fully specified in docs - collecting SNI for each hop
data: Uint8Array;
}
export interface MultipartPayload {
readonly payloadType: typeof PayloadType.MULTIPART;
data: Uint8Array; // One part of a multi-part packet
}
export const ControlSubType = {
DISCOVER_REQ: 0x8, // Upper 4 bits of flags
DISCOVER_RESP: 0x9
} as const;
export type ControlSubType = typeof ControlSubType[keyof typeof ControlSubType];
export const NodeType = {
TYPE_UNKNOWN: 0x0,
TYPE_CHAT_NODE: 0x1,
TYPE_REPEATER: 0x2,
TYPE_ROOM_SERVER: 0x3,
TYPE_SENSOR: 0x4
} as const;
export type NodeType = typeof NodeType[keyof typeof NodeType];
export interface ControlPayload {
readonly payloadType: typeof PayloadType.CONTROL;
flags: number; // Upper 4 bits is sub_type
data: Uint8Array;
}
export interface DiscoverRequest {
subType: typeof ControlSubType.DISCOVER_REQ;
prefixOnly: boolean; // Lowest bit of flags
typeFilter: number; // Bit for each ADV_TYPE_*
tag: number; // 4 bytes, LE - randomly generated
since?: number; // 4 bytes, LE - epoch timestamp (optional)
}
export interface DiscoverResponse {
subType: typeof ControlSubType.DISCOVER_RESP;
nodeType: NodeType; // Lower 4 bits of flags
snr: number; // 1 byte, signed - SNR*4
tag: number; // 4 bytes, LE - reflected from request
pubkey: BasePublicKey; // 8 or 32 bytes - node's ID or prefix
}
export interface RawCustomPayload {
readonly payloadType: typeof PayloadType.RAW_CUSTOM;
data: Uint8Array; // Raw bytes for custom encryption/application
}
export interface Group {
name: string;
secret: BaseGroupSecret;
}
export type PublicKeyValue = string | Uint8Array
export abstract class BasePublicKey {
abstract toBytes(): Uint8Array;
abstract toString(): string;
}
export type PrivateKeyValue = string | Uint8Array
export abstract class BasePrivateKey {
abstract toBytes(): Uint8Array;
abstract toString(): string;
abstract toPublicKey(): BasePublicKey;
}
export type SharedSecretValue = string | Uint8Array
export abstract class BaseSharedSecret {
abstract decrypt(cipherText: Uint8Array, cipherMAC: Uint8Array): Uint8Array
}
export type GroupSecretValue = string | Uint8Array
export abstract class BaseGroupSecret {
abstract toBytes(): Uint8Array;
abstract toString(): string;
abstract toHash(): string;
abstract decrypt(cipherText: Uint8Array, cipherMAC: Uint8Array): DecryptedGroupMessage;
}
export interface MeshCoreGroup {
id: number;
name: string;
secret: string;
isPublic: boolean;
}
export interface MeshCoreGroupMessage {
hash: string;
timestamp: Date;
channelHash: string;
sender: string;
message: string;
}

32
ui/src/services/API.ts Normal file
View File

@@ -0,0 +1,32 @@
import axios, { type AxiosRequestConfig } from 'axios';
import type { Radio } from '../types/radio.types';
export class APIService {
private readonly client;
constructor(baseURL: string = '/api/v1') {
this.client = axios.create({
baseURL,
headers: {
Accept: 'application/json',
},
});
}
public async fetch<T>(endpoint: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.get<T>(endpoint, config);
return response.data as T;
}
public async fetchRadios(protocol?: string): Promise<Radio[]> {
const endpoint = protocol ? `/radios/${encodeURIComponent(protocol)}` : '/radios';
return this.fetch<Radio[]>(endpoint);
}
public async fetchAllRadios(): Promise<Radio[]> {
return this.fetch<Radio[]>('/radios');
}
}
const API = new APIService();
export default API;

View File

@@ -0,0 +1,40 @@
import type { APIService } from './API';
export interface FetchedAPRSPacket {
id?: number;
radio_id?: number;
radio?: {
id?: number;
name?: string;
};
src?: string;
source?: string;
dst?: string;
destination?: string;
path?: string;
comment?: string;
latitude?: number | { Float64?: number; Valid?: boolean } | null;
longitude?: number | { Float64?: number; Valid?: boolean } | null;
raw?: string;
payload?: string;
received_at?: string;
timestamp?: string;
time?: string;
created_at?: string;
}
export class APRSServiceImpl {
private api: APIService;
constructor(api: APIService) {
this.api = api;
}
public async fetchPackets(limit = 200): Promise<FetchedAPRSPacket[]> {
const endpoint = '/aprs/packets';
const params = { limit };
return this.api.fetch<FetchedAPRSPacket[]>(endpoint, { params });
}
}
export default APRSServiceImpl;

View File

@@ -0,0 +1,117 @@
import { BaseStream } from './Stream';
export interface APRSMessage {
topic: string;
receivedAt: Date;
raw: string;
radioName?: string;
}
interface APRSJsonEnvelope {
raw?: string;
payload?: string;
payloadBase64?: string;
rawBase64?: string;
Raw?: string;
Payload?: string;
Time?: string;
time?: string;
timestamp?: string;
}
const fromBase64 = (value: string): string => {
return atob(value);
};
const pickString = (obj: Record<string, unknown>, keys: string[]): string | undefined => {
for (const key of keys) {
const value = obj[key];
if (typeof value === 'string' && value.trim().length > 0) {
return value;
}
}
return undefined;
};
export class APRSStream extends BaseStream {
constructor(autoConnect = false) {
super({}, autoConnect);
}
protected decodeMessage(topic: string, payload: Uint8Array): APRSMessage {
const text = new TextDecoder().decode(payload).trim();
const radioName = this.extractRadioNameFromTopic(topic);
if (!text.startsWith('{')) {
return {
topic,
receivedAt: new Date(),
raw: text,
radioName,
};
}
const envelope = JSON.parse(text) as APRSJsonEnvelope;
const record = envelope as unknown as Record<string, unknown>;
const raw = this.extractRawFrame(record);
const receivedAt = this.extractReceivedAt(record);
return {
topic,
receivedAt,
raw,
radioName,
};
}
private extractRawFrame(envelope: Record<string, unknown>): string {
const rawFrame = pickString(envelope, ['raw', 'payload']);
if (rawFrame && rawFrame.includes('>')) {
return rawFrame;
}
const maybeBase64 = pickString(envelope, ['Raw', 'payloadBase64', 'rawBase64', 'Payload']);
if (maybeBase64) {
const decoded = fromBase64(maybeBase64);
if (decoded.includes('>')) {
return decoded;
}
}
if (rawFrame) {
return rawFrame;
}
throw new Error('APRSStream: invalid payload envelope, no APRS frame found');
}
private extractReceivedAt(envelope: Record<string, unknown>): Date {
const timeValue = pickString(envelope, ['time', 'Time', 'timestamp']);
if (!timeValue) {
return new Date();
}
const parsed = new Date(timeValue);
if (Number.isNaN(parsed.getTime())) {
return new Date();
}
return parsed;
}
private extractRadioNameFromTopic(topic: string): string | undefined {
const parts = topic.split('/');
if (parts.length >= 3 && parts[0] === 'aprs' && parts[1] === 'packet') {
try {
return atob(parts[2]);
} catch (error) {
console.warn('Failed to decode radio name from topic:', topic, error);
}
}
return undefined;
}
}
export default APRSStream;

View File

@@ -0,0 +1,72 @@
import type { APIService } from './API';
import type { Group } from '../protocols/meshcore.types';
import { PayloadType } from '../protocols/meshcore.types';
import { GroupSecret } from '../protocols/meshcore';
interface FetchedMeshCoreGroup {
id: number;
name: string;
secret: string;
isPublic: boolean;
}
export type MeshCoreGroupRecord = Group & {
id: number;
isPublic: boolean;
};
export interface FetchedGroupPacket {
id: number;
radio_id: number;
snr: number;
rssi: number;
version: number;
route_type: number;
payload_type: number;
hash: string;
path: string;
payload: string;
raw: string;
channel_hash: string;
received_at: string;
}
export class MeshCoreServiceImpl {
private api: APIService;
constructor(api: APIService) {
this.api = api;
}
/**
* Fetch all available MeshCore groups
* @returns Array of Group objects with metadata
*/
public async fetchGroups(): Promise<MeshCoreGroupRecord[]> {
const groups = await this.api.fetch<FetchedMeshCoreGroup[]>('/meshcore/groups');
return groups.map((group) => ({
id: group.id,
name: group.name,
secret: group.secret && group.secret.trim().length > 0
? new GroupSecret(group.secret)
: GroupSecret.fromName(group.name),
isPublic: group.isPublic,
}));
}
/**
* Fetch GROUP_TEXT packets for a specific channel hash
* @param channelHash The channel hash to fetch packets for
* @returns Array of raw packet data
*/
public async fetchGroupPackets(channelHash: string): Promise<FetchedGroupPacket[]> {
const endpoint = '/meshcore/packets';
const params = {
type: PayloadType.GROUP_TEXT,
channel_hash: channelHash,
};
return this.api.fetch<FetchedGroupPacket[]>(endpoint, { params });
}
}
export default MeshCoreServiceImpl;

View File

@@ -0,0 +1,95 @@
import { bytesToHex } from '@noble/hashes/utils.js';
import { Packet } from '../protocols/meshcore';
import type { Payload } from '../protocols/meshcore.types';
import { BaseStream } from './Stream';
export interface MeshCoreMessage {
topic: string;
receivedAt: Date;
raw: Uint8Array;
hash: string;
decodedPayload?: Payload;
radioName?: string;
}
interface MeshCoreJsonEnvelope {
payloadBase64?: string;
payloadHex?: string;
}
const hexToBytes = (hex: string): Uint8Array => {
const normalized = hex.trim().toLowerCase();
if (normalized.length % 2 !== 0) {
throw new Error('MeshCoreStream: invalid hex payload length');
}
const bytes = new Uint8Array(normalized.length / 2);
for (let i = 0; i < normalized.length; i += 2) {
bytes[i / 2] = Number.parseInt(normalized.slice(i, i + 2), 16);
}
return bytes;
};
export class MeshCoreStream extends BaseStream {
constructor(autoConnect = false) {
super({}, autoConnect);
}
protected decodeMessage(topic: string, payload: Uint8Array): MeshCoreMessage {
const packetBytes = this.extractPacketBytes(payload);
const parsed = new Packet();
parsed.parse(packetBytes);
let decodedPayload: Payload | undefined;
try {
decodedPayload = parsed.decode();
} catch {
decodedPayload = undefined;
}
// Extract radio name from topic: meshcore/packet/<base64-encoded-radio-name>
const radioName = this.extractRadioNameFromTopic(topic);
return {
topic,
receivedAt: new Date(),
raw: packetBytes,
hash: bytesToHex(parsed.hash()),
decodedPayload,
radioName
};
}
private extractRadioNameFromTopic(topic: string): string | undefined {
// Topic format: meshcore/packet/<base64-encoded-radio-name>
const parts = topic.split('/');
if (parts.length >= 3 && parts[0] === 'meshcore' && parts[1] === 'packet') {
try {
const base64Name = parts[2];
return atob(base64Name);
} catch (error) {
console.warn('Failed to decode radio name from topic:', topic, error);
return undefined;
}
}
return undefined;
}
private extractPacketBytes(payload: Uint8Array): Uint8Array {
const text = new TextDecoder().decode(payload).trim();
if (!text.startsWith('{')) {
return payload;
}
const envelope = JSON.parse(text) as MeshCoreJsonEnvelope;
if (envelope.payloadBase64) {
return Uint8Array.from(atob(envelope.payloadBase64), (c) => c.charCodeAt(0));
}
if (envelope.payloadHex) {
return hexToBytes(envelope.payloadHex);
}
return payload;
}
}

315
ui/src/services/Stream.ts Normal file
View File

@@ -0,0 +1,315 @@
import mqtt from "mqtt";
import type { StreamConnectionOptions, StreamState, TopicSubscription } from "../types/stream.types";
const defaultConnectionOptions: StreamConnectionOptions = {
url: import.meta.env.DEV
? 'ws://10.42.23.73:8083'
: ((window.location.protocol === 'http:') ? 'ws:' : 'wss:') + '//' + window.location.host + '/broker'
}
export abstract class BaseStream {
protected client: mqtt.MqttClient | null = null;
protected connectionOptions: StreamConnectionOptions;
protected subscribers: Map<string, Set<(data: any, topic: string) => void>> = new Map();
protected stateSubscribers: Set<(state: StreamState) => void> = new Set();
protected reconnectTimer: NodeJS.Timeout | null = null;
protected autoConnect: boolean;
protected state: StreamState = {
isConnected: false,
isConnecting: false,
error: null,
subscriptions: new Map(),
lastMessages: new Map(),
};
constructor(connectionOptions: Partial<StreamConnectionOptions>, autoConnect: boolean = true) {
this.connectionOptions = {
...defaultConnectionOptions,
...connectionOptions,
}
this.autoConnect = autoConnect;
if (autoConnect) {
this.connect();
}
}
protected abstract decodeMessage(topic: string, payload: Uint8Array): any;
protected validateMessage?(topic: string, data: any): boolean;
public connect(): void {
if (this.client?.connected || this.state.isConnecting) {
return;
}
this.updateState({ isConnecting: true, error: null });
try {
const randomId = Math.random().toString(16).slice(2, 10);
const prefix = import.meta.env.DEV ? 'dev_' : '';
const defaultClientId = `${prefix}hamview_${randomId}`;
this.client = mqtt.connect(this.connectionOptions.url, {
...this.connectionOptions.options,
clientId: this.connectionOptions.options?.clientId || defaultClientId,
});
this.setupEventHandlers();
} catch (error) {
this.handleError(error);
}
}
public disconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.client) {
this.client.end(true, () => {
this.updateState({
isConnected: false,
isConnecting: false,
subscriptions: new Map(),
lastMessages: new Map(),
});
});
this.client = null;
}
}
public subscribe<T = any>(
topic: string,
callback: (data: T, topic: string) => void,
qos: 0 | 1 | 2 = 0
): () => void {
// Add to subscribers map
if (!this.subscribers.has(topic)) {
this.subscribers.set(topic, new Set());
}
const topicSubscribers = this.subscribers.get(topic)!;
topicSubscribers.add(callback);
// Update state subscriptions
const subscription: TopicSubscription<T> = { topic, qos, dataType: undefined as any };
this.state.subscriptions.set(topic, subscription);
// If connected, subscribe to the topic on the broker
if (this.client?.connected) {
this.client.subscribe(topic, { qos }, (error) => {
if (error) {
console.error(`Failed to subscribe to ${topic}:`, error);
}
});
}
// Return unsubscribe function
return () => {
const topicSubscribers = this.subscribers.get(topic);
if (topicSubscribers) {
topicSubscribers.delete(callback);
// If no more subscribers for this topic, unsubscribe from broker
if (topicSubscribers.size === 0) {
this.subscribers.delete(topic);
this.state.subscriptions.delete(topic);
this.state.lastMessages.delete(topic);
if (this.client?.connected) {
this.client.unsubscribe(topic);
}
}
}
};
}
public subscribeMany(
subscriptions: Array<{ topic: string; qos?: 0 | 1 | 2 }>,
callback: (data: any, topic: string) => void
): () => void {
const unsubscribers = subscriptions.map(({ topic, qos = 0 }) =>
this.subscribe(topic, callback, qos)
);
return () => {
unsubscribers.forEach(unsub => unsub());
};
}
public subscribeToState(callback: (state: StreamState) => void): () => void {
this.stateSubscribers.add(callback);
callback(this.state); // Immediately send current state
return () => {
this.stateSubscribers.delete(callback);
};
}
public getLastMessage<T = any>(topic: string): T | undefined {
return this.state.lastMessages.get(topic) as T | undefined;
}
public getSubscriptions(): Map<string, TopicSubscription> {
return new Map(this.state.subscriptions);
}
public getState(): StreamState {
return {
...this.state,
subscriptions: new Map(this.state.subscriptions),
lastMessages: new Map(this.state.lastMessages),
};
}
public isSubscribed(topic: string): boolean {
return this.state.subscriptions.has(topic);
}
public destroy(): void {
this.disconnect();
this.subscribers.clear();
this.stateSubscribers.clear();
}
private setupEventHandlers(): void {
if (!this.client) return;
this.client.on('connect', () => {
this.updateState({ isConnected: true, isConnecting: false, error: null });
// Resubscribe to all topics after reconnection
const subscriptions = Array.from(this.state.subscriptions.entries());
if (subscriptions.length > 0 && this.client) {
const subscribePromises = subscriptions.map(([topic, sub]) => {
return new Promise<void>((resolve, reject) => {
this.client?.subscribe(topic, { qos: sub.qos || 0 }, (error) => {
if (error) reject(error);
else resolve();
});
});
});
Promise.all(subscribePromises).catch(error => {
console.error('Failed to resubscribe to topics:', error);
});
}
});
this.client.on('message', (topic, payload) => {
try {
const uint8Array = new Uint8Array(payload);
const data = this.decodeMessage(topic, uint8Array);
// Validate if validator is provided
if (this.validateMessage && !this.validateMessage(topic, data)) {
console.warn('Invalid message received on topic:', topic, data);
return;
}
// Update last message for this topic
this.state.lastMessages.set(topic, data);
this.updateState({ lastMessages: new Map(this.state.lastMessages) });
// Notify subscribers for this topic
this.notifySubscribers(topic, data);
} catch (error) {
console.error(`Error processing message on topic ${topic}:`, error);
}
});
this.client.on('error', (error) => {
this.handleError(error);
});
this.client.on('close', () => {
this.updateState({ isConnected: false });
this.attemptReconnect();
});
}
private notifySubscribers(topic: string, data: any): void {
const topicSubscribers = this.subscribers.get(topic);
if (topicSubscribers) {
topicSubscribers.forEach(callback => {
try {
callback(data, topic);
} catch (error) {
console.error('Error in subscriber callback:', error);
}
});
}
// Also notify wildcard subscribers (those subscribed to patterns like 'sensors/+')
this.subscribers.forEach((subscribers, subscribedTopic) => {
if (subscribedTopic !== topic && this.topicMatches(subscribedTopic, topic)) {
subscribers.forEach(callback => {
try {
callback(data, topic);
} catch (error) {
console.error('Error in wildcard subscriber callback:', error);
}
});
}
});
}
private topicMatches(subscription: string, topic: string): boolean {
const subParts = subscription.split('/');
const topicParts = topic.split('/');
// Handle multi-level wildcard
if (subscription.includes('#')) {
const wildcardIndex = subParts.indexOf('#');
// Check if all parts before the wildcard match
for (let i = 0; i < wildcardIndex; i++) {
if (subParts[i] !== '+' && subParts[i] !== topicParts[i]) {
return false;
}
}
return true;
}
// Handle single-level wildcards and exact matches
if (subParts.length !== topicParts.length) return false;
for (let i = 0; i < subParts.length; i++) {
if (subParts[i] !== '+' && subParts[i] !== topicParts[i]) {
return false;
}
}
return true;
}
private updateState(updates: Partial<StreamState>) {
this.state = { ...this.state, ...updates };
this.stateSubscribers.forEach(callback => {
try {
callback(this.state);
} catch (error) {
console.error('Error in state subscriber callback:', error);
}
})
}
private handleError(error: any): void {
const errorObj = error instanceof Error ? error : new Error(String(error));
this.updateState({ error: errorObj, isConnecting: false });
console.error('Stream error:', errorObj);
}
private attemptReconnect(): void {
if (!this.autoConnect || this.reconnectTimer) return;
const reconnectPeriod = this.connectionOptions.options?.reconnectPeriod || 5000;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.state.isConnected && !this.state.isConnecting) {
this.connect();
}
}, reconnectPeriod);
}
}

11
ui/src/styles/_theme.scss Normal file
View File

@@ -0,0 +1,11 @@
/* ============================================
THEME STYLES - Reusable Typography & Colors
============================================ */
@import './theme/typography';
@import './theme/buttons';
@import './theme/badges';
@import './theme/tags';
@import './theme/forms';
@import './theme/code';
@import './theme/utilities';

View File

@@ -0,0 +1,41 @@
/* ============================================
THEME VARIABLES - Color & Design Tokens
============================================ */
:root {
/* Base Colors */
--app-bg: #071b3a;
--app-bg-elevated: #0b2752;
--app-text: #e8efff;
--app-text-muted: #afc1e6;
--app-text-subtle: #8a99b8;
--app-text-code: #ffd700;
/* Primary Accents */
--app-accent-primary: #8eb4ff;
--app-accent-blue: #5a9eff;
--app-accent-yellow: #ffd700;
--app-accent-yellow-muted: #f0c800;
/* Secondary Blue Tones */
--app-blue-light: #a8c5ff;
--app-blue-dark: #4a7bd4;
/* Status Colors */
--app-status-success: #4ade80;
--app-status-warning: #facc15;
--app-status-error: #ef4444;
--app-status-info: #60a5fa;
/* Interactive Elements */
--app-button-hover: rgba(142, 180, 255, 0.15);
--app-border-color: rgba(142, 180, 255, 0.25);
--app-border-color-active: rgba(142, 180, 255, 0.6);
/* Semantic */
--app-code-bg: rgba(2, 10, 26, 0.6);
--app-divider: rgba(142, 180, 255, 0.1);
/* Legacy (keeping for compatibility) */
--app-accent: #8eb4ff;
}

View File

@@ -0,0 +1,30 @@
/* Badge Styles */
.badge {
display: inline-block;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-success {
background: rgba(74, 222, 128, 0.15);
color: var(--app-status-success);
}
.badge-warning {
background: rgba(250, 204, 21, 0.15);
color: var(--app-status-warning);
}
.badge-error {
background: rgba(239, 68, 68, 0.15);
color: var(--app-status-error);
}
.badge-info {
background: rgba(96, 165, 250, 0.15);
color: var(--app-status-info);
}

View File

@@ -0,0 +1,80 @@
/* Button Styles */
.btn {
padding: 10px 20px;
border-radius: 6px;
border: 1px solid transparent;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
&:active {
transform: scale(0.98);
}
&:disabled {
cursor: not-allowed;
}
}
.btn-primary {
background: var(--app-accent-primary);
color: var(--app-bg);
border-color: var(--app-accent-primary);
&:hover:not(:disabled) {
background: var(--app-blue-light);
box-shadow: 0 4px 12px rgba(142, 180, 255, 0.3);
}
&:disabled {
opacity: 0.5;
}
}
.btn-secondary {
background: transparent;
color: var(--app-accent-primary);
border-color: var(--app-accent-primary);
&:hover:not(:disabled) {
background: var(--app-button-hover);
border-color: var(--app-blue-light);
color: var(--app-blue-light);
}
}
.btn-tertiary {
background: transparent;
color: var(--app-text-muted);
border-color: var(--app-border-color);
&:hover:not(:disabled) {
color: var(--app-text);
border-color: var(--app-accent-primary);
background: var(--app-button-hover);
}
}
.btn-danger {
background: rgba(239, 68, 68, 0.12);
color: var(--app-status-error);
border-color: rgba(239, 68, 68, 0.3);
&:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.2);
border-color: var(--app-status-error);
}
}
.btn-success {
background: rgba(74, 222, 128, 0.12);
color: var(--app-status-success);
border-color: rgba(74, 222, 128, 0.3);
&:hover:not(:disabled) {
background: rgba(74, 222, 128, 0.2);
border-color: var(--app-status-success);
}
}

View File

@@ -0,0 +1,37 @@
/* Code Styles */
.code-block {
background: var(--app-code-bg);
border: 1px solid var(--app-border-color);
border-radius: 8px;
overflow: auto;
margin-bottom: 20px;
pre {
margin: 0;
padding: 20px;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
color: var(--app-text-code);
overflow-x: auto;
code {
color: inherit;
}
}
}
.inline-code {
font-family: 'Courier New', monospace;
font-size: 13px;
background: rgba(255, 215, 0, 0.1);
color: var(--app-text-code);
padding: 2px 6px;
border-radius: 3px;
white-space: nowrap;
}
.highlight-tech {
color: var(--app-accent-yellow);
font-weight: 600;
}

View File

@@ -0,0 +1,42 @@
/* Form Elements */
.form-label {
font-size: 13px;
font-weight: 600;
color: var(--app-text);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-input,
.form-select {
padding: 10px 12px;
background: var(--app-bg);
border: 1px solid var(--app-border-color);
border-radius: 6px;
color: var(--app-text);
font-size: 14px;
font-family: inherit;
transition: all 0.2s ease;
&:focus {
outline: none;
border-color: var(--app-accent-primary);
box-shadow: 0 0 0 3px rgba(142, 180, 255, 0.1);
}
&::placeholder {
color: var(--app-text-subtle);
}
}
.form-input-search {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%23afc1e6' stroke-width='2'%3E%3Ccircle cx='6.5' cy='6.5' r='5'%3E%3C/circle%3E%3Cpath d='M11 11l4 4'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 36px;
}
.form-input-code {
font-family: 'Courier New', monospace;
color: var(--app-text-code);
}

View File

@@ -0,0 +1,19 @@
/* Tag Styles */
.tag {
display: inline-block;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
font-family: 'Courier New', monospace;
}
.tag-blue {
background: rgba(142, 180, 255, 0.15);
color: var(--app-accent-primary);
}
.tag-yellow {
background: rgba(255, 215, 0, 0.15);
color: var(--app-accent-yellow);
}

View File

@@ -0,0 +1,58 @@
/* Typography Styles */
.type-h1 {
font-size: 32px;
font-weight: 700;
line-height: 1.2;
margin: 0 0 12px 0;
color: var(--app-text);
}
.type-h2 {
font-size: 24px;
font-weight: 700;
line-height: 1.3;
margin: 0 0 12px 0;
color: var(--app-text);
}
.type-h3 {
font-size: 18px;
font-weight: 600;
line-height: 1.4;
margin: 0 0 12px 0;
color: var(--app-text);
}
.type-body {
font-size: 14px;
font-weight: 400;
line-height: 1.6;
margin: 0 0 12px 0;
color: var(--app-text);
}
.type-small {
font-size: 12px;
font-weight: 400;
line-height: 1.5;
margin: 0 0 12px 0;
color: var(--app-text-muted);
}
.type-code {
font-family: 'Courier New', monospace;
font-size: 13px;
color: var(--app-text-code);
background: var(--app-code-bg);
padding: 8px 12px;
border-radius: 4px;
display: inline-block;
}
.type-meta {
font-size: 12px;
color: var(--app-text-muted);
display: block;
margin-top: 8px;
font-family: 'Courier New', monospace;
}

View File

@@ -0,0 +1,56 @@
/* Color Utilities */
.text-primary {
color: var(--app-text);
}
.text-muted {
color: var(--app-text-muted);
}
.text-subtle {
color: var(--app-text-subtle);
}
.text-code {
color: var(--app-text-code);
}
.text-accent-primary {
color: var(--app-accent-primary);
}
.text-accent-yellow {
color: var(--app-accent-yellow);
}
.text-success {
color: var(--app-status-success);
}
.text-warning {
color: var(--app-status-warning);
}
.text-error {
color: var(--app-status-error);
}
.text-info {
color: var(--app-status-info);
}
.bg-primary {
background-color: var(--app-bg);
}
.bg-elevated {
background-color: var(--app-bg-elevated);
}
.border-primary {
border-color: var(--app-border-color);
}
.border-active {
border-color: var(--app-border-color-active);
}

View File

@@ -0,0 +1,36 @@
import React from "react";
export interface NavLinkItem {
label: string;
to: string;
end?: boolean;
}
export interface LayoutProps {
brandText?: string;
brandTo?: string;
buttonGroup?: React.ReactNode;
navLinks?: NavLinkItem[];
children?: React.ReactNode;
gutterSize?: number | string;
}
export interface FullProps {
children?: React.ReactNode;
className?: string;
}
export type VerticalSplitRatio = '50/50' | '75/25' | '25/70';
export interface VerticalSplitProps {
left?: React.ReactNode;
right?: React.ReactNode;
ratio?: VerticalSplitRatio;
className?: string;
}
export interface HorizontalSplitProps {
top?: React.ReactNode;
bottom?: React.ReactNode;
className?: string;
}

Some files were not shown because too many files have changed in this diff Show More