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:
@@ -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)"""
|
||||
|
||||
Reference in New Issue
Block a user