diff --git a/CHANGELOG.md b/CHANGELOG.md index db29c11..71c9458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.5.3 - 2026-06-14 +- Verhindert fachlich falsche Sensorzuordnungen nur aufgrund generischer Namen wie + `Licht` oder `Lichtschalter` +- Übernimmt numerische Sensoren nur noch bei einem belastbaren absoluten Score und + einer eindeutigen Abgrenzung zum zweitbesten Kandidaten +- Begrenzt Zusatzkontext auf relevante Sensoren und bevorzugt bei Lichtaktoren + echte Beleuchtungsstärke gegenüber fremden Leistungs- oder Energiezählern + ## 0.5.2 - 2026-06-14 - Add-on-Build invalidiert den Docker-Cache bei jeder Versionsänderung, damit Versionsmetadaten und tatsächlich ausgelieferter Anwendungscode übereinstimmen diff --git a/addon/config.yaml b/addon/config.yaml index 7ed818d..0339bd9 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "0.5.2" +version: "0.5.3" slug: sillyhome_next description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren url: http://192.168.6.31:3000/pino/sillyhome-next diff --git a/app/actuators/lifecycle.py b/app/actuators/lifecycle.py index 7427552..05ae1de 100644 --- a/app/actuators/lifecycle.py +++ b/app/actuators/lifecycle.py @@ -45,6 +45,8 @@ _STOPWORDS = frozenset( "humidity", "illuminance", "light", + "licht", + "lichtschalter", "power", "sensor", "state", @@ -54,8 +56,10 @@ _STOPWORDS = frozenset( } ) _NUMERIC_AUTO_ACCEPT_SCORE = 0.82 +_NUMERIC_AUTO_ACCEPT_MIN_SCORE = 0.5 _NUMERIC_MIN_MARGIN = 0.18 _CONTEXT_AUTO_ACCEPT_SCORE = 0.78 +_CONTEXT_AUTO_ACCEPT_MIN_SCORE = 0.3 _MAX_CONTEXT_SELECTIONS = 5 _AUDIT_LIMIT = 20 @@ -231,10 +235,14 @@ class ActuatorReconciliationService: numeric_candidates: list[AssignmentCandidate], context_candidates: list[AssignmentCandidate], ) -> AssignmentSelection: - top_numeric = numeric_candidates[0] if numeric_candidates else None + top_numeric = next( + (candidate for candidate in numeric_candidates if candidate.auto_accepted), + None, + ) top_contexts = [ candidate.entity_id for candidate in context_candidates + if candidate.auto_accepted ][: _MAX_CONTEXT_SELECTIONS] if top_numeric is None: return AssignmentSelection( @@ -433,8 +441,15 @@ class ActuatorReconciliationService: confidence = candidate.score / highest if highest else 0.0 margin = candidate.score - second_score if index == 0 else 0.0 auto_score = _CONTEXT_AUTO_ACCEPT_SCORE if context else _NUMERIC_AUTO_ACCEPT_SCORE - auto_accepted = confidence >= auto_score and ( - context or margin >= _NUMERIC_MIN_MARGIN + minimum_score = ( + _CONTEXT_AUTO_ACCEPT_MIN_SCORE + if context + else _NUMERIC_AUTO_ACCEPT_MIN_SCORE + ) + auto_accepted = ( + candidate.score >= minimum_score + and confidence >= auto_score + and (context or margin >= _NUMERIC_MIN_MARGIN) ) sorted_candidates[index] = candidate.model_copy( update={ @@ -510,6 +525,9 @@ def _score_candidate( if entity.device_class in preferred_device_classes: score += 0.2 evidence.append(f"Passende device_class: {entity.device_class}") + if not context and actuator.domain == "light" and entity.device_class == "illuminance": + score += 0.2 + evidence.append("Beleuchtungsstärke wird für Lichtaktoren bevorzugt.") if not context and entity.unit_of_measurement is not None: score += 0.05 evidence.append(f"Numerische Einheit vorhanden: {entity.unit_of_measurement}") diff --git a/tests/actuators/test_lifecycle.py b/tests/actuators/test_lifecycle.py index cf29a52..23341c0 100644 --- a/tests/actuators/test_lifecycle.py +++ b/tests/actuators/test_lifecycle.py @@ -141,7 +141,7 @@ def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) -> assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors -def test_reconciliation_uses_best_automatic_mapping_when_ambiguous(tmp_path: Path) -> None: +def test_reconciliation_rejects_ambiguous_numeric_mapping(tmp_path: Path) -> None: start = datetime(2026, 6, 1, tzinfo=timezone.utc) entities = [ HaEntitySummary( @@ -181,8 +181,59 @@ def test_reconciliation_uses_best_automatic_mapping_when_ambiguous(tmp_path: Pat record = service.configure_actuator("switch.garage_pump") assert record.assignment.review_required is True - assert record.assignment.selected_numeric_entity_id == "sensor.garage_energy" - assert record.lifecycle.status is LifecycleStatus.TRAINED + assert record.assignment.selected_numeric_entity_id is None + assert record.lifecycle.status is LifecycleStatus.ARCHIVED + + +def test_reconciliation_does_not_cross_assign_other_room_light_energy( + tmp_path: Path, +) -> None: + start = datetime(2026, 6, 1, tzinfo=timezone.utc) + entities = [ + HaEntitySummary( + entity_id=( + "light.lichtschalter_abstellraum_" + "lichtschalter_abstellraum_s1" + ), + domain="light", + friendly_name="Licht Abstellraum", + ), + HaEntitySummary( + entity_id="sensor.licht_badezimmer_energy", + domain="sensor", + device_class="energy", + state_class="total_increasing", + unit_of_measurement="kWh", + friendly_name="Lichtschalter_Badezimmer Licht Badezimmer energy", + ), + HaEntitySummary( + entity_id="binary_sensor.abstellraum_ture", + domain="binary_sensor", + device_class="door", + friendly_name="Abstellraum Türe", + ), + HaEntitySummary( + entity_id="binary_sensor.briefkasten_open", + domain="binary_sensor", + device_class="opening", + friendly_name="Briefkasten open", + ), + ] + service = _service( + tmp_path, + entities, + {"sensor.licht_badezimmer_energy": _points(8, start, 1.0)}, + ) + + record = service.configure_actuator( + "light.lichtschalter_abstellraum_lichtschalter_abstellraum_s1" + ) + + assert record.assignment.selected_numeric_entity_id is None + assert record.assignment.selected_context_entity_ids == [ + "binary_sensor.abstellraum_ture" + ] + assert record.lifecycle.status is LifecycleStatus.ARCHIVED def test_legacy_manual_override_is_cleared_and_automatic_mapping_wins(tmp_path: Path) -> None: