Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,9 @@ location:
update_interval_seconds: 5
# Minimum acceptable fix mode: 0=any (incl. no-fix), 1=2D, 2=3D.
min_fix_quality: 1

# automation:
# enabled: false
# # Long random secret for LAN scripts (Home Assistant, Node-RED). Generate with:
# # openssl rand -hex 32
# token: ""
172 changes: 172 additions & 0 deletions docs/API-AUTOMATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# LAN Automation API

Meshpoint exposes a small REST surface for Home Assistant, Node-RED, and other LAN automation tools. It reuses the same data and TX paths as the dashboard but accepts a static API token so scripts do not need browser cookies.

**Local network only.** Do not port-forward port 8080 to the internet. The automation API is designed for trusted LAN clients.

## Enable

1. Generate a long random token (32+ characters):

```bash
openssl rand -hex 32
```

2. Add to `config/local.yaml`:

```yaml
automation:
enabled: true
token: "paste-your-64-char-hex-here"
```

3. Restart Meshpoint:

```bash
sudo systemctl restart meshpoint
```

When `automation.enabled` is `false` (the default), all `/api/automation/*` routes return **403** and existing dashboard behaviour is unchanged.

## Authentication

Present the token using either header:

| Header | Example |
|--------|---------|
| `X-Meshpoint-Token` | `X-Meshpoint-Token: abc123...` |
| `Authorization` | `Authorization: Bearer abc123...` |

A valid dashboard JWT (cookie or `Authorization: Bearer <jwt>`) also works when automation is enabled.

## Endpoints (v1)

Base URL: `http://<meshpoint-ip>:8080`

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/automation/status` | Uptime, relay stats, WebSocket client count |
| `GET` | `/api/automation/nodes` | Node list with signal (`?limit=500&enrich=true`) |
| `GET` | `/api/automation/nodes/{id}` | Single node record |
| `GET` | `/api/automation/packets` | Recent packets (`?limit=100`) |
| `POST` | `/api/automation/send` | Send Meshtastic text (broadcast or DM) |

### Send message

```json
POST /api/automation/send
Content-Type: application/json
X-Meshpoint-Token: <your-token>

{
"text": "Hello from Home Assistant",
"channel": 0,
"destination": "broadcast",
"protocol": "meshtastic",
"want_ack": false
}
```

Response (success):

```json
{
"success": true,
"packet_id": 1234567890,
"protocol": "meshtastic",
"timestamp": "2026-06-02T12:00:00+00:00",
"airtime_ms": 42,
"error": null
}
```

TX rate limits and duty-cycle guards from the native messaging stack still apply.

## curl examples

```bash
export MESHPOINT="http://192.168.1.50:8080"
export TOKEN="your-automation-token"

# Health check
curl -s -H "X-Meshpoint-Token: $TOKEN" "$MESHPOINT/api/automation/status" | jq .

# List nodes
curl -s -H "X-Meshpoint-Token: $TOKEN" "$MESHPOINT/api/automation/nodes?limit=20" | jq .

# Recent packets
curl -s -H "X-Meshpoint-Token: $TOKEN" "$MESHPOINT/api/automation/packets?limit=5" | jq .

# Broadcast
curl -s -X POST -H "Content-Type: application/json" \
-H "X-Meshpoint-Token: $TOKEN" \
-d '{"text":"Test from curl","channel":0}' \
"$MESHPOINT/api/automation/send" | jq .
```

## Home Assistant

REST sensor for node count:

```yaml
rest:
- resource: "http://192.168.1.50:8080/api/automation/nodes?limit=500"
headers:
X-Meshpoint-Token: !secret meshpoint_automation_token
scan_interval: 60
sensor:
- name: "Mesh Nodes"
value_template: "{{ value_json | length }}"
unit_of_measurement: "nodes"
```

REST command to send a message:

```yaml
rest_command:
meshpoint_broadcast:
url: "http://192.168.1.50:8080/api/automation/send"
method: POST
headers:
Content-Type: "application/json"
X-Meshpoint-Token: !secret meshpoint_automation_token
payload: '{"text":"{{ message }}","channel":0}'
```

Automation example:

```yaml
automation:
- alias: "Meshpoint morning status"
trigger:
- platform: time
at: "08:00:00"
action:
- service: rest_command.meshpoint_broadcast
data:
message: "Good morning from Home Assistant"
```

Store the token in `secrets.yaml`:

```yaml
meshpoint_automation_token: "your-64-char-hex-token"
```

## Node-RED

Use an **http request** node:

- Method: `GET` or `POST` as needed
- URL: `http://192.168.1.50:8080/api/automation/status`
- Headers: `X-Meshpoint-Token` → your token (use an environment variable or credentials store)

## Errors

| Status | Meaning |
|--------|---------|
| `401` | Missing or invalid token / JWT |
| `403` | Automation API disabled (`automation.enabled: false`) |
| `503` | Enabled but token missing or shorter than 32 characters |

The raw token is never returned by `GET /api/config`; only `automation.token_set: true/false` is exposed.
26 changes: 26 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,28 @@ Access at `http://<pi-ip>:8080`. Bind to `127.0.0.1` to restrict to local access

---

## LAN Automation API

Optional REST endpoints for Home Assistant, Node-RED, and other LAN scripts. **Off by default** — enabling does not change dashboard behaviour.

```yaml
automation:
enabled: false
token: "" # required when enabled; use 32+ random characters
```

Generate a token:

