Checkpoint
This commit is contained in:
713
server/API.md
Normal file
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
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
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
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
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
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
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
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
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
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
|
||||
Reference in New Issue
Block a user