from __future__ import annotations
import json
import os
import subprocess
import sys
import time
import uuid
from pathlib import Path
import click
import requests
from state import DeviceState, list_all, load, remove, save
PID_DIR = Path(__file__).parent / "pids"
PID_DIR.mkdir(exist_ok=True)
def _pid_file(alias: str) -> Path:
return PID_DIR / f"{alias}.pid"
def _is_running(alias: str) -> bool:
pid_file = _pid_file(alias)
if not pid_file.exists():
return False
pid = int(pid_file.read_text().strip())
try:
os.kill(pid, 0)
return True
except OSError:
pid_file.unlink(missing_ok=True)
return False
def _start_emulator(alias: str, host: str = "0.0.0.0", port: int | None = None) -> None:
state = load(alias)
if state is None:
click.echo(f"Device '{alias}' not found.", err=True)
sys.exit(1)
if _is_running(alias):
click.echo(f"Device '{alias}' is already running (PID {int(_pid_file(alias).read_text().strip())}).")
sys.exit(1)
cmd = [sys.executable, str(Path(__file__).parent / "emulator.py"), "--alias", alias, "--host", host]
if port is not None:
cmd += ["--port", str(port)]
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(0.5)
if proc.poll() is not None:
click.echo("Emulator failed to start.", err=True)
sys.exit(1)
_pid_file(alias).write_text(str(proc.pid))
actual_port = port or state.port
click.echo(f"Started '{alias}' on http://{host}:{actual_port} (PID {proc.pid})")
def _stop_emulator(alias: str) -> None:
pid_file = _pid_file(alias)
if not pid_file.exists():
click.echo(f"Device '{alias}' is not running.")
sys.exit(1)
pid = int(pid_file.read_text().strip())
try:
os.kill(pid, 9)
click.echo(f"Stopped '{alias}' (PID {pid}).")
except ProcessLookupError:
click.echo(f"Process {pid} not found, cleaning up.")
finally:
pid_file.unlink(missing_ok=True)
@click.group()
def cli():
"""Virtual Smart Home Device Manager."""
pass
@cli.command()
@click.option("--type", "device_type", required=True, type=click.Choice(["relay", "button"]))
@click.option("--alias", required=True, help="Unique alias (e.g. virt_relay)")
@click.option("--name", default=None, help="Human-readable name")
@click.option("--channels", default=4, type=int, help="Number of channels")
@click.option("--port", default=9001, type=int, help="HTTP port for emulator")
@click.option("--server-url", default="http://localhost", help="Server URL to send events to")
@click.option("--ip", default="127.0.0.1", help="IP address reported in /about")
def create(device_type, alias, name, channels, port, server_url, ip):
"""Create a new virtual device state file."""
if load(alias) is not None:
click.echo(f"Device '{alias}' already exists. Use a different alias or remove it first.", err=True)
sys.exit(1)
device_id = f"virt-{uuid.uuid4().hex[:12]}"
mac = f"VIRTUAL:{uuid.uuid4().hex[:6].upper()}:{uuid.uuid4().hex[:6].upper()}"
state = DeviceState(
alias=alias,
device_type=device_type,
device_id=device_id,
device_name=name or alias,
port=port,
server_url=server_url,
mac_address=mac,
channels=[{} for _ in range(channels)],
)
state.channels_schema = []
for ch in range(8):
base = ch * 4
state.channels_schema.extend([base, ch, 255, 0])
save(state)
click.echo(f"Created {device_type} '{alias}' (ID {device_id}) on port {port}.")
click.echo(f"State file: devices/{alias}.json")
@cli.command()
@click.option("--alias", required=True)
@click.option("--host", default="0.0.0.0")
@click.option("--port", type=int, default=None)
def start(alias, host, port):
"""Start the emulator for a device."""
_start_emulator(alias, host, port)
@cli.command()
@click.option("--alias", required=True)
def stop(alias):
"""Stop the emulator for a device."""
_stop_emulator(alias)
@cli.command("list")
def list_devices():
"""List all virtual devices and their running status."""
aliases = list_all()
if not aliases:
click.echo("No virtual devices found. Use 'create' to make one.")
return
click.echo(f"{'Alias':<20} {'Type':<10} {'Port':<8} {'Status'}")
click.echo("-" * 50)
for alias in aliases:
state = load(alias)
running = "running" if _is_running(alias) else "stopped"
click.echo(f"{alias:<20} {state.device_type:<10} {state.port:<8} {running}")
@cli.command()
@click.option("--alias", required=True)
def status(alias):
"""Show device state and current channels."""
state = load(alias)
if state is None:
click.echo(f"Device '{alias}' not found.", err=True)
sys.exit(1)
click.echo(json.dumps(state.to_dict(), indent=2, ensure_ascii=False))
@cli.command("click")
@click.option("--alias", required=True)
@click.option("--channel", default=0, type=int)
def click_cmd(alias, channel):
"""Simulate a button click (for button-type devices)."""
state = load(alias)
if state is None:
click.echo(f"Device '{alias}' not found.", err=True)
sys.exit(1)
url = f"http://127.0.0.1:{state.port}/simulate-event"
try:
resp = requests.post(url, json={"event_name": "click", "channel": channel}, timeout=5)
click.echo(resp.json())
except requests.RequestException as e:
click.echo(f"Failed to reach emulator: {e}", err=True)
sys.exit(1)
@cli.command()
@click.option("--alias", required=True)
@click.option("--server-url", default="http://localhost", help="PHP server base URL")
def register(alias, server_url):
"""Register the virtual device in the PHP server (setup flow)."""
state = load(alias)
if state is None:
click.echo(f"Device '{alias}' not found.", err=True)
sys.exit(1)
# Step 1: call /about to verify setup mode
about_url = f"http://127.0.0.1:{state.port}/about"
try:
resp = requests.get(about_url, timeout=5)
about = resp.json()
except requests.RequestException as e:
click.echo(f"Emulator not reachable at {about_url}: {e}", err=True)
sys.exit(1)
if about.get("status") != "setup":
click.echo("Device is not in setup mode. Reset it first.", err=True)
sys.exit(1)
# Step 2: register in PHP server via API
setup_url = f"{server_url.rstrip('/')}/api/v1/devices/setup/new-device"
payload = {
"device_ip": f"127.0.0.1:{state.port}",
"alias": alias,
"name": state.device_name,
"description": "Virtual device (emulator)",
}
try:
resp = requests.post(setup_url, json=payload, timeout=10)
result = resp.json()
except requests.RequestException as e:
click.echo(f"Server request failed: {e}", err=True)
sys.exit(1)
if result.get("status") == "ok":
click.echo(f"Device '{alias}' registered successfully.")
click.echo(json.dumps(result.get("data", {}).get("device", {}), indent=2, ensure_ascii=False))
else:
click.echo(f"Registration failed: {result}", err=True)
sys.exit(1)
@cli.command("remove")
@click.option("--alias", required=True)
def remove_cmd(alias):
"""Remove a virtual device state file."""
if _is_running(alias):
click.echo(f"Device '{alias}' is running. Stop it first.", err=True)
sys.exit(1)
if remove(alias):
click.echo(f"Removed device '{alias}'.")
else:
click.echo(f"Device '{alias}' not found.", err=True)
sys.exit(1)
if __name__ == "__main__":
cli()