feat(规则管理): 添加主题规则自动发现设备功能
实现设备自动发现规则管理系统,包含以下主要功能: 1. 新增规则管理页面和API,支持创建、编辑和删除主题匹配规则 2. 添加规则匹配引擎,支持+和#通配符匹配设备主题 3. 实现Broker设备注册表,自动发现并管理符合规则的设备 4. 扩展仪表盘显示Broker信息和活跃主题 5. 修改设备卡片和详情页以区分规则发现的设备 6. 添加相关测试用例确保功能稳定性
This commit is contained in:
86
tests/test_broker_devices.py
Normal file
86
tests/test_broker_devices.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from types import SimpleNamespace
|
||||
from mqtt_home.broker_devices import BrokerDevice, BrokerDeviceRegistry
|
||||
|
||||
|
||||
def make_rule(id=1, topic_pattern="home/+/state", device_type="switch",
|
||||
command_template="home/{device_id}/set", state_value_path="state",
|
||||
is_enabled=True):
|
||||
return SimpleNamespace(
|
||||
id=id, topic_pattern=topic_pattern, device_type=device_type,
|
||||
command_template=command_template, state_value_path=state_value_path,
|
||||
is_enabled=is_enabled,
|
||||
)
|
||||
|
||||
|
||||
def test_create_device_on_match():
|
||||
registry = BrokerDeviceRegistry()
|
||||
rule = make_rule()
|
||||
device = registry.update_or_create("home/fire/state", '{"state":"on"}', rule)
|
||||
assert device is not None
|
||||
assert device.id == "broker:fire"
|
||||
assert device.name == "fire"
|
||||
assert device.type == "switch"
|
||||
assert device.state == "on"
|
||||
assert device.command_topic == "home/fire/set"
|
||||
assert device.is_online is True
|
||||
assert device.rule_id == 1
|
||||
|
||||
|
||||
def test_update_existing_device():
|
||||
registry = BrokerDeviceRegistry()
|
||||
rule = make_rule()
|
||||
registry.update_or_create("home/fire/state", '{"state":"on"}', rule)
|
||||
device = registry.update_or_create("home/fire/state", '{"state":"off"}', rule)
|
||||
assert device.state == "off"
|
||||
assert len(registry.get_all()) == 1
|
||||
|
||||
|
||||
def test_different_topics_different_devices():
|
||||
registry = BrokerDeviceRegistry()
|
||||
rule = make_rule()
|
||||
registry.update_or_create("home/fire/state", '{"state":"on"}', rule)
|
||||
registry.update_or_create("home/servo/state", '{"state":"off"}', rule)
|
||||
assert len(registry.get_all()) == 2
|
||||
|
||||
|
||||
def test_no_match_returns_none():
|
||||
registry = BrokerDeviceRegistry()
|
||||
rule = make_rule(topic_pattern="home/+/state")
|
||||
result = registry.update_or_create("living/light/status", '{"state":"on"}', rule)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_remove_by_rule():
|
||||
registry = BrokerDeviceRegistry()
|
||||
rule1 = make_rule(id=1)
|
||||
rule2 = make_rule(id=2, topic_pattern="sensor/+/data")
|
||||
registry.update_or_create("home/fire/state", '{"state":"on"}', rule1)
|
||||
registry.update_or_create("sensor/temp/data", '{"value":25}', rule2)
|
||||
assert len(registry.get_all()) == 2
|
||||
removed = registry.remove_by_rule(1)
|
||||
assert removed == ["broker:fire"]
|
||||
assert len(registry.get_all()) == 1
|
||||
assert registry.get("broker:fire") is None
|
||||
|
||||
|
||||
def test_full_payload_as_state():
|
||||
registry = BrokerDeviceRegistry()
|
||||
rule = make_rule(state_value_path=None)
|
||||
device = registry.update_or_create("home/fire/state", '{"state":"on","brightness":128}', rule)
|
||||
assert device.state == '{"state":"on","brightness":128}'
|
||||
|
||||
|
||||
def test_get_device():
|
||||
registry = BrokerDeviceRegistry()
|
||||
rule = make_rule()
|
||||
registry.update_or_create("home/fire/state", '{"state":"on"}', rule)
|
||||
assert registry.get("broker:fire") is not None
|
||||
assert registry.get("broker:nonexistent") is None
|
||||
|
||||
|
||||
def test_clear():
|
||||
registry = BrokerDeviceRegistry()
|
||||
rule = make_rule()
|
||||
registry.update_or_create("home/fire/state", '{"state":"on"}', rule)
|
||||
registry.clear()
|
||||
assert len(registry.get_all()) == 0
|
||||
66
tests/test_rule_registry.py
Normal file
66
tests/test_rule_registry.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import pytest
|
||||
from mqtt_home.rule_registry import list_rules, get_rule, create_rule, update_rule, delete_rule
|
||||
from mqtt_home.schemas import RuleCreate, RuleUpdate
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rule(db_session):
|
||||
rule = await create_rule(db_session, RuleCreate(
|
||||
name="Test Rule",
|
||||
topic_pattern="home/+/state",
|
||||
device_type="switch",
|
||||
command_template="home/{device_id}/set",
|
||||
state_value_path="state",
|
||||
))
|
||||
assert rule.id is not None
|
||||
assert rule.name == "Test Rule"
|
||||
assert rule.topic_pattern == "home/+/state"
|
||||
assert rule.is_enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_rules(db_session):
|
||||
await create_rule(db_session, RuleCreate(name="Rule 1", topic_pattern="a/+"))
|
||||
await create_rule(db_session, RuleCreate(name="Rule 2", topic_pattern="b/+"))
|
||||
rules = await list_rules(db_session)
|
||||
assert len(rules) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_rule(db_session):
|
||||
created = await create_rule(db_session, RuleCreate(name="Test", topic_pattern="x/+"))
|
||||
rule = await get_rule(db_session, created.id)
|
||||
assert rule is not None
|
||||
assert rule.name == "Test"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_rule_not_found(db_session):
|
||||
rule = await get_rule(db_session, 99999)
|
||||
assert rule is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_rule(db_session):
|
||||
created = await create_rule(db_session, RuleCreate(name="Original", topic_pattern="x/+"))
|
||||
updated = await update_rule(db_session, created.id, RuleUpdate(name="Updated"))
|
||||
assert updated.name == "Updated"
|
||||
assert updated.topic_pattern == "x/+" # unchanged
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_rule_not_found(db_session):
|
||||
result = await update_rule(db_session, 99999, RuleUpdate(name="X"))
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_rule(db_session):
|
||||
created = await create_rule(db_session, RuleCreate(name="To Delete", topic_pattern="x/+"))
|
||||
assert await delete_rule(db_session, created.id) is True
|
||||
assert await get_rule(db_session, created.id) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_rule_not_found(db_session):
|
||||
assert await delete_rule(db_session, 99999) is False
|
||||
74
tests/test_topic_matcher.py
Normal file
74
tests/test_topic_matcher.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from mqtt_home.topic_matcher import match_topic, extract_device_id, build_command_topic, extract_state_value
|
||||
|
||||
|
||||
def test_match_topic_single_plus():
|
||||
assert match_topic("home/fire/state", "home/+/state") == {"1": "fire"}
|
||||
|
||||
|
||||
def test_match_topic_no_match():
|
||||
assert match_topic("home/fire/state", "home/living/state") is None
|
||||
|
||||
|
||||
def test_match_topic_hash():
|
||||
# # at end matches remaining segments
|
||||
result = match_topic("home/fire/brightness", "home/fire/#")
|
||||
assert result is not None
|
||||
assert "2" in result # # is at index 2
|
||||
|
||||
|
||||
def test_match_topic_multiple_plus():
|
||||
assert match_topic("home/fire/living/state", "home/+/+/state") == {"1": "fire", "2": "living"}
|
||||
|
||||
|
||||
def test_match_topic_hash_matches_empty():
|
||||
assert match_topic("home/fire/", "home/fire/#") is not None
|
||||
|
||||
|
||||
def test_extract_device_id_single_plus():
|
||||
assert extract_device_id("home/fire/state", "home/+/state") == "fire"
|
||||
|
||||
|
||||
def test_extract_device_id_no_match():
|
||||
assert extract_device_id("home/fire/state", "home/living/state") is None
|
||||
|
||||
|
||||
def test_extract_device_id_hash():
|
||||
assert extract_device_id("home/fire/brightness", "home/fire/#") == "brightness"
|
||||
|
||||
|
||||
def test_extract_device_id_multiple_plus():
|
||||
# Returns last + match
|
||||
assert extract_device_id("home/fire/living/state", "home/+/+/state") == "living"
|
||||
|
||||
|
||||
def test_build_command_topic_basic():
|
||||
assert build_command_topic("home/{device_id}/set", "fire") == "home/fire/set"
|
||||
|
||||
|
||||
def test_build_command_topic_none():
|
||||
assert build_command_topic(None, "fire") is None
|
||||
assert build_command_topic("", "fire") is None
|
||||
|
||||
|
||||
def test_extract_state_value_json_path():
|
||||
assert extract_state_value('{"state":"on"}', "state") == "on"
|
||||
|
||||
|
||||
def test_extract_state_value_no_path():
|
||||
assert extract_state_value('{"state":"on"}', None) == '{"state":"on"}'
|
||||
|
||||
|
||||
def test_extract_state_value_plain_text():
|
||||
assert extract_state_value("plain text", "state") == "plain text"
|
||||
|
||||
|
||||
def test_extract_state_value_nested_path():
|
||||
assert extract_state_value('{"brightness":255}', "brightness") == "255"
|
||||
|
||||
|
||||
def test_extract_state_value_missing_key():
|
||||
assert extract_state_value('{"temperature":22}', "humidity") == '{"temperature":22}'
|
||||
|
||||
|
||||
def test_extract_state_value_numeric_value():
|
||||
assert extract_state_value('{"brightness":255}', "brightness") == "255"
|
||||
Reference in New Issue
Block a user