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

713
server/API.md Normal file
View File

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

164
server/README.md Normal file
View File

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

30
server/error.go Normal file
View File

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

65
server/handlers_aprs.go Normal file
View File

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

331
server/handlers_meshcore.go Normal file
View File

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

68
server/handlers_radios.go Normal file
View File

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

48
server/router.go Normal file
View File

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

99
server/server.go Normal file
View File

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

370
server/server_test.go Normal file
View File

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

273
server/types.go Normal file
View File

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