feat: add Home Assistant integration and improve CLI/UI
Home Assistant Integration: - New homeassistant.py module with webhook support - Webhooks for scan results, new devices, and device departures - Absence detection with configurable timeout - Documentation in docs/HOME_ASSISTANT.md CLI Improvements: - Replace 'web' command with start/stop/restart/status - Background daemon mode with PID file management - Foreground mode for debugging (--foreground) Web UI Enhancements: - Improved device list styling and layout - Better floor assignment UI - Enhanced map visualization Documentation: - Add CHANGELOG.md - Add docs/API.md with full endpoint reference - Add docs/CHEATSHEET.md for quick reference - Update project documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,11 +22,12 @@ def main():
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
rf-mapper scan # Scan and save results
|
||||
rf-mapper start # Start web server (background)
|
||||
rf-mapper start --foreground # Start in foreground (debug)
|
||||
rf-mapper stop # Stop web server
|
||||
rf-mapper restart # Restart web server
|
||||
rf-mapper status # Check if running
|
||||
rf-mapper scan -l kitchen # Scan with location label
|
||||
rf-mapper visualize # Visualize latest scan
|
||||
rf-mapper analyze # Analyze RF environment
|
||||
rf-mapper web # Start web server
|
||||
rf-mapper config # Show current configuration
|
||||
|
||||
Note: Requires sudo for WiFi/Bluetooth scanning.
|
||||
@@ -95,33 +96,79 @@ Note: Requires sudo for WiFi/Bluetooth scanning.
|
||||
# List command
|
||||
subparsers.add_parser('list', help='List saved scans')
|
||||
|
||||
# Web server command
|
||||
web_parser = subparsers.add_parser('web', help='Start web server')
|
||||
web_parser.add_argument(
|
||||
# Start command
|
||||
start_parser = subparsers.add_parser('start', help='Start web server')
|
||||
start_parser.add_argument(
|
||||
'-H', '--host',
|
||||
help='Host to bind to (default from config)'
|
||||
)
|
||||
web_parser.add_argument(
|
||||
start_parser.add_argument(
|
||||
'-p', '--port',
|
||||
type=int,
|
||||
help='Port to listen on (default from config)'
|
||||
)
|
||||
web_parser.add_argument(
|
||||
start_parser.add_argument(
|
||||
'-f', '--foreground',
|
||||
action='store_true',
|
||||
help='Run in foreground (default: background daemon)'
|
||||
)
|
||||
start_parser.add_argument(
|
||||
'--debug',
|
||||
action='store_true',
|
||||
help='Enable debug mode'
|
||||
help='Enable Flask debug mode'
|
||||
)
|
||||
web_parser.add_argument(
|
||||
start_parser.add_argument(
|
||||
'--profile-requests',
|
||||
action='store_true',
|
||||
help='Enable per-request profiling (saves profiles to data/profiles/)'
|
||||
)
|
||||
web_parser.add_argument(
|
||||
start_parser.add_argument(
|
||||
'--log-requests',
|
||||
action='store_true',
|
||||
help='Log all requests to data/logs/requests_YYYYMMDD.log'
|
||||
)
|
||||
|
||||
# Stop command
|
||||
subparsers.add_parser('stop', help='Stop background web server')
|
||||
|
||||
# Restart command
|
||||
restart_parser = subparsers.add_parser('restart', help='Restart web server')
|
||||
restart_parser.add_argument(
|
||||
'-H', '--host',
|
||||
help='Host to bind to (default from config)'
|
||||
)
|
||||
restart_parser.add_argument(
|
||||
'-p', '--port',
|
||||
type=int,
|
||||
help='Port to listen on (default from config)'
|
||||
)
|
||||
restart_parser.add_argument(
|
||||
'--debug',
|
||||
action='store_true',
|
||||
help='Enable Flask debug mode'
|
||||
)
|
||||
restart_parser.add_argument(
|
||||
'--profile-requests',
|
||||
action='store_true',
|
||||
help='Enable per-request profiling'
|
||||
)
|
||||
restart_parser.add_argument(
|
||||
'--log-requests',
|
||||
action='store_true',
|
||||
help='Log all requests'
|
||||
)
|
||||
|
||||
# Status command
|
||||
subparsers.add_parser('status', help='Check if web server is running')
|
||||
|
||||
# Deprecated: web command (alias for start --foreground)
|
||||
web_parser = subparsers.add_parser('web', help='[DEPRECATED] Use "start" instead')
|
||||
web_parser.add_argument('-H', '--host', help='Host to bind to')
|
||||
web_parser.add_argument('-p', '--port', type=int, help='Port to listen on')
|
||||
web_parser.add_argument('--debug', action='store_true', help='Enable debug mode')
|
||||
web_parser.add_argument('--profile-requests', action='store_true', help='Enable profiling')
|
||||
web_parser.add_argument('--log-requests', action='store_true', help='Log requests')
|
||||
|
||||
# Config command
|
||||
config_parser = subparsers.add_parser('config', help='Show/edit configuration')
|
||||
config_parser.add_argument(
|
||||
@@ -152,10 +199,18 @@ Note: Requires sudo for WiFi/Bluetooth scanning.
|
||||
run_analyze(args, data_dir)
|
||||
elif args.command == 'list':
|
||||
run_list(data_dir)
|
||||
elif args.command == 'web':
|
||||
run_web(args, config)
|
||||
elif args.command == 'start':
|
||||
run_start(args, config, data_dir)
|
||||
elif args.command == 'stop':
|
||||
run_stop(data_dir)
|
||||
elif args.command == 'restart':
|
||||
run_restart(args, config, data_dir)
|
||||
elif args.command == 'status':
|
||||
run_status(data_dir)
|
||||
elif args.command == 'config':
|
||||
run_config(args, config)
|
||||
elif args.command == 'web':
|
||||
run_web_deprecated(args, config, data_dir)
|
||||
else:
|
||||
# Default: run interactive scan
|
||||
run_interactive(config, data_dir)
|
||||
@@ -339,26 +394,6 @@ def run_list(data_dir: Path):
|
||||
print(f"{ts:<20} {loc:<20} {wifi_count:>6} {bt_count:>6}")
|
||||
|
||||
|
||||
def run_web(args, config: Config):
|
||||
"""Start the web server"""
|
||||
from .web.app import run_server
|
||||
|
||||
host = args.host
|
||||
port = args.port
|
||||
debug = args.debug
|
||||
profile_requests = getattr(args, 'profile_requests', False)
|
||||
log_requests = getattr(args, 'log_requests', False)
|
||||
|
||||
run_server(
|
||||
host=host,
|
||||
port=port,
|
||||
debug=debug,
|
||||
config=config,
|
||||
profile_requests=profile_requests,
|
||||
log_requests=log_requests
|
||||
)
|
||||
|
||||
|
||||
def run_config(args, config: Config):
|
||||
"""Show or edit configuration"""
|
||||
if args.set_gps:
|
||||
@@ -401,5 +436,249 @@ Home Assistant:
|
||||
""")
|
||||
|
||||
|
||||
def get_pid_file(data_dir: Path) -> Path:
|
||||
"""Get path to PID file"""
|
||||
return data_dir / "rf-mapper.pid"
|
||||
|
||||
|
||||
def get_start_time_file(data_dir: Path) -> Path:
|
||||
"""Get path to start time file"""
|
||||
return data_dir / "rf-mapper.started"
|
||||
|
||||
|
||||
def read_pid(data_dir: Path) -> int | None:
|
||||
"""Read PID from file, return None if not exists or invalid"""
|
||||
pid_file = get_pid_file(data_dir)
|
||||
if not pid_file.exists():
|
||||
return None
|
||||
try:
|
||||
pid = int(pid_file.read_text().strip())
|
||||
return pid
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def read_start_time(data_dir: Path) -> float | None:
|
||||
"""Read start timestamp from file"""
|
||||
start_file = get_start_time_file(data_dir)
|
||||
if not start_file.exists():
|
||||
return None
|
||||
try:
|
||||
return float(start_file.read_text().strip())
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def format_uptime(seconds: float) -> str:
|
||||
"""Format uptime in human-readable form"""
|
||||
if seconds < 60:
|
||||
return f"{int(seconds)}s"
|
||||
elif seconds < 3600:
|
||||
mins = int(seconds // 60)
|
||||
secs = int(seconds % 60)
|
||||
return f"{mins}m {secs}s"
|
||||
elif seconds < 86400:
|
||||
hours = int(seconds // 3600)
|
||||
mins = int((seconds % 3600) // 60)
|
||||
return f"{hours}h {mins}m"
|
||||
else:
|
||||
days = int(seconds // 86400)
|
||||
hours = int((seconds % 86400) // 3600)
|
||||
return f"{days}d {hours}h"
|
||||
|
||||
|
||||
def is_process_running(pid: int) -> bool:
|
||||
"""Check if process with given PID is running"""
|
||||
import os
|
||||
import signal
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def run_web_deprecated(args, config: Config, data_dir: Path):
|
||||
"""Deprecated: run web server (forwards to start --foreground)"""
|
||||
import sys
|
||||
|
||||
print("\033[33m" + "=" * 60 + "\033[0m")
|
||||
print("\033[33mWARNING: 'rf-mapper web' is deprecated.\033[0m")
|
||||
print("\033[33mUse 'rf-mapper start' instead (runs in background).\033[0m")
|
||||
print("\033[33mUse 'rf-mapper start -f' for foreground mode.\033[0m")
|
||||
print("\033[33mThis command will be removed in a future version.\033[0m")
|
||||
print("\033[33m" + "=" * 60 + "\033[0m\n")
|
||||
|
||||
# Run in foreground (old behavior)
|
||||
from .web.app import run_server
|
||||
|
||||
host = args.host or config.web.host
|
||||
port = args.port or config.web.port
|
||||
debug = getattr(args, 'debug', False)
|
||||
profile_requests = getattr(args, 'profile_requests', False)
|
||||
log_requests = getattr(args, 'log_requests', False)
|
||||
|
||||
run_server(
|
||||
host=host,
|
||||
port=port,
|
||||
debug=debug,
|
||||
config=config,
|
||||
profile_requests=profile_requests,
|
||||
log_requests=log_requests
|
||||
)
|
||||
|
||||
|
||||
def run_start(args, config: Config, data_dir: Path):
|
||||
"""Start web server (foreground or background)"""
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
host = args.host or config.web.host
|
||||
port = args.port or config.web.port
|
||||
debug = getattr(args, 'debug', False)
|
||||
profile_requests = getattr(args, 'profile_requests', False)
|
||||
log_requests = getattr(args, 'log_requests', False)
|
||||
foreground = getattr(args, 'foreground', False)
|
||||
|
||||
pid_file = get_pid_file(data_dir)
|
||||
start_file = get_start_time_file(data_dir)
|
||||
|
||||
# Check if already running (skip check if we're the spawned foreground process)
|
||||
if not foreground:
|
||||
pid = read_pid(data_dir)
|
||||
if pid and is_process_running(pid):
|
||||
print(f"RF Mapper is already running (PID: {pid})")
|
||||
print(f"Use 'rf-mapper stop' to stop it, or 'rf-mapper restart' to restart")
|
||||
sys.exit(1)
|
||||
|
||||
if foreground:
|
||||
# Run in foreground (blocking)
|
||||
from .web.app import run_server
|
||||
|
||||
# Write PID file for status command
|
||||
pid_file.write_text(str(os.getpid()))
|
||||
start_file.write_text(str(time.time()))
|
||||
|
||||
try:
|
||||
run_server(
|
||||
host=host,
|
||||
port=port,
|
||||
debug=debug,
|
||||
config=config,
|
||||
profile_requests=profile_requests,
|
||||
log_requests=log_requests
|
||||
)
|
||||
finally:
|
||||
# Cleanup PID file on exit
|
||||
pid_file.unlink(missing_ok=True)
|
||||
start_file.unlink(missing_ok=True)
|
||||
else:
|
||||
# Run in background (daemon mode)
|
||||
# Build command for subprocess
|
||||
cmd = [sys.executable, "-m", "rf_mapper", "start", "-H", host, "-p", str(port), "--foreground"]
|
||||
if debug:
|
||||
cmd.append("--debug")
|
||||
if profile_requests:
|
||||
cmd.append("--profile-requests")
|
||||
if log_requests:
|
||||
cmd.append("--log-requests")
|
||||
|
||||
# Start process in background
|
||||
log_file = data_dir / "rf-mapper.log"
|
||||
with open(log_file, 'a') as log:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=log,
|
||||
stderr=log,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Wait briefly for process to start and write PID file
|
||||
time.sleep(1)
|
||||
|
||||
# Verify it started successfully
|
||||
if not is_process_running(process.pid):
|
||||
print("RF Mapper failed to start. Check log:")
|
||||
print(f" {log_file}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"RF Mapper started (PID: {process.pid})")
|
||||
print(f" URL: http://{host}:{port}")
|
||||
print(f" Log: {log_file}")
|
||||
print(f"\nUse 'rf-mapper stop' to stop the server")
|
||||
|
||||
|
||||
def run_stop(data_dir: Path):
|
||||
"""Stop background web server"""
|
||||
import os
|
||||
import signal
|
||||
|
||||
pid = read_pid(data_dir)
|
||||
if not pid:
|
||||
print("RF Mapper is not running (no PID file)")
|
||||
sys.exit(1)
|
||||
|
||||
if not is_process_running(pid):
|
||||
print(f"RF Mapper is not running (stale PID: {pid})")
|
||||
get_pid_file(data_dir).unlink(missing_ok=True)
|
||||
get_start_time_file(data_dir).unlink(missing_ok=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Send SIGTERM
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
print(f"RF Mapper stopped (PID: {pid})")
|
||||
get_pid_file(data_dir).unlink(missing_ok=True)
|
||||
get_start_time_file(data_dir).unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
print(f"Failed to stop RF Mapper: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_restart(args, config: Config, data_dir: Path):
|
||||
"""Restart background web server"""
|
||||
import time
|
||||
|
||||
pid = read_pid(data_dir)
|
||||
if pid and is_process_running(pid):
|
||||
print("Stopping RF Mapper...")
|
||||
run_stop(data_dir)
|
||||
time.sleep(1)
|
||||
|
||||
print("Starting RF Mapper...")
|
||||
run_start(args, config, data_dir)
|
||||
|
||||
|
||||
def run_status(data_dir: Path):
|
||||
"""Check if web server is running"""
|
||||
import time
|
||||
|
||||
pid = read_pid(data_dir)
|
||||
|
||||
if not pid:
|
||||
print("RF Mapper is not running (no PID file)")
|
||||
sys.exit(1)
|
||||
|
||||
if is_process_running(pid):
|
||||
print(f"RF Mapper is running (PID: {pid})")
|
||||
|
||||
# Show uptime
|
||||
start_time = read_start_time(data_dir)
|
||||
if start_time:
|
||||
uptime_secs = time.time() - start_time
|
||||
print(f" Uptime: {format_uptime(uptime_secs)}")
|
||||
|
||||
log_file = data_dir / "rf-mapper.log"
|
||||
if log_file.exists():
|
||||
print(f" Log: {log_file}")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(f"RF Mapper is not running (stale PID: {pid})")
|
||||
get_pid_file(data_dir).unlink(missing_ok=True)
|
||||
get_start_time_file(data_dir).unlink(missing_ok=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user