Checkpoint
58
.air.toml
Normal 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
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
# Test binary, built with `go test -c`
|
# Test binary, built with `go test -c`
|
||||||
*.test
|
*.test
|
||||||
|
test.db
|
||||||
|
|
||||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
*.out
|
*.out
|
||||||
@@ -23,6 +24,7 @@ go.work.sum
|
|||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
build/
|
build/
|
||||||
|
tmp/
|
||||||
|
|
||||||
# Local configuration files
|
# Local configuration files
|
||||||
etc/*.key
|
etc/*.key
|
||||||
|
|||||||
0
.gitmodules
vendored
Normal file
46
.vscode/settings.json
vendored
@@ -1,5 +1,45 @@
|
|||||||
{
|
{
|
||||||
"gopls": {
|
"gopls": {
|
||||||
"formatting.local": "git.maze.io"
|
"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
@@ -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.
|
||||||
BIN
asset/image/device/unknown.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
asset/image/protocol/aprs.org.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
asset/image/protocol/aprs.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
asset/image/protocol/unknown.org.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
asset/image/protocol/unknown.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
24
broker.go
@@ -33,13 +33,18 @@ type Broker interface {
|
|||||||
SubscribeRadios() (<-chan *Radio, error)
|
SubscribeRadios() (<-chan *Radio, error)
|
||||||
|
|
||||||
PublishPacket(topic string, packet *protocol.Packet) error
|
PublishPacket(topic string, packet *protocol.Packet) error
|
||||||
SubscribePackets(topic string) (<-chan *protocol.Packet, error)
|
SubscribePackets(topic string) (<-chan *Packet, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Receiver interface {
|
type Receiver interface {
|
||||||
Disconnected()
|
Disconnected()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Packet struct {
|
||||||
|
RadioID string
|
||||||
|
*protocol.Packet
|
||||||
|
}
|
||||||
|
|
||||||
type BrokerConfig struct {
|
type BrokerConfig struct {
|
||||||
Type string `yaml:"type"`
|
Type string `yaml:"type"`
|
||||||
Config yaml.Node `yaml:"conf"`
|
Config yaml.Node `yaml:"conf"`
|
||||||
@@ -197,7 +202,7 @@ func (broker *mqttBroker) SubscribeRadios() (<-chan *Radio, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
radios := make(chan *Radio, 8)
|
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
|
var radio Radio
|
||||||
if err := json.Unmarshal(message.Payload(), &radio); err == nil {
|
if err := json.Unmarshal(message.Payload(), &radio); err == nil {
|
||||||
select {
|
select {
|
||||||
@@ -232,17 +237,24 @@ func (broker *mqttBroker) PublishPacket(topic string, packet *protocol.Packet) e
|
|||||||
return nil
|
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 {
|
if broker.client == nil {
|
||||||
return nil, ErrBrokerNotStarted
|
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) {
|
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 {
|
if err := json.Unmarshal(message.Payload(), &packet); err == nil {
|
||||||
select {
|
select {
|
||||||
case packets <- &packet:
|
case packets <- &Packet{
|
||||||
|
RadioID: id,
|
||||||
|
Packet: &packet,
|
||||||
|
}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ func run(ctx context.Context, command *cli.Command) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer collector.Close()
|
|
||||||
|
|
||||||
broker, err := hamview.NewBroker(&config.Broker)
|
broker, err := hamview.NewBroker(&config.Broker)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,7 +78,11 @@ func run(ctx context.Context, command *cli.Command) error {
|
|||||||
protocol.APRS,
|
protocol.APRS,
|
||||||
protocol.MeshCore,
|
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")
|
return cmd.WaitForInterrupt(logger, "collector")
|
||||||
|
|||||||
@@ -2,19 +2,22 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
|
|
||||||
"git.maze.io/go/ham/protocol/aprs/aprsis"
|
"git.maze.io/go/ham/protocol/aprs/aprsis"
|
||||||
|
"git.maze.io/go/ham/radio"
|
||||||
"git.maze.io/ham/hamview"
|
"git.maze.io/ham/hamview"
|
||||||
"git.maze.io/ham/hamview/cmd"
|
"git.maze.io/ham/hamview/cmd"
|
||||||
)
|
)
|
||||||
|
|
||||||
type aprsisConfig struct {
|
type aprsisConfig struct {
|
||||||
Broker hamview.BrokerConfig `yaml:"broker"`
|
Broker hamview.BrokerConfig `yaml:"broker"`
|
||||||
Receiver hamview.APRSISConfig `yaml:"receiver"`
|
Receiver hamview.APRSISConfig `yaml:"receiver"`
|
||||||
Include []string `yaml:"include"`
|
Radio map[string]*radio.Info `yaml:"radio"`
|
||||||
|
Include []string `yaml:"include"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (config *aprsisConfig) Includes() []string {
|
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) {
|
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()
|
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() }()
|
defer func() { _ = client.Close() }()
|
||||||
|
|
||||||
|
if extra == nil {
|
||||||
|
logger.Warnf("receiver: no radio info configured for %s!", callsign)
|
||||||
|
}
|
||||||
|
|
||||||
broker, err := hamview.NewBroker(config)
|
broker, err := hamview.NewBroker(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("receiver: can't setup to broker: %v", err)
|
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() }()
|
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 {
|
if err = broker.StartRadio("aprs", info); err != nil {
|
||||||
logger.Fatalf("receiver: can't start broker: %v", err)
|
logger.Fatalf("receiver: can't start broker: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
id := base64.RawURLEncoding.EncodeToString([]byte(callsign))
|
||||||
|
|
||||||
logger.Infof("receiver: start receiving packets from station: %s", callsign)
|
logger.Infof("receiver: start receiving packets from station: %s", callsign)
|
||||||
for packet := range client.RawPackets() {
|
for packet := range client.RawPackets() {
|
||||||
logger.Debugf("aprs packet: %#+v", packet)
|
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.Error(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.Infof("receiver: stopped receiving packets from station: %s", callsign)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
|
|
||||||
@@ -48,6 +49,9 @@ func runMeshCore(ctx context.Context, command *cli.Command) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Node id
|
||||||
|
id := base64.RawURLEncoding.EncodeToString([]byte(info.Name))
|
||||||
|
|
||||||
// Trace scheduler
|
// Trace scheduler
|
||||||
//go receiver.RunTraces()
|
//go receiver.RunTraces()
|
||||||
|
|
||||||
@@ -68,7 +72,7 @@ func runMeshCore(ctx context.Context, command *cli.Command) error {
|
|||||||
payloadType,
|
payloadType,
|
||||||
len(packet.Raw))
|
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)
|
logger.Errorf("receiver: failed to publish packet: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
591
collector.go
@@ -1,15 +1,22 @@
|
|||||||
package hamview
|
package hamview
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
_ "github.com/cridenour/go-postgis" // PostGIS support
|
_ "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"
|
||||||
"git.maze.io/go/ham/protocol/aprs"
|
"git.maze.io/go/ham/protocol/aprs"
|
||||||
"git.maze.io/go/ham/protocol/meshcore"
|
"git.maze.io/go/ham/protocol/meshcore"
|
||||||
|
"git.maze.io/ham/hamview/schema"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CollectorConfig struct {
|
type CollectorConfig struct {
|
||||||
@@ -22,58 +29,20 @@ type DatabaseConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Collector struct {
|
type Collector struct {
|
||||||
*sql.DB
|
radioByID map[string]*schema.Radio
|
||||||
|
|
||||||
meshCoreGroup map[byte][]*meshcore.Group
|
meshCoreGroup map[byte][]*meshcore.Group
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCollector(config *CollectorConfig) (*Collector, error) {
|
func NewCollector(config *CollectorConfig) (*Collector, error) {
|
||||||
d, err := sql.Open(config.Database.Type, config.Database.Conf)
|
Logger.Debugf("collector: opening %q database", config.Database.Type)
|
||||||
if err != nil {
|
schema.Logger = Logger
|
||||||
|
if err := schema.Open(config.Database.Type, config.Database.Conf); err != nil {
|
||||||
return nil, err
|
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{
|
return &Collector{
|
||||||
DB: d,
|
|
||||||
meshCoreGroup: make(map[byte][]*meshcore.Group),
|
meshCoreGroup: make(map[byte][]*meshcore.Group),
|
||||||
|
radioByID: make(map[string]*schema.Radio),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +61,8 @@ func (c *Collector) Collect(broker Broker, topic string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
loop:
|
loop:
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -99,7 +70,7 @@ loop:
|
|||||||
if radio == nil {
|
if radio == nil {
|
||||||
break loop
|
break loop
|
||||||
}
|
}
|
||||||
c.processRadio(radio)
|
c.processRadio(ctx, radio)
|
||||||
|
|
||||||
case packet := <-packets:
|
case packet := <-packets:
|
||||||
if packet == nil {
|
if packet == nil {
|
||||||
@@ -107,9 +78,9 @@ loop:
|
|||||||
}
|
}
|
||||||
switch packet.Protocol {
|
switch packet.Protocol {
|
||||||
case protocol.APRS:
|
case protocol.APRS:
|
||||||
c.processAPRSPacket(packet)
|
c.processAPRSPacket(ctx, packet)
|
||||||
case protocol.MeshCore:
|
case protocol.MeshCore:
|
||||||
c.processMeshCorePacket(packet)
|
c.processMeshCorePacket(ctx, packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,150 +90,182 @@ loop:
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Collector) processRadio(radio *Radio) {
|
func (c *Collector) getRadioByID(ctx context.Context, id string) (*schema.Radio, error) {
|
||||||
Logger.Tracef("collector: process %s radio %q online %t",
|
id = strings.TrimRight(id, "=")
|
||||||
radio.Protocol,
|
if radio, ok := c.radioByID[id]; ok {
|
||||||
radio.Name,
|
return radio, nil
|
||||||
radio.IsOnline)
|
|
||||||
|
|
||||||
var latitude, longitude, altitude *float64
|
|
||||||
if radio.Position != nil {
|
|
||||||
latitude = &radio.Position.Latitude
|
|
||||||
longitude = &radio.Position.Longitude
|
|
||||||
altitude = &radio.Position.Altitude
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var id int64
|
radio, err := schema.GetRadioByEncodedID(ctx, id)
|
||||||
if err := c.QueryRow(`
|
if err == nil {
|
||||||
INSERT INTO radio (
|
c.radioByID[id] = radio
|
||||||
name,
|
}
|
||||||
is_online,
|
return radio, err
|
||||||
device,
|
}
|
||||||
manufacturer,
|
|
||||||
firmware_date,
|
func (c *Collector) processRadio(ctx context.Context, received *Radio) {
|
||||||
firmware_version,
|
Logger.Tracef("collector: process %s radio %q online %t",
|
||||||
antenna,
|
received.Protocol,
|
||||||
modulation,
|
received.Name,
|
||||||
protocol,
|
received.IsOnline)
|
||||||
latitude,
|
|
||||||
longitude,
|
var (
|
||||||
altitude,
|
now = time.Now()
|
||||||
frequency,
|
engine = schema.Query(ctx).(*xorm.Session)
|
||||||
rx_frequency,
|
)
|
||||||
tx_frequency,
|
if err := engine.Begin(); err != nil {
|
||||||
bandwidth,
|
Logger.Warnf("collector: can't start session: %v", err)
|
||||||
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)
|
|
||||||
return
|
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) {
|
func (c *Collector) processAPRSPacket(ctx context.Context, received *Packet) {
|
||||||
decoded, err := aprs.ParsePacket(string(packet.Raw))
|
radio, err := c.getRadioByID(ctx, received.RadioID)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.Tracef("collector: process %s packet (%d bytes)",
|
Logger.Tracef("collector: process %s packet (%d bytes)",
|
||||||
packet.Protocol,
|
received.Protocol,
|
||||||
len(packet.Raw))
|
len(received.Raw))
|
||||||
|
|
||||||
var id int64
|
engine := schema.Query(ctx)
|
||||||
if err := c.QueryRow(`
|
station := new(schema.APRSStation)
|
||||||
INSERT INTO aprs_packet (
|
has, err := engine.Where(builder.Eq{"call": strings.ToUpper(decoded.Source.String())}).Get(station)
|
||||||
src_address,
|
if err != nil {
|
||||||
dst_address,
|
Logger.Warnf("collector: can't query APRS station: %v", err)
|
||||||
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)
|
|
||||||
return
|
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
|
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)
|
Logger.Warnf("collector: invalid %s packet: %v", packet.Protocol, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -276,125 +279,137 @@ func (c *Collector) processMeshCorePacket(packet *protocol.Packet) {
|
|||||||
parsed.Path = nil // store NULL
|
parsed.Path = nil // store NULL
|
||||||
}
|
}
|
||||||
|
|
||||||
var id int64
|
var channelHash string
|
||||||
if err := c.QueryRow(`
|
switch parsed.PayloadType {
|
||||||
INSERT INTO meshcore_packet (
|
case meshcore.TypeGroupText, meshcore.TypeGroupData:
|
||||||
snr,
|
if len(parsed.Payload) > 0 {
|
||||||
rssi,
|
channelHash = fmt.Sprintf("%02x", parsed.Payload[0])
|
||||||
hash,
|
}
|
||||||
route_type,
|
}
|
||||||
payload_type,
|
|
||||||
path,
|
var (
|
||||||
payload,
|
save = &schema.MeshCorePacket{
|
||||||
raw,
|
RadioID: radio.ID,
|
||||||
received_at
|
SNR: packet.SNR,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
RSSI: packet.RSSI,
|
||||||
RETURNING id;`,
|
RouteType: uint8(parsed.RouteType),
|
||||||
packet.SNR,
|
PayloadType: uint8(parsed.PayloadType),
|
||||||
packet.RSSI,
|
Version: parsed.Version,
|
||||||
parsed.Hash(),
|
Hash: hex.EncodeToString(parsed.Hash()),
|
||||||
parsed.RouteType,
|
Path: parsed.Path,
|
||||||
parsed.PayloadType,
|
Payload: parsed.Payload,
|
||||||
parsed.Path,
|
ChannelHash: channelHash,
|
||||||
parsed.Payload,
|
Raw: packet.Raw,
|
||||||
packet.Raw,
|
ReceivedAt: packet.Time,
|
||||||
packet.Time,
|
}
|
||||||
).Scan(&id); err != nil {
|
)
|
||||||
|
if _, err = schema.Query(ctx).Insert(save); err != nil {
|
||||||
Logger.Warnf("collector: error storing packet: %v", err)
|
Logger.Warnf("collector: error storing packet: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch parsed.PayloadType {
|
switch parsed.PayloadType {
|
||||||
case meshcore.TypeAdvert:
|
case meshcore.TypeAdvert:
|
||||||
payload, err := parsed.Decode()
|
c.processMeshCoreAdvert(ctx, save, &parsed)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
@@ -1,9 +1,11 @@
|
|||||||
module git.maze.io/ham/hamview
|
module git.maze.io/ham/hamview
|
||||||
|
|
||||||
go 1.25.6
|
go 1.26
|
||||||
|
|
||||||
|
replace git.maze.io/go/ham => ../ham
|
||||||
|
|
||||||
require (
|
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/Vaniog/go-postgis v0.0.0-20240619200434-9c2eb8ed621e
|
||||||
github.com/cemkiy/echo-logrus v0.0.0-20200218141616-06f9cd1dae34
|
github.com/cemkiy/echo-logrus v0.0.0-20200218141616-06f9cd1dae34
|
||||||
github.com/cridenour/go-postgis v1.0.1
|
github.com/cridenour/go-postgis v1.0.1
|
||||||
@@ -11,20 +13,26 @@ require (
|
|||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/labstack/echo/v4 v4.15.0
|
github.com/labstack/echo/v4 v4.15.0
|
||||||
github.com/lib/pq v1.11.2
|
github.com/lib/pq v1.11.2
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.32
|
||||||
github.com/sirupsen/logrus v1.9.4
|
github.com/sirupsen/logrus v1.9.4
|
||||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
|
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
|
||||||
github.com/urfave/cli/v3 v3.6.2
|
github.com/urfave/cli/v3 v3.6.2
|
||||||
go.yaml.in/yaml/v3 v3.0.4
|
go.yaml.in/yaml/v3 v3.0.4
|
||||||
|
xorm.io/builder v0.3.13
|
||||||
|
xorm.io/xorm v1.3.11
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
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/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/kr/pretty v0.3.0 // indirect
|
github.com/kr/pretty v0.3.0 // indirect
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.8.0 // 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/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
|||||||
74
go.sum
@@ -1,9 +1,7 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
git.maze.io/go/ham v0.1.0 h1:ytqqkGux4E6h3QbCB3zJy/Ngc+fEqodyMpepbp9o/ts=
|
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
|
||||||
git.maze.io/go/ham v0.1.0/go.mod h1:+WuiawzNBqlWgklVoodUAJc0cV+NDW6RR8Tn+AW8hsU=
|
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
|
||||||
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=
|
|
||||||
github.com/Vaniog/go-postgis v0.0.0-20240619200434-9c2eb8ed621e h1:Ck+0lNRr62RM/LNKkkD0R1aJ2DvgELqmmuNvyyHL75E=
|
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/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=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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/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 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||||
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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.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/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=
|
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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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.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 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
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.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.1.1/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.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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU=
|
||||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
|
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=
|
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.0.0-20200214034016-1d94cc7ab1c6/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
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-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-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.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 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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-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-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
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 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
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.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 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-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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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/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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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=
|
||||||
|
|||||||
55
meshcore.go
@@ -24,9 +24,10 @@ type MeshCoreConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MeshCoreCompanionConfig struct {
|
type MeshCoreCompanionConfig struct {
|
||||||
Port string `yaml:"port"`
|
Port string `yaml:"port"`
|
||||||
Baud int `yaml:"baud"`
|
Baud int `yaml:"baud"`
|
||||||
Addr string `yaml:"addr"`
|
Addr string `yaml:"addr"`
|
||||||
|
HasSNR bool `yaml:"has_snr"` // patch: adds SNR/RSSI data
|
||||||
}
|
}
|
||||||
|
|
||||||
type MeshCorePrefix byte
|
type MeshCorePrefix byte
|
||||||
@@ -54,6 +55,9 @@ func NewMeshCoreReceiver(config *MeshCoreConfig) (protocol.PacketReceiver, error
|
|||||||
case "companion", "":
|
case "companion", "":
|
||||||
return newMeshCoreCompanionReceiver(config.Conf)
|
return newMeshCoreCompanionReceiver(config.Conf)
|
||||||
|
|
||||||
|
case "repeater":
|
||||||
|
return newMeshCoreRepeaterReceiver(config.Conf)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("hamview: unsupported MeshCore node type %q", config.Type)
|
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
|
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 {
|
type meshCoreNode struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
PublicKey []byte `json:"public_key"`
|
PublicKey []byte `json:"public_key"`
|
||||||
|
|||||||
87
schema/aprs.go
Normal 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
@@ -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
@@ -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
@@ -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
@@ -1,423 +1,30 @@
|
|||||||
package hamview
|
package hamview
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"github.com/sirupsen/logrus"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
echologrus "github.com/cemkiy/echo-logrus"
|
"git.maze.io/ham/hamview/server"
|
||||||
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultServerListen = ":8073"
|
// Deprecated: Use server.Config instead
|
||||||
|
type ServerConfig = server.Config
|
||||||
|
|
||||||
type ServerConfig struct {
|
// Deprecated: Use server.Server instead
|
||||||
Listen string `yaml:"listen"`
|
type Server = server.Server
|
||||||
}
|
|
||||||
|
|
||||||
type Server struct {
|
|
||||||
listen string
|
|
||||||
listenAddr *net.TCPAddr
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// NewServer creates a new server instance
|
||||||
|
// Deprecated: Use server.New instead
|
||||||
func NewServer(serverConfig *ServerConfig, databaseConfig *DatabaseConfig) (*Server, error) {
|
func NewServer(serverConfig *ServerConfig, databaseConfig *DatabaseConfig) (*Server, error) {
|
||||||
if serverConfig.Listen == "" {
|
// Get logger from the global context or create a new one
|
||||||
serverConfig.Listen = DefaultServerListen
|
logger := Logger
|
||||||
|
if logger == nil {
|
||||||
|
logger = logrus.New()
|
||||||
}
|
}
|
||||||
|
|
||||||
listenAddr, err := net.ResolveTCPAddr("tcp", serverConfig.Listen)
|
dbConfig := &server.DatabaseConfig{
|
||||||
if err != nil {
|
Type: databaseConfig.Type,
|
||||||
return nil, fmt.Errorf("hamview: invalid listen address %q: %v", serverConfig.Listen, err)
|
Conf: databaseConfig.Conf,
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := sql.Open(databaseConfig.Type, databaseConfig.Conf)
|
return server.New(serverConfig, dbConfig, logger)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
713
server/API.md
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,5 +1,31 @@
|
|||||||
package hamview
|
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 (
|
const (
|
||||||
sqlCreateRadio = `
|
sqlCreateRadio = `
|
||||||
CREATE TABLE IF NOT EXISTS radio (
|
CREATE TABLE IF NOT EXISTS radio (
|
||||||
@@ -16,20 +42,56 @@ const (
|
|||||||
latitude NUMERIC(10, 8), -- GPS latitude in decimal degrees
|
latitude NUMERIC(10, 8), -- GPS latitude in decimal degrees
|
||||||
longitude NUMERIC(11, 8), -- GPS longitude in decimal degrees
|
longitude NUMERIC(11, 8), -- GPS longitude in decimal degrees
|
||||||
altitude REAL, -- Altitude in meters
|
altitude REAL, -- Altitude in meters
|
||||||
frequency DOUBLE PRECISION,
|
frequency DOUBLE PRECISION NOT NULL,
|
||||||
bandwidth DOUBLE PRECISION,
|
bandwidth DOUBLE PRECISION NOT NULL,
|
||||||
rx_frequency DOUBLE PRECISION,
|
rx_frequency DOUBLE PRECISION,
|
||||||
tx_frequency DOUBLE PRECISION,
|
tx_frequency DOUBLE PRECISION,
|
||||||
power REAL,
|
power REAL,
|
||||||
gain REAL,
|
gain REAL,
|
||||||
lora_sf SMALLINT,
|
lora_sf SMALLINT,
|
||||||
lora_cr 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);`
|
sqlIndexRadioName = `CREATE INDEX IF NOT EXISTS idx_radio_name ON radio(name);`
|
||||||
sqlIndexRadioProtocol = `CREATE INDEX IF NOT EXISTS idx_radio_protocol ON radio(protocol);`
|
sqlIndexRadioProtocol = `CREATE INDEX IF NOT EXISTS idx_radio_protocol ON radio(protocol);`
|
||||||
sqlGeometryRadioPosition = `SELECT AddGeometryColumn('public', 'radio', 'position', 4326, 'POINT', 2);`
|
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 (
|
const (
|
||||||
@@ -74,6 +136,38 @@ const (
|
|||||||
`
|
`
|
||||||
sqlIndexMeshCorePacketHash = `CREATE INDEX IF NOT EXISTS idx_meshcore_packet_hash ON meshcore_packet(hash);`
|
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);`
|
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 (
|
const (
|
||||||
@@ -105,6 +199,12 @@ const (
|
|||||||
ELSE NULL
|
ELSE NULL
|
||||||
END
|
END
|
||||||
) STORED;`
|
) STORED;`
|
||||||
|
sqlSelectMeshCoreNodeStats = `
|
||||||
|
SELECT
|
||||||
|
COUNT(id)
|
||||||
|
FROM
|
||||||
|
meshcore_node
|
||||||
|
`
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -234,3 +334,4 @@ const (
|
|||||||
);
|
);
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
|
*/
|
||||||
|
|||||||
24
ui/.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
53
ui/package.json
Normal 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
@@ -0,0 +1 @@
|
|||||||
|
../../asset/image
|
||||||
1
ui/public/vite.svg
Normal 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
@@ -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
@@ -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
@@ -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 |
13
ui/src/components/Full.tsx
Normal 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;
|
||||||
17
ui/src/components/HorizontalSplit.tsx
Normal 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;
|
||||||
90
ui/src/components/Layout.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
ui/src/components/Layout.tsx
Normal 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>© {new Date().getFullYear()} PD0MZ. All rights reserved.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
138
ui/src/components/RadioCard.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
ui/src/components/RadioCard.tsx
Normal 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;
|
||||||
29
ui/src/components/StreamStatus.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
ui/src/components/StreamStatus.tsx
Normal 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;
|
||||||
27
ui/src/components/VerticalSplit.tsx
Normal 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;
|
||||||
60
ui/src/contexts/RadiosContext.tsx
Normal 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]);
|
||||||
|
};
|
||||||
215
ui/src/contexts/StreamContext.tsx
Normal 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
@@ -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
@@ -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('');
|
||||||
|
}
|
||||||
235
ui/src/libs/deviceImageMapper.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
71
ui/src/pages/NotFound.scss
Normal 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
@@ -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;
|
||||||
50
ui/src/pages/Overview.scss
Normal 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
@@ -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
|
||||||
382
ui/src/pages/StyleGuide.scss
Normal 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
@@ -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;
|
||||||
240
ui/src/pages/aprs/APRSData.tsx
Normal 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>;
|
||||||
|
};
|
||||||
221
ui/src/pages/aprs/APRSPacketsView.tsx
Normal 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;
|
||||||
50
ui/src/pages/meshcore/MeshCoreContext.tsx
Normal 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;
|
||||||
385
ui/src/pages/meshcore/MeshCoreData.tsx
Normal 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>;
|
||||||
|
};
|
||||||
254
ui/src/pages/meshcore/MeshCoreGroupChatView.tsx
Normal 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;
|
||||||
62
ui/src/pages/meshcore/MeshCoreMapView.tsx
Normal 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;
|
||||||
590
ui/src/pages/meshcore/MeshCorePacketsView.tsx
Normal 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;
|
||||||
964
ui/src/protocols/aprs.test.ts
Normal 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
@@ -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);
|
||||||
|
}
|
||||||
302
ui/src/protocols/aprs.types.ts
Normal 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;
|
||||||
|
}
|
||||||
654
ui/src/protocols/meshcore.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
541
ui/src/protocols/meshcore.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
388
ui/src/protocols/meshcore.types.ts
Normal 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
@@ -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;
|
||||||
40
ui/src/services/APRSService.ts
Normal 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;
|
||||||
117
ui/src/services/APRSStream.ts
Normal 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;
|
||||||
72
ui/src/services/MeshCoreService.ts
Normal 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;
|
||||||
95
ui/src/services/MeshCoreStream.ts
Normal 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
@@ -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
@@ -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';
|
||||||
41
ui/src/styles/_variables.scss
Normal 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;
|
||||||
|
}
|
||||||
30
ui/src/styles/theme/_badges.scss
Normal 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);
|
||||||
|
}
|
||||||
80
ui/src/styles/theme/_buttons.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
ui/src/styles/theme/_code.scss
Normal 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;
|
||||||
|
}
|
||||||
42
ui/src/styles/theme/_forms.scss
Normal 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);
|
||||||
|
}
|
||||||
19
ui/src/styles/theme/_tags.scss
Normal 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);
|
||||||
|
}
|
||||||
58
ui/src/styles/theme/_typography.scss
Normal 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;
|
||||||
|
}
|
||||||
56
ui/src/styles/theme/_utilities.scss
Normal 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);
|
||||||
|
}
|
||||||
36
ui/src/types/layout.types.tsx
Normal 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;
|
||||||
|
}
|
||||||