feat: CLI commands for device management, broker monitoring, and serve
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
248
src/mqtt_home/cli.py
Normal file
248
src/mqtt_home/cli.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import asyncio
|
||||||
|
import click
|
||||||
|
|
||||||
|
from mqtt_home.config import get_settings
|
||||||
|
from mqtt_home.database import init_db, get_session_factory
|
||||||
|
from mqtt_home.emqx_api import EmqxApiClient
|
||||||
|
from mqtt_home.device_registry import (
|
||||||
|
list_devices, get_device, create_device, delete_device,
|
||||||
|
send_command, get_device_logs,
|
||||||
|
)
|
||||||
|
from mqtt_home.schemas import DeviceCreate
|
||||||
|
from mqtt_home.mqtt_client import MqttClient
|
||||||
|
|
||||||
|
|
||||||
|
def run_async(coro):
|
||||||
|
asyncio.run(coro)
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
def cli():
|
||||||
|
"""MQTT 智能家居管理工具"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def device():
|
||||||
|
"""设备管理"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@device.command("list")
|
||||||
|
def device_list():
|
||||||
|
"""列出所有设备"""
|
||||||
|
async def _run():
|
||||||
|
await init_db()
|
||||||
|
factory = get_session_factory()
|
||||||
|
async with factory() as db:
|
||||||
|
devices = await list_devices(db)
|
||||||
|
if not devices:
|
||||||
|
click.echo("暂无设备")
|
||||||
|
return
|
||||||
|
for d in devices:
|
||||||
|
status = "ON " if d.is_online else "OFF"
|
||||||
|
state = d.state or "-"
|
||||||
|
click.echo(f"[{status}] [{d.id[:8]}] {d.name} ({d.type}) state={state}")
|
||||||
|
|
||||||
|
run_async(_run())
|
||||||
|
|
||||||
|
|
||||||
|
@device.command("add")
|
||||||
|
@click.option("--name", required=True, help="设备名称")
|
||||||
|
@click.option("--type", "device_type", default="switch", help="设备类型")
|
||||||
|
@click.option("--state-topic", required=True, help="状态主题")
|
||||||
|
@click.option("--command-topic", default=None, help="命令主题")
|
||||||
|
def device_add(name, device_type, state_topic, command_topic):
|
||||||
|
"""手动添加设备"""
|
||||||
|
async def _run():
|
||||||
|
await init_db()
|
||||||
|
factory = get_session_factory()
|
||||||
|
async with factory() as db:
|
||||||
|
d = await create_device(db, DeviceCreate(
|
||||||
|
name=name,
|
||||||
|
type=device_type,
|
||||||
|
mqtt_topic=state_topic,
|
||||||
|
command_topic=command_topic,
|
||||||
|
))
|
||||||
|
click.echo(f"设备已创建: {d.id} - {d.name}")
|
||||||
|
|
||||||
|
run_async(_run())
|
||||||
|
|
||||||
|
|
||||||
|
@device.command("info")
|
||||||
|
@click.argument("device_id")
|
||||||
|
def device_info(device_id):
|
||||||
|
"""查看设备详情"""
|
||||||
|
async def _run():
|
||||||
|
await init_db()
|
||||||
|
factory = get_session_factory()
|
||||||
|
async with factory() as db:
|
||||||
|
d = await get_device(db, device_id)
|
||||||
|
if not d:
|
||||||
|
click.echo(f"设备不存在: {device_id}", err=True)
|
||||||
|
return
|
||||||
|
click.echo(f"ID: {d.id}")
|
||||||
|
click.echo(f"名称: {d.name}")
|
||||||
|
click.echo(f"类型: {d.type}")
|
||||||
|
click.echo(f"协议: {d.protocol}")
|
||||||
|
click.echo(f"状态主题: {d.mqtt_topic}")
|
||||||
|
click.echo(f"命令主题: {d.command_topic or '无'}")
|
||||||
|
click.echo(f"当前状态: {d.state or '无'}")
|
||||||
|
click.echo(f"在线: {'是' if d.is_online else '否'}")
|
||||||
|
click.echo(f"最后活跃: {d.last_seen or '从未'}")
|
||||||
|
|
||||||
|
run_async(_run())
|
||||||
|
|
||||||
|
|
||||||
|
@device.command("remove")
|
||||||
|
@click.argument("device_id")
|
||||||
|
def device_remove(device_id):
|
||||||
|
"""删除设备"""
|
||||||
|
async def _run():
|
||||||
|
await init_db()
|
||||||
|
factory = get_session_factory()
|
||||||
|
async with factory() as db:
|
||||||
|
if await delete_device(db, device_id):
|
||||||
|
click.echo(f"设备已删除: {device_id}")
|
||||||
|
else:
|
||||||
|
click.echo(f"设备不存在: {device_id}", err=True)
|
||||||
|
|
||||||
|
run_async(_run())
|
||||||
|
|
||||||
|
|
||||||
|
@device.command("command")
|
||||||
|
@click.argument("device_id")
|
||||||
|
@click.option("--payload", required=True, help="命令内容(JSON)")
|
||||||
|
def device_command(device_id, payload):
|
||||||
|
"""向设备发送命令"""
|
||||||
|
async def _run():
|
||||||
|
settings = get_settings()
|
||||||
|
await init_db()
|
||||||
|
factory = get_session_factory()
|
||||||
|
|
||||||
|
import aiomqtt
|
||||||
|
async with aiomqtt.Client(
|
||||||
|
hostname=settings.mqtt_host,
|
||||||
|
port=settings.mqtt_port,
|
||||||
|
username=settings.mqtt_username or None,
|
||||||
|
password=settings.mqtt_password or None,
|
||||||
|
) as client:
|
||||||
|
async def publish_fn(topic, p):
|
||||||
|
await client.publish(topic, p, qos=1)
|
||||||
|
|
||||||
|
async with factory() as db:
|
||||||
|
try:
|
||||||
|
log = await send_command(db, device_id, payload, publish_fn=publish_fn)
|
||||||
|
if log:
|
||||||
|
click.echo(f"命令已发送到 {log.topic}: {log.payload}")
|
||||||
|
else:
|
||||||
|
click.echo(f"设备不存在: {device_id}", err=True)
|
||||||
|
except ValueError as e:
|
||||||
|
click.echo(str(e), err=True)
|
||||||
|
|
||||||
|
run_async(_run())
|
||||||
|
|
||||||
|
|
||||||
|
@device.command("logs")
|
||||||
|
@click.argument("device_id")
|
||||||
|
@click.option("--limit", default=20, help="日志条数")
|
||||||
|
def device_logs(device_id, limit):
|
||||||
|
"""查看设备消息日志"""
|
||||||
|
async def _run():
|
||||||
|
await init_db()
|
||||||
|
factory = get_session_factory()
|
||||||
|
async with factory() as db:
|
||||||
|
logs = await get_device_logs(db, device_id, limit)
|
||||||
|
if not logs:
|
||||||
|
click.echo("暂无日志")
|
||||||
|
return
|
||||||
|
for log in logs:
|
||||||
|
direction = "RX" if log.direction == "rx" else "TX"
|
||||||
|
click.echo(f"[{log.timestamp}] {direction} {log.topic} | {log.payload}")
|
||||||
|
|
||||||
|
run_async(_run())
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def broker():
|
||||||
|
"""Broker 管理"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@broker.command("status")
|
||||||
|
def broker_status():
|
||||||
|
"""查看 Broker 状态"""
|
||||||
|
async def _run():
|
||||||
|
settings = get_settings()
|
||||||
|
emqx = EmqxApiClient(settings)
|
||||||
|
try:
|
||||||
|
status = await emqx.get_broker_status()
|
||||||
|
click.echo(f"版本: {status.get('version', 'unknown')}")
|
||||||
|
click.echo(f"运行时间: {status.get('uptime', 0)}s")
|
||||||
|
metrics = await emqx.get_metrics()
|
||||||
|
click.echo(f"连接数: {metrics.get('connections.count', 0)}")
|
||||||
|
click.echo(f"订阅数: {metrics.get('subscriptions.count', 0)}")
|
||||||
|
click.echo(f"主题数: {metrics.get('topics.count', 0)}")
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"连接 EMQX 失败: {e}", err=True)
|
||||||
|
finally:
|
||||||
|
await emqx.close()
|
||||||
|
|
||||||
|
run_async(_run())
|
||||||
|
|
||||||
|
|
||||||
|
@broker.command("clients")
|
||||||
|
def broker_clients():
|
||||||
|
"""列出已连接客户端"""
|
||||||
|
async def _run():
|
||||||
|
settings = get_settings()
|
||||||
|
emqx = EmqxApiClient(settings)
|
||||||
|
try:
|
||||||
|
clients = await emqx.get_clients()
|
||||||
|
if not clients:
|
||||||
|
click.echo("暂无客户端连接")
|
||||||
|
return
|
||||||
|
for c in clients:
|
||||||
|
status = "在线" if c.get("connected") else "离线"
|
||||||
|
click.echo(f"[{c.get('clientid')}] {status} ip={c.get('ip_address')}")
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"连接 EMQX 失败: {e}", err=True)
|
||||||
|
finally:
|
||||||
|
await emqx.close()
|
||||||
|
|
||||||
|
run_async(_run())
|
||||||
|
|
||||||
|
|
||||||
|
@broker.command("topics")
|
||||||
|
def broker_topics():
|
||||||
|
"""列出活跃主题"""
|
||||||
|
async def _run():
|
||||||
|
settings = get_settings()
|
||||||
|
emqx = EmqxApiClient(settings)
|
||||||
|
try:
|
||||||
|
topics = await emqx.get_topics()
|
||||||
|
if not topics:
|
||||||
|
click.echo("暂无活跃主题")
|
||||||
|
return
|
||||||
|
for t in topics:
|
||||||
|
click.echo(t.get("topic", ""))
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"连接 EMQX 失败: {e}", err=True)
|
||||||
|
finally:
|
||||||
|
await emqx.close()
|
||||||
|
|
||||||
|
run_async(_run())
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("serve")
|
||||||
|
def serve():
|
||||||
|
"""启动 Web 服务和 MQTT 客户端"""
|
||||||
|
import uvicorn
|
||||||
|
from mqtt_home.config import get_settings
|
||||||
|
settings = get_settings()
|
||||||
|
uvicorn.run(
|
||||||
|
"mqtt_home.main:app",
|
||||||
|
host=settings.web_host,
|
||||||
|
port=settings.web_port,
|
||||||
|
reload=False,
|
||||||
|
)
|
||||||
27
tests/test_cli.py
Normal file
27
tests/test_cli.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
from mqtt_home.cli import cli
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_list_empty(monkeypatch):
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
async def mock_init():
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def mock_list(db):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr("mqtt_home.cli.init_db", mock_init)
|
||||||
|
monkeypatch.setattr("mqtt_home.cli.list_devices", mock_list)
|
||||||
|
|
||||||
|
result = runner.invoke(cli, ["device", "list"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "暂无设备" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_groups():
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["--help"])
|
||||||
|
assert "device" in result.output
|
||||||
|
assert "broker" in result.output
|
||||||
Reference in New Issue
Block a user