Compare commits

...

5 Commits

Author SHA1 Message Date
100f5af578 UI-002: align context and activation status
Some checks failed
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
2026-06-14 15:28:52 +02:00
ede6b87dbd Merge pull request 'v0.5.3: sichere Sensorzuordnung für Aktoren' (#35) from fix/sensor-assignment-v0.5.3 into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 15:19:55 +02:00
47e8c7e549 ASSIGN-001: reject unrelated actuator sensors
Some checks failed
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
2026-06-14 15:19:30 +02:00
ef7e0c5600 Merge pull request 'v0.5.2: Add-on-Build liefert zuverlässig aktuellen Code' (#34) from fix/addon-cache-v0.5.2 into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 11:38:51 +02:00
8d070fc9ca BUILD-001: invalidate addon application cache per release
Some checks failed
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
2026-06-14 11:38:36 +02:00
10 changed files with 145 additions and 14 deletions

View File

@@ -1,5 +1,26 @@
# Changelog # Changelog
## 0.5.4 - 2026-06-14
- Tür-, Bewegungs- und andere belastbare Kontextsensoren werden auch ohne
numerischen Sensor als vollständige automatische Kontextzuordnung angezeigt
- Status und Zuordnungssicherheit bilden das aktive Verhaltenslernen ab statt
eines optionalen numerischen Modells
- Ausführungsfreigabe erscheint erst, wenn genügend eindeutig manuelle
Bedienungen vorliegen; bis dahin nennt die Oberfläche die noch fehlende Anzahl
## 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
- Korrigierte Ingress-Oberfläche aus 0.5.1 dadurch erstmals zuverlässig ausgeliefert
## 0.5.1 - 2026-06-14 ## 0.5.1 - 2026-06-14
- Technische Modell-, Intervall- und Sicherheitsparameter aus der normalen - Technische Modell-, Intervall- und Sicherheitsparameter aus der normalen
Home-Assistant-Add-on-Konfiguration entfernt; sichere Standardwerte bleiben aktiv Home-Assistant-Add-on-Konfiguration entfernt; sichere Standardwerte bleiben aktiv

View File

@@ -4,13 +4,17 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 PIP_NO_CACHE_DIR=1
# The add-on version changes for every release. Copying its config before the
# clone makes Docker invalidate the application layer instead of reusing old code.
COPY config.yaml /tmp/addon-config.yaml
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends git \ && apt-get install -y --no-install-recommends git \
&& git clone --depth 1 --branch main \ && git clone --depth 1 --branch main \
http://192.168.6.31:3000/pino/sillyhome-next.git /app \ http://192.168.6.31:3000/pino/sillyhome-next.git /app \
&& python -m pip install --upgrade pip \ && python -m pip install --upgrade pip \
&& python -m pip install /app \ && python -m pip install /app \
&& rm -rf /var/lib/apt/lists/* /app/.git && rm -rf /var/lib/apt/lists/* /app/.git /tmp/addon-config.yaml
COPY run.sh /run.sh COPY run.sh /run.sh
RUN chmod 0755 /run.sh RUN chmod 0755 /run.sh

View File

@@ -1,5 +1,5 @@
name: SillyHome Next name: SillyHome Next
version: "0.5.1" version: "0.5.4"
slug: sillyhome_next slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next url: http://192.168.6.31:3000/pino/sillyhome-next

View File

@@ -45,6 +45,8 @@ _STOPWORDS = frozenset(
"humidity", "humidity",
"illuminance", "illuminance",
"light", "light",
"licht",
"lichtschalter",
"power", "power",
"sensor", "sensor",
"state", "state",
@@ -54,8 +56,10 @@ _STOPWORDS = frozenset(
} }
) )
_NUMERIC_AUTO_ACCEPT_SCORE = 0.82 _NUMERIC_AUTO_ACCEPT_SCORE = 0.82
_NUMERIC_AUTO_ACCEPT_MIN_SCORE = 0.5
_NUMERIC_MIN_MARGIN = 0.18 _NUMERIC_MIN_MARGIN = 0.18
_CONTEXT_AUTO_ACCEPT_SCORE = 0.78 _CONTEXT_AUTO_ACCEPT_SCORE = 0.78
_CONTEXT_AUTO_ACCEPT_MIN_SCORE = 0.3
_MAX_CONTEXT_SELECTIONS = 5 _MAX_CONTEXT_SELECTIONS = 5
_AUDIT_LIMIT = 20 _AUDIT_LIMIT = 20
@@ -231,12 +235,29 @@ class ActuatorReconciliationService:
numeric_candidates: list[AssignmentCandidate], numeric_candidates: list[AssignmentCandidate],
context_candidates: list[AssignmentCandidate], context_candidates: list[AssignmentCandidate],
) -> AssignmentSelection: ) -> AssignmentSelection:
top_numeric = numeric_candidates[0] if numeric_candidates else None top_numeric = next(
top_contexts = [ (candidate for candidate in numeric_candidates if candidate.auto_accepted),
candidate.entity_id None,
)
accepted_contexts = [
candidate
for candidate in context_candidates for candidate in context_candidates
if candidate.auto_accepted
][: _MAX_CONTEXT_SELECTIONS] ][: _MAX_CONTEXT_SELECTIONS]
top_contexts = [candidate.entity_id for candidate in accepted_contexts]
if top_numeric is None: if top_numeric is None:
if accepted_contexts:
return AssignmentSelection(
selected_numeric_entity_id=None,
selected_context_entity_ids=top_contexts,
source=AssignmentSource.AUTOMATIC,
confidence=max(candidate.confidence for candidate in accepted_contexts),
review_required=False,
reason=(
"Passender Schaltkontext automatisch erkannt. Für diese "
"Verhaltensvorhersage ist kein numerischer Sensor erforderlich."
),
)
return AssignmentSelection( return AssignmentSelection(
selected_numeric_entity_id=None, selected_numeric_entity_id=None,
selected_context_entity_ids=top_contexts, selected_context_entity_ids=top_contexts,
@@ -433,8 +454,15 @@ class ActuatorReconciliationService:
confidence = candidate.score / highest if highest else 0.0 confidence = candidate.score / highest if highest else 0.0
margin = candidate.score - second_score if index == 0 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_score = _CONTEXT_AUTO_ACCEPT_SCORE if context else _NUMERIC_AUTO_ACCEPT_SCORE
auto_accepted = confidence >= auto_score and ( minimum_score = (
context or margin >= _NUMERIC_MIN_MARGIN _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( sorted_candidates[index] = candidate.model_copy(
update={ update={
@@ -510,6 +538,9 @@ def _score_candidate(
if entity.device_class in preferred_device_classes: if entity.device_class in preferred_device_classes:
score += 0.2 score += 0.2
evidence.append(f"Passende device_class: {entity.device_class}") 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: if not context and entity.unit_of_measurement is not None:
score += 0.05 score += 0.05
evidence.append(f"Numerische Einheit vorhanden: {entity.unit_of_measurement}") evidence.append(f"Numerische Einheit vorhanden: {entity.unit_of_measurement}")

View File

@@ -77,7 +77,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI( app = FastAPI(
title="SillyHome Next API", title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.5.1", version="0.5.2",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()

View File

@@ -112,6 +112,7 @@ async function api(path, options = {}) {
} }
function lifecycleLabel(record) { function lifecycleLabel(record) {
if (record.behavior.status === "trained") return "Kontext erkannt";
const labels = { const labels = {
trained: "lernt", trained: "lernt",
pending_history: "sammelt Historie", pending_history: "sammelt Historie",
@@ -124,6 +125,7 @@ function lifecycleLabel(record) {
} }
function statusClass(record) { function statusClass(record) {
if (record.behavior.status === "trained") return "ok";
if (record.lifecycle.status === "trained") return "ok"; if (record.lifecycle.status === "trained") return "ok";
if (["pending_history", "pending_assignment", "archived"].includes(record.lifecycle.status)) return "warn"; if (["pending_history", "pending_assignment", "archived"].includes(record.lifecycle.status)) return "warn";
return "bad"; return "bad";
@@ -237,11 +239,18 @@ async function showActuator(actuatorId) {
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`) .map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
.join(""); .join("");
const prediction = record.behavior.prediction; const prediction = record.behavior.prediction;
const requiredUserActions = 3;
const missingUserActions = Math.max(
0,
requiredUserActions - record.behavior.high_confidence_sample_count,
);
const activationButton = record.behavior.mode === "active" const activationButton = record.behavior.mode === "active"
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false)">Autonomes Schalten stoppen</button>` ? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false)">Autonomes Schalten stoppen</button>`
: record.behavior.status === "trained" : record.behavior.status === "trained" && missingUserActions === 0
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true)">Lernen und Schalten freigeben</button>` ? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true)">Lernen und Schalten freigeben</button>`
: "<p class='muted'>Freigabe wird möglich, sobald genügend Handlungen gelernt wurden.</p>"; : record.behavior.status === "trained"
? `<p class='muted'>Freigabe noch gesperrt: ${missingUserActions} eindeutig manuelle Bedienung${missingUserActions === 1 ? "" : "en"} fehlen. Bediene das Licht dafür direkt über Home Assistant.</p>`
: "<p class='muted'>Freigabe wird möglich, sobald genügend Handlungen gelernt wurden.</p>";
box.innerHTML = ` box.innerHTML = `
<div class="grid-two"> <div class="grid-two">
<div> <div>

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "0.5.1" version = "0.5.2"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View File

@@ -5,6 +5,7 @@ from pathlib import Path
from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ( from app.actuators.models import (
AssignmentSource,
LifecycleStatus, LifecycleStatus,
ManualOverride, ManualOverride,
model_id_for_actuator, model_id_for_actuator,
@@ -141,7 +142,7 @@ def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) ->
assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors 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) start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [ entities = [
HaEntitySummary( HaEntitySummary(
@@ -181,8 +182,62 @@ def test_reconciliation_uses_best_automatic_mapping_when_ambiguous(tmp_path: Pat
record = service.configure_actuator("switch.garage_pump") record = service.configure_actuator("switch.garage_pump")
assert record.assignment.review_required is True assert record.assignment.review_required is True
assert record.assignment.selected_numeric_entity_id == "sensor.garage_energy" assert record.assignment.selected_numeric_entity_id is None
assert record.lifecycle.status is LifecycleStatus.TRAINED 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.assignment.source is AssignmentSource.AUTOMATIC
assert record.assignment.confidence == 1.0
assert record.assignment.review_required is False
assert record.lifecycle.status is LifecycleStatus.ARCHIVED
def test_legacy_manual_override_is_cleared_and_automatic_mapping_wins(tmp_path: Path) -> None: def test_legacy_manual_override_is_cleared_and_automatic_mapping_wins(tmp_path: Path) -> None:

View File

@@ -8,3 +8,11 @@ def test_addon_does_not_expose_internal_learning_parameters() -> None:
assert "\nschema:" not in config assert "\nschema:" not in config
assert "prediction_confidence" not in config assert "prediction_confidence" not in config
assert "execution_cooldown_seconds" not in config assert "execution_cooldown_seconds" not in config
def test_addon_version_invalidates_application_build_layer() -> None:
dockerfile = Path("addon/Dockerfile").read_text(encoding="utf-8")
config_copy = dockerfile.index("COPY config.yaml /tmp/addon-config.yaml")
repository_clone = dockerfile.index("git clone --depth 1 --branch main")
assert config_copy < repository_clone

View File

@@ -14,5 +14,8 @@ def test_dashboard_is_served_at_root() -> None:
assert "Wie gewohnt bedienen" in response.text assert "Wie gewohnt bedienen" in response.text
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text
assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text
assert "Freigabe noch gesperrt" in response.text
assert "Bediene das Licht dafür direkt über Home Assistant" in response.text
assert 'record.behavior.status === "trained" && missingUserActions === 0' in response.text
assert "Automation-Entwurf" not in response.text assert "Automation-Entwurf" not in response.text
assert "Manuelle Overrides" not in response.text assert "Manuelle Overrides" not in response.text