diff --git a/API Documentation.md b/API Documentation.md new file mode 100644 index 0000000..3bdd5c9 --- /dev/null +++ b/API Documentation.md @@ -0,0 +1,122 @@ +# API Documentation + +This document describes the available REST API endpoints for **Chat** and **Nodes**. + +--- + +## **Chat API** + +### **`GET /api/chat`** +Fetches chat messages, with support for both initial loading and incremental updates. + +#### **Query Parameters** +| Name | Type | Required | Description | +|----------|--------|----------|-------------| +| `limit` | int | No | Number of messages to return (default: 100, max: 200). | +| `since` | string | No | Return only messages with `import_time > since` (ISO 8601 format, e.g., `2025-07-21T12:00:00`). | + +#### **Response (200 OK)** +```json +{ + "packets": [ + { + "id": 123, + "import_time": "2025-07-22T14:12:00.123456", + "channel": "LongFast", + "from_node_id": 456789, + "long_name": "Node A", + "payload": "Hello world!" + } + ], + "latest_import_time": "2025-07-22T14:12:00.123456" +} +``` + +**Fields:** +- `id`: Unique packet ID. +- `import_time`: ISO 8601 timestamp of when the message was imported. +- `channel`: Channel name. +- `from_node_id`: Numeric ID of the node that sent the message. +- `long_name`: Human-readable name of the node (if available). +- `payload`: Actual message text. + +#### **Examples** +- **Get last 50 messages:** + ``` + GET /api/chat?limit=50 + ``` +- **Get messages after a certain timestamp:** + ``` + GET /api/chat?since=2025-07-22T12:00:00 + ``` + +--- + +## **Nodes API** + +### **`GET /api/nodes`** +Returns a list of all nodes, with optional filters based on "last seen" time. + +#### **Query Parameters** +| Name | Type | Required | Description | +|-------------------|--------|----------|-------------| +| `hours` | int | No | Only return nodes seen within the last `N` hours. | +| `days` | int | No | Only return nodes seen within the last `N` days. | +| `last_seen_after` | string | No | Custom ISO 8601 timestamp for filtering (`2025-07-21T10:00:00`). | + +**Note:** `hours` and `days` take precedence over `last_seen_after`. + +#### **Response (200 OK)** +```json +{ + "nodes": [ + { + "node_id": 123456, + "long_name": "BaseStation", + "short_name": "BS", + "channel": "LongFast", + "last_seen": "2025-07-22T14:10:00.000000", + "hardware": "Heltec V3", + "firmware": "1.3.2", + "role": "CLIENT" + } + ] +} +``` + +**Fields:** +- `node_id`: Unique ID of the node. +- `long_name`: Full descriptive name of the node. +- `short_name`: Short name or alias. +- `channel`: Channel the node is configured on. +- `last_seen`: ISO 8601 timestamp of the last time the node was seen. +- `hardware`: Hardware model (e.g., Heltec V3). +- `firmware`: Firmware version of the node. +- `role`: Node role (e.g., `CLIENT`, `ROUTER`, etc.). + +#### **Examples** +- **All nodes:** + ``` + GET /api/nodes + ``` +- **Nodes seen in the last 1 hour:** + ``` + GET /api/nodes?hours=1 + ``` +- **Nodes seen in the last 7 days:** + ``` + GET /api/nodes?days=7 + ``` +- **Custom timestamp:** + ``` + GET /api/nodes?last_seen_after=2025-07-21T12:00:00 + ``` + +--- + +## **General Notes** +- All timestamps are returned in **localtime** (ISO 8601 format). +- Both endpoints return JSON responses with `application/json` content type. +- Error responses return `{"error": "message"}` with an appropriate HTTP status code (e.g., `500`). + +--- diff --git a/meshview/web.py b/meshview/web.py index 511bfe5..1fd5512 100644 --- a/meshview/web.py +++ b/meshview/web.py @@ -1431,6 +1431,131 @@ async def get_config(request): except (json.JSONDecodeError, TypeError): return web.json_response({"error": "Invalid configuration format"}, status=500) +# API Section + +# How this works +# When your frontend calls /api/chat without since, it returns the most recent limit (default 100) messages. +# When your frontend calls /api/chat?since=ISO_TIMESTAMP, it returns only messages with import_time > since. +# The response includes "latest_import_time" for frontend to keep track of the newest message timestamp. +# The backend fetches extra packets (limit*5) to account for filtering messages like "seq N" and since filtering. + +@routes.get("/api/chat") +async def api_chat(request): + try: + # Parse query params + limit_str = request.query.get("limit", "100") + since_str = request.query.get("since", None) + + try: + limit = min(max(int(limit_str), 1), 200) # Limit between 1 and 200 + except ValueError: + limit = 100 + + if since_str: + try: + since = datetime.datetime.fromisoformat(since_str) + except Exception as e: + print(f"Failed to parse since '{since_str}': {e}") + since = None + else: + since = None + + # Fetch packets from store + packets = await store.get_packets( + node_id=0xFFFFFFFF, + portnum=PortNum.TEXT_MESSAGE_APP, + limit=limit*5, # Fetch extra to filter out seq messages and since filter + ) + + SEQ_REGEX = re.compile(r"seq \d+") + ui_packets = [Packet.from_model(p) for p in packets] + filtered_packets = [p for p in ui_packets if p.payload and not SEQ_REGEX.fullmatch(p.payload)] + + # Filter by 'since' timestamp if provided + if since: + filtered_packets = [p for p in filtered_packets if p.import_time > since] + + # Sort by import_time descending (latest first) + filtered_packets.sort(key=lambda p: p.import_time, reverse=True) + + # Trim to requested limit + filtered_packets = filtered_packets[:limit] + + packets_data = [{ + "id": p.id, + "import_time": p.import_time.isoformat(), + "channel": getattr(p.from_node, "channel", ""), + "from_node_id": p.from_node_id, + "long_name": getattr(p.from_node, "long_name", ""), + "payload": p.payload, + } for p in filtered_packets] + + latest_import_time = filtered_packets[0].import_time.isoformat() if filtered_packets else since_str or None + + return web.json_response({ + "packets": packets_data, + "latest_import_time": latest_import_time, + }) + + except Exception as e: + print("Error in /api/chat:", e) + return web.json_response({"error": "Failed to fetch chat data"}, status=500) + + +# Client to pass ?hours=1 or ?days=7 to filter + +@routes.get("/api/nodes") +async def api_nodes(request): + try: + # Query params + hours = request.query.get("hours") + days = request.query.get("days") + last_seen_after = None + + # Determine cutoff time + if hours: + try: + last_seen_after = datetime.datetime.now() - datetime.timedelta(hours=int(hours)) + except ValueError: + pass + elif days: + try: + last_seen_after = datetime.datetime.now() - datetime.timedelta(days=int(days)) + except ValueError: + pass + else: + # Fallback: if a direct ISO timestamp is provided + last_seen_str = request.query.get("last_seen_after") + if last_seen_str: + try: + last_seen_after = datetime.datetime.fromisoformat(last_seen_str) + except Exception as e: + print(f"Failed to parse last_seen_after '{last_seen_str}': {e}") + + # Fetch nodes + nodes = await store.get_nodes() + + # Apply filter + if last_seen_after: + nodes = [n for n in nodes if n.last_seen and n.last_seen > last_seen_after] + + # Prepare response + nodes_data = [{ + "node_id": n.id, + "long_name": n.long_name, + "short_name": n.short_name, + "channel": n.channel, + "last_seen": n.last_seen.isoformat() if n.last_seen else None, + "hardware": n.hardware, + "firmware": n.firmware, + "role": n.role, + } for n in nodes] + + return web.json_response({"nodes": nodes_data}) + except Exception as e: + print("Error in /api/nodes:", e) + return web.json_response({"error": "Failed to fetch nodes"}, status=500) + async def run_server(): app = web.Application()