2 Commits

Author SHA1 Message Date
bf832b49f4 Filter autopilot actuator candidates 2026-06-18 18:43:53 +02:00
8057751c11 Filter autopilot trigger candidates 2026-06-18 17:30:48 +02:00
6 changed files with 79 additions and 9 deletions

View File

@@ -2,7 +2,7 @@ FROM python:3.13-slim
WORKDIR /app WORKDIR /app
ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.13 ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.15
RUN python -m pip install --no-cache-dir \ RUN python -m pip install --no-cache-dir \
"http://192.168.6.31:3000/Otto/sillyhome-future/archive/${SILLYHOME_FUTURE_REF}.tar.gz" "http://192.168.6.31:3000/Otto/sillyhome-future/archive/${SILLYHOME_FUTURE_REF}.tar.gz"

View File

@@ -1,5 +1,5 @@
name: SillyHome Future name: SillyHome Future
version: "2.0.0-alpha.13" version: "2.0.0-alpha.15"
slug: sillyhome_future slug: sillyhome_future
description: Event-first SillyHome v2 test controller description: Event-first SillyHome v2 test controller
url: http://192.168.6.31:3000/Otto/sillyhome-future url: http://192.168.6.31:3000/Otto/sillyhome-future

View File

@@ -155,7 +155,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI( app = FastAPI(
title="SillyHome Future API", title="SillyHome Future API",
description="SillyHome v2 event-core side project.", description="SillyHome v2 event-core side project.",
version="2.0.0-alpha.13", version="2.0.0-alpha.15",
lifespan=lifespan, lifespan=lifespan,
) )
@@ -199,7 +199,7 @@ def dashboard_data() -> dict[str, object]:
actuator_entities = [ actuator_entities = [
entity entity
for entity in runtime.entities.values() for entity in runtime.entities.values()
if entity.domain in {"light", "switch", "fan", "cover", "humidifier"} if _is_good_actuator(entity.entity_id, entity.friendly_name)
] ]
return { return {
"websocket_status": runtime.websocket_status, "websocket_status": runtime.websocket_status,
@@ -951,6 +951,12 @@ def _run_autopilot_once() -> AutopilotRunResult:
if not settings.enabled: if not settings.enabled:
return AutopilotRunResult(candidates=[], trained_models=[], evaluations=[]) return AutopilotRunResult(candidates=[], trained_models=[], evaluations=[])
learning = stores.learning() learning = stores.learning()
learning.candidates = {
candidate_id: candidate
for candidate_id, candidate in learning.candidates.items()
if _is_good_actuator(candidate.actuator_entity_id, None)
and _is_good_trigger_id(candidate.trigger_entity_id)
}
candidates = _generate_candidates(settings) candidates = _generate_candidates(settings)
for candidate in candidates: for candidate in candidates:
learning.candidates[candidate.candidate_id] = candidate learning.candidates[candidate.candidate_id] = candidate
@@ -969,12 +975,12 @@ def _generate_candidates(settings: AutopilotSettings) -> list[CandidateRecommend
actuators = [ actuators = [
entity entity
for entity in runtime.entities.values() for entity in runtime.entities.values()
if entity.domain in {"light", "switch", "fan", "cover", "humidifier"} if _is_good_actuator(entity.entity_id, entity.friendly_name)
][:150] ][:150]
triggers = [ triggers = [
entity entity
for entity in runtime.entities.values() for entity in runtime.entities.values()
if entity.domain in {"binary_sensor", "sensor"} if entity.domain == "binary_sensor" and _is_good_trigger_id(entity.entity_id)
][:500] ][:500]
result: list[CandidateRecommendation] = [] result: list[CandidateRecommendation] = []
for actuator in actuators: for actuator in actuators:
@@ -1023,6 +1029,60 @@ def _candidate_score(actuator: EntityState, trigger: EntityState) -> tuple[float
return 0.0, "" return 0.0, ""
def _is_good_trigger_id(entity_id: str) -> bool:
raw = entity_id.lower()
bad = {
"battery",
"batterie",
"low",
"update",
"problem",
"connectivity",
"linkquality",
"tamper",
}
good = {
"door",
"tuer",
"ture",
"window",
"fenster",
"motion",
"pir",
"occupancy",
"presence",
"kontakt",
"contact",
}
return not any(token in raw for token in bad) and any(token in raw for token in good)
def _is_good_actuator(entity_id: str, friendly_name: str | None) -> bool:
domain = entity_id.split(".", 1)[0]
if domain not in {"light", "switch", "fan", "cover", "humidifier"}:
return False
if domain != "switch":
return True
raw = f"{entity_id} {friendly_name or ''}".lower()
bad = {
"alarm",
"battery",
"batterie",
"detection",
"linkquality",
"low",
"motion",
"occupancy",
"people",
"presence",
"problem",
"tamper",
"trigger",
"update",
}
return not any(token in raw for token in bad)
def _entity_tokens(entity: EntityState) -> set[str]: def _entity_tokens(entity: EntityState) -> set[str]:
raw = f"{entity.entity_id} {entity.friendly_name or ''}".lower() raw = f"{entity.entity_id} {entity.friendly_name or ''}".lower()
return {part for part in raw.replace(".", "_").split("_") if len(part) >= 4} return {part for part in raw.replace(".", "_").split("_") if len(part) >= 4}

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-future" name = "sillyhome-future"
version = "2.0.0-alpha.13" version = "2.0.0-alpha.15"
description = "SillyHome v2 event-core prototype" description = "SillyHome v2 event-core prototype"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View File

@@ -9,7 +9,7 @@ def test_addon_config_declares_future_addon() -> None:
config = yaml.safe_load(Path("addon/config.yaml").read_text(encoding="utf-8")) config = yaml.safe_load(Path("addon/config.yaml").read_text(encoding="utf-8"))
assert config["slug"] == "sillyhome_future" assert config["slug"] == "sillyhome_future"
assert config["version"] == "2.0.0-alpha.13" assert config["version"] == "2.0.0-alpha.15"
assert config["ingress"] is True assert config["ingress"] is True
assert config["ingress_port"] == 8099 assert config["ingress_port"] == 8099
assert config["homeassistant_api"] is True assert config["homeassistant_api"] is True

View File

@@ -19,7 +19,7 @@ def test_health_and_backup_roundtrip(tmp_path, monkeypatch) -> None: # type: ig
restore = client.post("/v2/backup/restore", json=backup.json()) restore = client.post("/v2/backup/restore", json=backup.json())
assert health.status_code == 200 assert health.status_code == 200
assert health.json()["version"] == "2.0.0-alpha.13" assert health.json()["version"] == "2.0.0-alpha.15"
assert backup.status_code == 200 assert backup.status_code == 200
assert restore.status_code == 200 assert restore.status_code == 200
assert restore.json() == {"status": "restored"} assert restore.json() == {"status": "restored"}
@@ -47,6 +47,12 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
state="off", state="off",
area_name="Storage", area_name="Storage",
), ),
"switch.storage_motion": EntityState(
entity_id="switch.storage_motion",
domain="switch",
state="off",
area_name="Storage",
),
} }
) )
) )
@@ -162,6 +168,10 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
assert evaluations.json() assert evaluations.json()
assert autopilot.status_code == 200 assert autopilot.status_code == 200
assert autopilot.json()["candidates"] assert autopilot.json()["candidates"]
assert all(
candidate["actuator_entity_id"] != "switch.storage_motion"
for candidate in autopilot.json()["candidates"]
)
assert candidates.status_code == 200 assert candidates.status_code == 200
assert accepted.status_code == 200 assert accepted.status_code == 200
assert jobs.status_code == 200 assert jobs.status_code == 200