```bash
openssl rand -hex 32
```

Add the `automation` block to `config/local.yaml`, set `enabled: true`, paste the token, and restart Meshpoint. Scripts authenticate with `X-Meshpoint-Token: <token>` or `Authorization: Bearer <token>`.

**Security:** use only on a trusted LAN. Do not port-forward the dashboard port. See [API-AUTOMATION.md](API-AUTOMATION.md) for endpoint reference and copy-paste Home Assistant examples.

---

## Device Identity

```yaml
Expand Down Expand Up @@ -748,6 +770,10 @@ dashboard: # local web UI
host: "0.0.0.0"
port: 8080
static_dir: "frontend"

automation: # LAN REST API for scripts (off by default)
enabled: false
token: ""
```

You only need to put the keys you want to override into `local.yaml`. Every key omitted from `local.yaml` falls back to the value in `config/default.yaml`.
56 changes: 55 additions & 1 deletion src/api/auth/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from __future__ import annotations

import secrets
from typing import NoReturn, Optional

from fastapi import Header, HTTPException, Request, status
Expand All @@ -30,11 +31,14 @@
JwtSessionService,
SessionClaims,
)
from src.config import ApiAutomationConfig

SESSION_COOKIE_NAME = "meshpoint_session"
_BEARER_PREFIX = "Bearer "

_jwt_service: JwtSessionService | None = None
_automation_cfg: ApiAutomationConfig | None = None
_MIN_AUTOMATION_TOKEN_LEN = 32


def init_auth(jwt_service: JwtSessionService) -> None:
Expand All @@ -43,10 +47,17 @@ def init_auth(jwt_service: JwtSessionService) -> None:
_jwt_service = jwt_service


def init_automation(config: ApiAutomationConfig) -> None:
"""Bind automation API settings used by ``require_automation_auth``."""
global _automation_cfg
_automation_cfg = config


def reset_auth() -> None:
"""Test helper: clear module-level state between cases."""
global _jwt_service
global _jwt_service, _automation_cfg
_jwt_service = None
_automation_cfg = None


def _extract_token(request: Request, authorization: Optional[str]) -> str:
Expand Down Expand Up @@ -109,3 +120,46 @@ async def optional_auth(
) -> Optional[SessionClaims]:
"""Dependency: returns claims if presented, ``None`` otherwise."""
return _claims_or_none(_extract_token(request, authorization))


def _automation_token_configured() -> str:
cfg = _automation_cfg
if cfg is None or not cfg.enabled:
return ""
token = (cfg.token or "").strip()
if len(token) < _MIN_AUTOMATION_TOKEN_LEN:
return ""
return token


async def require_automation_auth(
request: Request,
authorization: Optional[str] = Header(default=None),
x_meshpoint_token: Optional[str] = Header(default=None, alias="X-Meshpoint-Token"),
) -> None:
"""Dependency: 403 when disabled; 401 unless JWT or automation token is valid."""
if _automation_cfg is None or not _automation_cfg.enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="automation API disabled",
)

api_token = _automation_token_configured()
if not api_token:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="automation API misconfigured: token required",
)

session_token = _extract_token(request, authorization)
if session_token:
if _claims_or_none(session_token) is not None:
return
if secrets.compare_digest(session_token, api_token):
return

header_token = (x_meshpoint_token or "").strip()
if header_token and secrets.compare_digest(header_token, api_token):
return

_raise_unauthorized()
63 changes: 63 additions & 0 deletions src/api/routes/automation_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Thin LAN automation API for Home Assistant / Node-RED clients.

Aliases the dashboard endpoints operators already use, gated by
``require_automation_auth`` (optional static token or existing JWT).
"""

from __future__ import annotations

from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field

from src.api.auth.dependencies import require_automation_auth
from src.api.routes import device as device_routes
from src.api.routes import messages as messages_routes
from src.api.routes import nodes as nodes_routes
from src.api.routes import packets as packets_routes

router = APIRouter(
prefix="/api/automation",
tags=["automation"],
dependencies=[Depends(require_automation_auth)],
)


@router.get("/nodes")
async def automation_list_nodes(limit: int = 500, enrich: bool = True):
return await nodes_routes.list_nodes(limit=limit, enrich=enrich)


@router.get("/nodes/{node_id}")
async def automation_get_node(node_id: str):
return await nodes_routes.get_node(node_id)


@router.get("/packets")
async def automation_list_packets(limit: int = 100):
return await packets_routes.list_packets(limit=limit)


@router.get("/status")
async def automation_device_status():
return await device_routes.device_status()


class AutomationSendRequest(BaseModel):
text: str
channel: int = Field(0, ge=0, le=7)
destination: str = "broadcast"
protocol: str = "meshtastic"
want_ack: bool = False


@router.post("/send")
async def automation_send(req: AutomationSendRequest):
return await messages_routes.send_message(
messages_routes.SendRequest(
text=req.text,
channel=req.channel,
destination=req.destination,
protocol=req.protocol,
want_ack=req.want_ack,
)
)
5 changes: 5 additions & 0 deletions src/api/routes/config_enrichment.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,9 @@ def enrich_config_payload(cfg: AppConfig, base: dict) -> dict:
"coordinate_source": pos.coordinate_source,
"location_precision": pos.location_precision,
}
auto = cfg.automation
base["automation"] = {
"enabled": auto.enabled,
"token_set": bool((auto.token or "").strip()),
}
return base
Loading
Loading