Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9b5def7bb |
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.4 - 2026-06-17
|
||||||
|
- Sensor-Relevanz ist in der Aktor-Detailansicht sichtbar: automatische
|
||||||
|
Relevanz, aktive Gewichtung und Score werden pro verwendetem Sensor/Zustand
|
||||||
|
angezeigt.
|
||||||
|
- Gewichtungen koennen im Dashboard korrigiert und per API unter
|
||||||
|
`/v1/actuators/{actuator_entity_id}/weights` gespeichert werden.
|
||||||
|
- Gruppen-Gewichtungen buendeln mehrere Sensoren/Zustaende fuer einen Aktor,
|
||||||
|
damit verbundene Kontextsignale gemeinsam bewertet werden koennen.
|
||||||
|
|
||||||
## 1.0.3 - 2026-06-17
|
## 1.0.3 - 2026-06-17
|
||||||
- Header-Menue als Pulldown umgesetzt; die separate Navigationsleiste entfaellt.
|
- Header-Menue als Pulldown umgesetzt; die separate Navigationsleiste entfaellt.
|
||||||
- Geraetegruppen und manuelle Kontextbereiche sind standardmaessig geschlossen.
|
- Geraetegruppen und manuelle Kontextbereiche sind standardmaessig geschlossen.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: SillyHome Next
|
name: SillyHome Next
|
||||||
version: "1.0.3"
|
version: "1.0.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
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.actuators.models import (
|
|||||||
ManualOverride,
|
ManualOverride,
|
||||||
ModelLifecycleState,
|
ModelLifecycleState,
|
||||||
ReconciliationState,
|
ReconciliationState,
|
||||||
|
SensorWeightGroup,
|
||||||
model_id_for_actuator,
|
model_id_for_actuator,
|
||||||
)
|
)
|
||||||
from app.actuators.store import ActuatorStore
|
from app.actuators.store import ActuatorStore
|
||||||
@@ -239,6 +240,10 @@ class ActuatorReconciliationService:
|
|||||||
override = ManualOverride(
|
override = ManualOverride(
|
||||||
numeric_entity_id=numeric_entity_id,
|
numeric_entity_id=numeric_entity_id,
|
||||||
context_entity_ids=selected_context_ids,
|
context_entity_ids=selected_context_ids,
|
||||||
|
sensor_weights=record.manual_override.sensor_weights if record.manual_override else {},
|
||||||
|
sensor_weight_groups=(
|
||||||
|
record.manual_override.sensor_weight_groups if record.manual_override else []
|
||||||
|
),
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
note=note,
|
note=note,
|
||||||
)
|
)
|
||||||
@@ -253,24 +258,89 @@ class ActuatorReconciliationService:
|
|||||||
update={
|
update={
|
||||||
"assignment": assignment,
|
"assignment": assignment,
|
||||||
"manual_override": override,
|
"manual_override": override,
|
||||||
"numeric_candidates": _merge_manual_candidates(
|
"numeric_candidates": _apply_weight_overrides(
|
||||||
|
_merge_manual_candidates(
|
||||||
record.numeric_candidates,
|
record.numeric_candidates,
|
||||||
entities,
|
entities,
|
||||||
[numeric_entity_id] if numeric_entity_id else [],
|
[numeric_entity_id] if numeric_entity_id else [],
|
||||||
role=EntityRole.MEASUREMENT,
|
role=EntityRole.MEASUREMENT,
|
||||||
),
|
),
|
||||||
"context_candidates": _merge_manual_candidates(
|
override,
|
||||||
|
),
|
||||||
|
"context_candidates": _apply_weight_overrides(
|
||||||
|
_merge_manual_candidates(
|
||||||
record.context_candidates,
|
record.context_candidates,
|
||||||
entities,
|
entities,
|
||||||
selected_context_ids,
|
selected_context_ids,
|
||||||
role=EntityRole.CONTEXT,
|
role=EntityRole.CONTEXT,
|
||||||
),
|
),
|
||||||
|
override,
|
||||||
|
),
|
||||||
"lifecycle": lifecycle,
|
"lifecycle": lifecycle,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self._store.upsert(updated)
|
return self._store.upsert(updated)
|
||||||
|
|
||||||
|
def set_weight_overrides(
|
||||||
|
self,
|
||||||
|
actuator_entity_id: str,
|
||||||
|
*,
|
||||||
|
sensor_weights: dict[str, float],
|
||||||
|
sensor_weight_groups: list[SensorWeightGroup],
|
||||||
|
note: str | None = None,
|
||||||
|
) -> ActuatorRecord:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
record = self._store.get(actuator_entity_id)
|
||||||
|
selected_ids = {
|
||||||
|
entity_id
|
||||||
|
for entity_id in [
|
||||||
|
record.assignment.selected_numeric_entity_id,
|
||||||
|
*record.assignment.selected_context_entity_ids,
|
||||||
|
]
|
||||||
|
if entity_id
|
||||||
|
}
|
||||||
|
selected_ids.update(sensor_weights)
|
||||||
|
for group in sensor_weight_groups:
|
||||||
|
selected_ids.update(group.entity_ids)
|
||||||
|
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
|
||||||
|
missing = [entity_id for entity_id in selected_ids if entity_id not in entities]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Unbekannte Home-Assistant-Entity: {', '.join(sorted(missing))}")
|
||||||
|
|
||||||
|
previous = record.manual_override
|
||||||
|
override = ManualOverride(
|
||||||
|
numeric_entity_id=(
|
||||||
|
previous.numeric_entity_id
|
||||||
|
if previous is not None
|
||||||
|
else record.assignment.selected_numeric_entity_id
|
||||||
|
),
|
||||||
|
context_entity_ids=(
|
||||||
|
previous.context_entity_ids
|
||||||
|
if previous is not None
|
||||||
|
else record.assignment.selected_context_entity_ids
|
||||||
|
),
|
||||||
|
sensor_weights={entity_id: round(weight, 4) for entity_id, weight in sensor_weights.items()},
|
||||||
|
sensor_weight_groups=sensor_weight_groups,
|
||||||
|
updated_at=now,
|
||||||
|
note=note,
|
||||||
|
)
|
||||||
|
updated = record.model_copy(
|
||||||
|
update={
|
||||||
|
"manual_override": override,
|
||||||
|
"numeric_candidates": _apply_weight_overrides(
|
||||||
|
record.numeric_candidates,
|
||||||
|
override,
|
||||||
|
),
|
||||||
|
"context_candidates": _apply_weight_overrides(
|
||||||
|
record.context_candidates,
|
||||||
|
override,
|
||||||
|
),
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self._store.upsert(updated)
|
||||||
|
|
||||||
def reconcile_all(self, trigger: str = "manual") -> ReconciliationState:
|
def reconcile_all(self, trigger: str = "manual") -> ReconciliationState:
|
||||||
state = self._store.load_reconciliation_state().model_copy(
|
state = self._store.load_reconciliation_state().model_copy(
|
||||||
update={
|
update={
|
||||||
@@ -376,6 +446,9 @@ class ActuatorReconciliationService:
|
|||||||
),
|
),
|
||||||
context=True,
|
context=True,
|
||||||
)
|
)
|
||||||
|
if record.manual_override is not None:
|
||||||
|
numeric_candidates = _apply_weight_overrides(numeric_candidates, record.manual_override)
|
||||||
|
context_candidates = _apply_weight_overrides(context_candidates, record.manual_override)
|
||||||
assignment = (
|
assignment = (
|
||||||
self._manual_assignment(record.manual_override)
|
self._manual_assignment(record.manual_override)
|
||||||
if record.manual_override is not None
|
if record.manual_override is not None
|
||||||
@@ -918,6 +991,41 @@ def _merge_manual_candidates(
|
|||||||
return sorted(by_id.values(), key=lambda item: (-item.confidence, item.entity_id))
|
return sorted(by_id.values(), key=lambda item: (-item.confidence, item.entity_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_weight_overrides(
|
||||||
|
candidates: list[AssignmentCandidate],
|
||||||
|
override: ManualOverride,
|
||||||
|
) -> list[AssignmentCandidate]:
|
||||||
|
if not override.sensor_weights and not override.sensor_weight_groups:
|
||||||
|
return candidates
|
||||||
|
group_weights: dict[str, float] = {}
|
||||||
|
for group in override.sensor_weight_groups:
|
||||||
|
for entity_id in group.entity_ids:
|
||||||
|
group_weights[entity_id] = max(group_weights.get(entity_id, 0.0), group.weight)
|
||||||
|
weighted: list[AssignmentCandidate] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
explicit = override.sensor_weights.get(candidate.entity_id)
|
||||||
|
group_weight = group_weights.get(candidate.entity_id)
|
||||||
|
manual_weight = explicit if explicit is not None else group_weight
|
||||||
|
effective_weight = manual_weight if manual_weight is not None else 1.0
|
||||||
|
evidence = [
|
||||||
|
item
|
||||||
|
for item in candidate.evidence
|
||||||
|
if not item.startswith("Manuelle Gewichtung:")
|
||||||
|
]
|
||||||
|
if manual_weight is not None:
|
||||||
|
evidence.append(f"Manuelle Gewichtung: {round(manual_weight * 100)} %.")
|
||||||
|
weighted.append(
|
||||||
|
candidate.model_copy(
|
||||||
|
update={
|
||||||
|
"manual_weight": manual_weight,
|
||||||
|
"effective_weight": round(effective_weight, 4),
|
||||||
|
"evidence": evidence,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sorted(weighted, key=lambda item: (-item.confidence * item.effective_weight, item.entity_id))
|
||||||
|
|
||||||
|
|
||||||
def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
|
def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
|
||||||
if context:
|
if context:
|
||||||
mapping = {
|
mapping = {
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ class AssignmentCandidate(BaseModel):
|
|||||||
device_name: str | None = None
|
device_name: str | None = None
|
||||||
score: float = Field(ge=0.0)
|
score: float = Field(ge=0.0)
|
||||||
confidence: float = Field(ge=0.0, le=1.0)
|
confidence: float = Field(ge=0.0, le=1.0)
|
||||||
|
manual_weight: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||||
|
effective_weight: float = Field(default=1.0, ge=0.0, le=1.0)
|
||||||
auto_accepted: bool = False
|
auto_accepted: bool = False
|
||||||
evidence: list[str] = Field(default_factory=list)
|
evidence: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
@@ -62,9 +64,18 @@ class AssignmentSelection(BaseModel):
|
|||||||
reason: str = "Noch keine Zuordnung vorhanden."
|
reason: str = "Noch keine Zuordnung vorhanden."
|
||||||
|
|
||||||
|
|
||||||
|
class SensorWeightGroup(BaseModel):
|
||||||
|
group_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
entity_ids: list[str] = Field(default_factory=list)
|
||||||
|
weight: float = Field(default=1.0, ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
class ManualOverride(BaseModel):
|
class ManualOverride(BaseModel):
|
||||||
numeric_entity_id: str | None = None
|
numeric_entity_id: str | None = None
|
||||||
context_entity_ids: list[str] = Field(default_factory=list)
|
context_entity_ids: list[str] = Field(default_factory=list)
|
||||||
|
sensor_weights: dict[str, float] = Field(default_factory=dict)
|
||||||
|
sensor_weight_groups: list[SensorWeightGroup] = Field(default_factory=list)
|
||||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||||
from app.actuators.models import ActuatorRecord, ReconciliationState
|
from app.actuators.models import ActuatorRecord, ReconciliationState, SensorWeightGroup
|
||||||
from app.actuators.store import ActuatorStore
|
from app.actuators.store import ActuatorStore
|
||||||
from app.behavior.engine import BehaviorEngine
|
from app.behavior.engine import BehaviorEngine
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
@@ -44,6 +44,12 @@ class ManualAssignmentRequest(BaseModel):
|
|||||||
note: str | None = Field(default=None, max_length=500)
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class WeightOverrideRequest(BaseModel):
|
||||||
|
sensor_weights: dict[str, float] = Field(default_factory=dict)
|
||||||
|
sensor_weight_groups: list[SensorWeightGroup] = Field(default_factory=list)
|
||||||
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
class FeedbackRequest(BaseModel):
|
class FeedbackRequest(BaseModel):
|
||||||
correct: bool
|
correct: bool
|
||||||
expected_state: str | None = Field(default=None, max_length=100)
|
expected_state: str | None = Field(default=None, max_length=100)
|
||||||
@@ -406,6 +412,27 @@ def set_manual_assignment(
|
|||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{actuator_entity_id}/weights", response_model=ActuatorRecord)
|
||||||
|
def set_weight_overrides(
|
||||||
|
actuator_entity_id: str,
|
||||||
|
payload: WeightOverrideRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> ActuatorRecord:
|
||||||
|
try:
|
||||||
|
_validate_weight_payload(payload)
|
||||||
|
record = _service(request).set_weight_overrides(
|
||||||
|
actuator_entity_id,
|
||||||
|
sensor_weights=payload.sensor_weights,
|
||||||
|
sensor_weight_groups=payload.sensor_weight_groups,
|
||||||
|
note=payload.note,
|
||||||
|
)
|
||||||
|
return record
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{actuator_entity_id}/related-automations/refresh",
|
"/{actuator_entity_id}/related-automations/refresh",
|
||||||
response_model=ActuatorRecord,
|
response_model=ActuatorRecord,
|
||||||
@@ -485,6 +512,20 @@ def _behavior(request: Request) -> BehaviorEngine:
|
|||||||
return engine
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_weight_payload(payload: WeightOverrideRequest) -> None:
|
||||||
|
for entity_id, weight in payload.sensor_weights.items():
|
||||||
|
if "." not in entity_id:
|
||||||
|
raise ValueError(f"Ungültige Entity-ID: {entity_id}")
|
||||||
|
if not 0.0 <= weight <= 1.0:
|
||||||
|
raise ValueError(f"Ungültige Gewichtung für {entity_id}: {weight}")
|
||||||
|
for group in payload.sensor_weight_groups:
|
||||||
|
if not group.entity_ids:
|
||||||
|
raise ValueError(f"Gruppe {group.name} enthält keine Entities.")
|
||||||
|
for entity_id in group.entity_ids:
|
||||||
|
if "." not in entity_id:
|
||||||
|
raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}")
|
||||||
|
|
||||||
|
|
||||||
def _reconciliation_state_or_default(request: Request) -> ReconciliationState:
|
def _reconciliation_state_or_default(request: Request) -> ReconciliationState:
|
||||||
store = getattr(request.app.state, "actuator_store", None)
|
store = getattr(request.app.state, "actuator_store", None)
|
||||||
if not isinstance(store, ActuatorStore):
|
if not isinstance(store, ActuatorStore):
|
||||||
|
|||||||
@@ -105,7 +105,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="1.0.3",
|
version="1.0.4",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
app.state.settings = load_settings()
|
app.state.settings = load_settings()
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ let cachedActuators = null;
|
|||||||
let cachedEntities = null;
|
let cachedEntities = null;
|
||||||
let cachedDiscovery = null;
|
let cachedDiscovery = null;
|
||||||
let discoveryLoadPromise = null;
|
let discoveryLoadPromise = null;
|
||||||
|
let currentSensorWeightGroups = [];
|
||||||
const ACTUATOR_RESULT_LIMIT = 50;
|
const ACTUATOR_RESULT_LIMIT = 50;
|
||||||
const STATUS_TIMEOUT_MS = 2000;
|
const STATUS_TIMEOUT_MS = 2000;
|
||||||
const DASHBOARD_TIMEOUT_MS = 4500;
|
const DASHBOARD_TIMEOUT_MS = 4500;
|
||||||
@@ -803,6 +804,62 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
.filter(candidate => contexts.includes(candidate.entity_id))
|
.filter(candidate => contexts.includes(candidate.entity_id))
|
||||||
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${uniqueValues(candidate.evidence).map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
|
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${uniqueValues(candidate.evidence).map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
|
||||||
.join("");
|
.join("");
|
||||||
|
const weightedCandidates = [...record.numeric_candidates, ...record.context_candidates]
|
||||||
|
.filter(candidate => contexts.includes(candidate.entity_id));
|
||||||
|
const weightGroups = record.manual_override?.sensor_weight_groups || [];
|
||||||
|
currentSensorWeightGroups = weightGroups;
|
||||||
|
const weightControls = weightedCandidates.length ? `
|
||||||
|
<div class="card-list">
|
||||||
|
${weightedCandidates.map(candidate => {
|
||||||
|
const relevance = Math.round((candidate.confidence ?? 0) * 100);
|
||||||
|
const effective = Math.round((candidate.effective_weight ?? 1) * 100);
|
||||||
|
const manual = candidate.manual_weight == null ? effective : Math.round(candidate.manual_weight * 100);
|
||||||
|
return `
|
||||||
|
<article class="actuator-card">
|
||||||
|
<div class="card-title">
|
||||||
|
<div>
|
||||||
|
<div><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong></div>
|
||||||
|
<div class="entity-id">${escapeHtml(candidate.entity_id)}</div>
|
||||||
|
</div>
|
||||||
|
<span class="chip">${relevance} % relevant</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-grid">
|
||||||
|
<div class="metric"><strong>Automatische Relevanz</strong>${relevance} %</div>
|
||||||
|
<div class="metric"><strong>Aktive Gewichtung</strong>${effective} %</div>
|
||||||
|
<div class="metric"><strong>Score</strong>${escapeHtml(candidate.score)}</div>
|
||||||
|
</div>
|
||||||
|
<label for="weight-${escapeHtml(candidate.entity_id)}">Gewichtung korrigieren</label>
|
||||||
|
<input id="weight-${escapeHtml(candidate.entity_id)}" data-weight-entity="${escapeHtml(candidate.entity_id)}" type="number" min="0" max="100" step="5" value="${manual}">
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}).join("")}
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button onclick="saveWeightOverrides('${escapeHtml(record.actuator_entity_id)}')">Gewichtungen speichern</button>
|
||||||
|
</div>
|
||||||
|
` : "<p class='muted'>Noch keine verwendeten Sensoren oder Zustände für eine Gewichtung ausgewählt.</p>";
|
||||||
|
const weightGroupControls = `
|
||||||
|
<details class="manual-context">
|
||||||
|
<summary>Gruppen-Gewichtung</summary>
|
||||||
|
${weightGroups.length ? `<ul>${weightGroups.map(group => `
|
||||||
|
<li><strong>${escapeHtml(group.name)}</strong>: ${Math.round(group.weight * 100)} %
|
||||||
|
<span class="muted">${group.entity_ids.map(escapeHtml).join(", ")}</span></li>
|
||||||
|
`).join("")}</ul>` : "<p class='muted'>Noch keine Gruppe gespeichert.</p>"}
|
||||||
|
<div class="inline-controls">
|
||||||
|
<div>
|
||||||
|
<label for="weight-group-name">Gruppenname</label>
|
||||||
|
<input id="weight-group-name" placeholder="z. B. Flur Bewegung + Helligkeit">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="weight-group-value">Gruppen-Gewicht in %</label>
|
||||||
|
<input id="weight-group-value" type="number" min="0" max="100" step="5" value="100">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label for="weight-group-entities">Entity-IDs der Gruppe</label>
|
||||||
|
<textarea id="weight-group-entities" class="manual-entry" placeholder="Eine oder mehrere Entity-IDs">${escapeHtml(contexts.join("\n"))}</textarea>
|
||||||
|
<button class="secondary" onclick="saveWeightOverrides('${escapeHtml(record.actuator_entity_id)}', true)">Als Gruppe speichern</button>
|
||||||
|
</details>
|
||||||
|
`;
|
||||||
const currentContextControls = contexts.length
|
const currentContextControls = contexts.length
|
||||||
? `<ul>${contexts.map(entityId => `
|
? `<ul>${contexts.map(entityId => `
|
||||||
<li>
|
<li>
|
||||||
@@ -927,6 +984,10 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
${automationControls}
|
${automationControls}
|
||||||
<h3>Welche Zusammenhänge automatisch verwendet werden</h3>
|
<h3>Welche Zusammenhänge automatisch verwendet werden</h3>
|
||||||
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
|
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
|
||||||
|
<h3>Sensor-Gewichtung</h3>
|
||||||
|
<p class="muted">Automatische Relevanz kommt aus der Zuordnung. Die aktive Gewichtung kannst du korrigieren; Gruppen bündeln mehrere Sensoren/Zustände.</p>
|
||||||
|
${weightControls}
|
||||||
|
${weightGroupControls}
|
||||||
<h3>Verwendete Sensoren/Zustände ändern</h3>
|
<h3>Verwendete Sensoren/Zustände ändern</h3>
|
||||||
${currentContextControls}
|
${currentContextControls}
|
||||||
${manualAssignment}
|
${manualAssignment}
|
||||||
@@ -986,6 +1047,45 @@ async function hydrateCurrentContextOptions(actuatorId) {
|
|||||||
await hydrateContextOptions(record);
|
await hydrateContextOptions(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveWeightOverrides(actuatorId, includeNewGroup = false) {
|
||||||
|
const sensorWeights = {};
|
||||||
|
for (const input of document.querySelectorAll("[data-weight-entity]")) {
|
||||||
|
const value = Number(input.value);
|
||||||
|
if (Number.isFinite(value)) {
|
||||||
|
sensorWeights[input.dataset.weightEntity] = Math.max(0, Math.min(100, value)) / 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const groups = [...currentSensorWeightGroups];
|
||||||
|
if (includeNewGroup) {
|
||||||
|
const name = document.getElementById("weight-group-name")?.value.trim();
|
||||||
|
const value = Number(document.getElementById("weight-group-value")?.value || 100);
|
||||||
|
const entityIds = parseEntityIds(document.getElementById("weight-group-entities")?.value || "");
|
||||||
|
if (name && entityIds.length) {
|
||||||
|
groups.push({
|
||||||
|
group_id: name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64) || "gruppe",
|
||||||
|
name,
|
||||||
|
entity_ids: entityIds,
|
||||||
|
weight: Math.max(0, Math.min(100, Number.isFinite(value) ? value : 100)) / 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/weights`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
sensor_weights: sensorWeights,
|
||||||
|
sensor_weight_groups: groups,
|
||||||
|
note: "Gewichtung im Dashboard korrigiert",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
invalidateDashboardCache();
|
||||||
|
await loadConfiguredActuators();
|
||||||
|
await showActuator(actuatorId, "Sensor-Gewichtung gespeichert.");
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveManualAssignment(actuatorId) {
|
async function saveManualAssignment(actuatorId) {
|
||||||
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
|
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
|
||||||
const selectedContextIds = Array.from(
|
const selectedContextIds = Array.from(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "sillyhome-next"
|
name = "sillyhome-next"
|
||||||
version = "1.0.3"
|
version = "1.0.4"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -215,6 +215,54 @@ def test_manual_assignment_endpoint_updates_context(tmp_path: Path) -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_weight_override_endpoint_updates_sensor_relevance(tmp_path: Path) -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_install_service(tmp_path)
|
||||||
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||||
|
client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/assignment",
|
||||||
|
json={
|
||||||
|
"numeric_entity_id": "sensor.abstellkammer_illuminance",
|
||||||
|
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/weights",
|
||||||
|
json={
|
||||||
|
"sensor_weights": {
|
||||||
|
"sensor.abstellkammer_illuminance": 0.75,
|
||||||
|
"binary_sensor.abstellkammer_motion": 0.5,
|
||||||
|
},
|
||||||
|
"sensor_weight_groups": [
|
||||||
|
{
|
||||||
|
"group_id": "abstellkammer_context",
|
||||||
|
"name": "Abstellkammer Kontext",
|
||||||
|
"entity_ids": [
|
||||||
|
"sensor.abstellkammer_illuminance",
|
||||||
|
"binary_sensor.abstellkammer_motion",
|
||||||
|
],
|
||||||
|
"weight": 0.8,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"note": "Gewichtung korrigiert",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["manual_override"]["sensor_weights"]["sensor.abstellkammer_illuminance"] == 0.75
|
||||||
|
assert payload["manual_override"]["sensor_weight_groups"][0]["group_id"] == (
|
||||||
|
"abstellkammer_context"
|
||||||
|
)
|
||||||
|
numeric = {
|
||||||
|
candidate["entity_id"]: candidate
|
||||||
|
for candidate in payload["numeric_candidates"]
|
||||||
|
}
|
||||||
|
assert numeric["sensor.abstellkammer_illuminance"]["manual_weight"] == 0.75
|
||||||
|
assert numeric["sensor.abstellkammer_illuminance"]["effective_weight"] == 0.75
|
||||||
|
|
||||||
|
|
||||||
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
|
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
_install_service(tmp_path)
|
_install_service(tmp_path)
|
||||||
|
|||||||
Reference in New Issue
Block a user