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

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

87
schema/aprs.go Normal file
View File

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

192
schema/engine.go Normal file
View File

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

273
schema/meshcore.go Normal file
View File

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

75
schema/radio.go Normal file
View File

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