feat: add node control API for starting/stopping peers via SSH

New endpoints on master node:
- POST /api/nodes/<id>/start - Start rf-mapper on peer
- POST /api/nodes/<id>/stop - Stop rf-mapper on peer
- POST /api/nodes/<id>/restart - Restart rf-mapper on peer
- GET /api/nodes/<id>/status - Check peer status

Uses SSH with scanner_id as hostname (relies on ~/.ssh/config).
Enables Home Assistant integration to control peer nodes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
User
2026-02-01 14:16:38 +01:00
parent 3ff43de5ea
commit cee36e2ce1

View File

@@ -2,6 +2,7 @@
import json
import os
import subprocess
import threading
import time
from datetime import datetime
@@ -1598,6 +1599,130 @@ def create_app(config: Config | None = None) -> Flask:
else:
return jsonify({"error": "Peer not found"}), 404
# ==================== Node Control API ====================
def _ssh_node_command(scanner_id: str, command: str, timeout: int = 30) -> tuple[bool, str]:
"""Execute a command on a peer node via SSH.
Uses scanner_id as SSH hostname (relies on ~/.ssh/config for ports).
Returns:
Tuple of (success, output/error message)
"""
try:
result = subprocess.run(
["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes",
scanner_id, command],
capture_output=True,
text=True,
timeout=timeout
)
output = result.stdout.strip() or result.stderr.strip()
return result.returncode == 0, output
except subprocess.TimeoutExpired:
return False, "SSH command timed out"
except Exception as e:
return False, str(e)
@app.route("/api/nodes/<scanner_id>/start", methods=["POST"])
def api_node_start(scanner_id: str):
"""Start rf-mapper on a peer node via SSH"""
rf_config = app.config["RF_CONFIG"]
if not rf_config.scanner.is_master:
return jsonify({"error": "Only master node can control peers"}), 403
db = app.config.get("DATABASE")
if db:
peer = db.get_peer(scanner_id)
if not peer:
return jsonify({"error": f"Unknown peer: {scanner_id}"}), 404
print(f"[Node] Starting rf-mapper on {scanner_id}...")
cmd = "cd ~/git/rf-mapper && source venv/bin/activate && rf-mapper start"
success, output = _ssh_node_command(scanner_id, cmd, timeout=60)
if success:
print(f"[Node] {scanner_id} started successfully")
return jsonify({"status": "started", "scanner_id": scanner_id, "output": output})
else:
print(f"[Node] Failed to start {scanner_id}: {output}")
return jsonify({"error": "Failed to start", "details": output}), 500
@app.route("/api/nodes/<scanner_id>/stop", methods=["POST"])
def api_node_stop(scanner_id: str):
"""Stop rf-mapper on a peer node via SSH"""
rf_config = app.config["RF_CONFIG"]
if not rf_config.scanner.is_master:
return jsonify({"error": "Only master node can control peers"}), 403
db = app.config.get("DATABASE")
if db:
peer = db.get_peer(scanner_id)
if not peer:
return jsonify({"error": f"Unknown peer: {scanner_id}"}), 404
print(f"[Node] Stopping rf-mapper on {scanner_id}...")
cmd = "cd ~/git/rf-mapper && source venv/bin/activate && rf-mapper stop"
success, output = _ssh_node_command(scanner_id, cmd, timeout=30)
if success:
print(f"[Node] {scanner_id} stopped successfully")
return jsonify({"status": "stopped", "scanner_id": scanner_id, "output": output})
else:
print(f"[Node] Failed to stop {scanner_id}: {output}")
return jsonify({"error": "Failed to stop", "details": output}), 500
@app.route("/api/nodes/<scanner_id>/restart", methods=["POST"])
def api_node_restart(scanner_id: str):
"""Restart rf-mapper on a peer node via SSH"""
rf_config = app.config["RF_CONFIG"]
if not rf_config.scanner.is_master:
return jsonify({"error": "Only master node can control peers"}), 403
db = app.config.get("DATABASE")
if db:
peer = db.get_peer(scanner_id)
if not peer:
return jsonify({"error": f"Unknown peer: {scanner_id}"}), 404
print(f"[Node] Restarting rf-mapper on {scanner_id}...")
cmd = "cd ~/git/rf-mapper && source venv/bin/activate && rf-mapper restart"
success, output = _ssh_node_command(scanner_id, cmd, timeout=60)
if success:
print(f"[Node] {scanner_id} restarted successfully")
return jsonify({"status": "restarted", "scanner_id": scanner_id, "output": output})
else:
print(f"[Node] Failed to restart {scanner_id}: {output}")
return jsonify({"error": "Failed to restart", "details": output}), 500
@app.route("/api/nodes/<scanner_id>/status", methods=["GET"])
def api_node_status(scanner_id: str):
"""Get rf-mapper status on a peer node via SSH"""
rf_config = app.config["RF_CONFIG"]
if not rf_config.scanner.is_master:
return jsonify({"error": "Only master node can control peers"}), 403
db = app.config.get("DATABASE")
if db:
peer = db.get_peer(scanner_id)
if not peer:
return jsonify({"error": f"Unknown peer: {scanner_id}"}), 404
cmd = "cd ~/git/rf-mapper && source venv/bin/activate && rf-mapper status"
success, output = _ssh_node_command(scanner_id, cmd, timeout=15)
# Parse status from output
reachable = success or "not running" in output.lower()
running = success and "running" in output.lower() and "not running" not in output.lower()
return jsonify({
"scanner_id": scanner_id,
"reachable": reachable,
"running": running,
"output": output
})
@app.route("/api/sync/devices", methods=["GET"])
def api_sync_devices_get():
"""Get devices for sync (called by peers)"""