Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ca0c53f6a | |||
| 8070a85b52 |
@@ -1,5 +1,5 @@
|
|||||||
name: SillyHome Next
|
name: SillyHome Next
|
||||||
version: "1.7.0"
|
version: "1.7.2"
|
||||||
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
|
||||||
|
|||||||
@@ -154,6 +154,19 @@ class DecisionFactor(BaseModel):
|
|||||||
evidence: list[str] = Field(default_factory=list)
|
evidence: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationOutcome(BaseModel):
|
||||||
|
scenario_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
|
||||||
|
actuator_entity_id: str
|
||||||
|
sensor_states: dict[str, str] = Field(default_factory=dict)
|
||||||
|
sensor_weights: dict[str, float] = Field(default_factory=dict)
|
||||||
|
prediction: BehaviorPrediction | None = None
|
||||||
|
decision_factors: list[DecisionFactor] = Field(default_factory=list)
|
||||||
|
would_execute: bool = False
|
||||||
|
blockers: list[str] = Field(default_factory=list)
|
||||||
|
score: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
recommendation: str = Field(default="", max_length=700)
|
||||||
|
|
||||||
|
|
||||||
class DecisionTrace(BaseModel):
|
class DecisionTrace(BaseModel):
|
||||||
trace_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
|
trace_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from app.actuators.cache_db import DashboardCache
|
from app.actuators.cache_db import DashboardCache
|
||||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||||
from app.actuators.models import ActuatorRecord, AnomalyEvent, FeedbackKind, ReconciliationState, SensorWeightGroup
|
from app.actuators.models import (
|
||||||
|
ActuatorRecord,
|
||||||
|
AnomalyEvent,
|
||||||
|
FeedbackKind,
|
||||||
|
ReconciliationState,
|
||||||
|
SensorWeightGroup,
|
||||||
|
SimulationOutcome,
|
||||||
|
)
|
||||||
from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile
|
from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile
|
||||||
from app.actuators.store import ActuatorStore
|
from app.actuators.store import ActuatorStore
|
||||||
from app.behavior.engine import BehaviorEngine
|
from app.behavior.engine import BehaviorEngine
|
||||||
@@ -52,6 +59,14 @@ class WeightOverrideRequest(BaseModel):
|
|||||||
note: str | None = Field(default=None, max_length=500)
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationRequest(BaseModel):
|
||||||
|
sensor_states: dict[str, str] = Field(default_factory=dict)
|
||||||
|
sensor_weights: dict[str, float] = Field(default_factory=dict)
|
||||||
|
state_options: dict[str, list[str]] = Field(default_factory=dict)
|
||||||
|
include_current: bool = True
|
||||||
|
max_results: int = Field(default=8, ge=1, le=20)
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -567,6 +582,28 @@ def evaluate_actuator(
|
|||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{actuator_entity_id}/simulate", response_model=list[SimulationOutcome])
|
||||||
|
def simulate_actuator(
|
||||||
|
actuator_entity_id: str,
|
||||||
|
payload: SimulationRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> list[SimulationOutcome]:
|
||||||
|
try:
|
||||||
|
_validate_simulation_payload(payload)
|
||||||
|
return _behavior(request).simulate(
|
||||||
|
actuator_entity_id,
|
||||||
|
sensor_states=payload.sensor_states,
|
||||||
|
sensor_weights=payload.sensor_weights,
|
||||||
|
state_options=payload.state_options,
|
||||||
|
include_current=payload.include_current,
|
||||||
|
max_results=payload.max_results,
|
||||||
|
)
|
||||||
|
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("/{actuator_entity_id}/feedback", response_model=ActuatorRecord)
|
@router.post("/{actuator_entity_id}/feedback", response_model=ActuatorRecord)
|
||||||
def record_feedback(
|
def record_feedback(
|
||||||
actuator_entity_id: str,
|
actuator_entity_id: str,
|
||||||
@@ -885,6 +922,22 @@ def _validate_weight_payload(payload: WeightOverrideRequest) -> None:
|
|||||||
raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}")
|
raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_simulation_payload(payload: SimulationRequest) -> None:
|
||||||
|
for entity_id in [
|
||||||
|
*payload.sensor_states.keys(),
|
||||||
|
*payload.sensor_weights.keys(),
|
||||||
|
*payload.state_options.keys(),
|
||||||
|
]:
|
||||||
|
if "." not in entity_id:
|
||||||
|
raise ValueError(f"Ungültige Entity-ID: {entity_id}")
|
||||||
|
for entity_id, weight in payload.sensor_weights.items():
|
||||||
|
if not 0.0 <= weight <= 1.0:
|
||||||
|
raise ValueError(f"Ungültige Gewichtung für {entity_id}: {weight}")
|
||||||
|
for entity_id, states in payload.state_options.items():
|
||||||
|
if not states:
|
||||||
|
raise ValueError(f"Keine Zustände für {entity_id} angegeben.")
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from itertools import product
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
@@ -29,6 +30,7 @@ from app.actuators.models import (
|
|||||||
SafetyProfile,
|
SafetyProfile,
|
||||||
SafetyStage,
|
SafetyStage,
|
||||||
SceneSuggestion,
|
SceneSuggestion,
|
||||||
|
SimulationOutcome,
|
||||||
TimeProfile,
|
TimeProfile,
|
||||||
)
|
)
|
||||||
from app.actuators.store import ActuatorStore
|
from app.actuators.store import ActuatorStore
|
||||||
@@ -333,6 +335,7 @@ class BehaviorEngine:
|
|||||||
record.behavior.patterns,
|
record.behavior.patterns,
|
||||||
current_context=current_context,
|
current_context=current_context,
|
||||||
current_context_changed_at=current_context_changed_at,
|
current_context_changed_at=current_context_changed_at,
|
||||||
|
context_weights=_context_weights_for(record),
|
||||||
now=now,
|
now=now,
|
||||||
min_support=self._settings.min_behavior_actions,
|
min_support=self._settings.min_behavior_actions,
|
||||||
window_minutes=self._settings.prediction_window_minutes,
|
window_minutes=self._settings.prediction_window_minutes,
|
||||||
@@ -514,6 +517,116 @@ class BehaviorEngine:
|
|||||||
)
|
)
|
||||||
return self._save_behavior(record, behavior)
|
return self._save_behavior(record, behavior)
|
||||||
|
|
||||||
|
def simulate(
|
||||||
|
self,
|
||||||
|
actuator_entity_id: str,
|
||||||
|
*,
|
||||||
|
sensor_states: dict[str, str],
|
||||||
|
sensor_weights: dict[str, float],
|
||||||
|
state_options: dict[str, list[str]],
|
||||||
|
max_results: int,
|
||||||
|
include_current: bool = True,
|
||||||
|
) -> list[SimulationOutcome]:
|
||||||
|
record = self._store.get(actuator_entity_id)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
current_entities = self._ha_reader.read_entities()
|
||||||
|
entities = {entity.entity_id: entity for entity in current_entities}
|
||||||
|
actuator = entities.get(actuator_entity_id)
|
||||||
|
if actuator is None:
|
||||||
|
raise KeyError("Aktor ist aktuell nicht in Home Assistant verfügbar.")
|
||||||
|
selected_context_ids = [
|
||||||
|
entity_id
|
||||||
|
for entity_id in [
|
||||||
|
record.assignment.selected_numeric_entity_id,
|
||||||
|
*record.assignment.selected_context_entity_ids,
|
||||||
|
]
|
||||||
|
if entity_id
|
||||||
|
]
|
||||||
|
if not selected_context_ids:
|
||||||
|
return []
|
||||||
|
base_context = {
|
||||||
|
entity_id: entities[entity_id].state
|
||||||
|
for entity_id in selected_context_ids
|
||||||
|
if entity_id in entities and entities[entity_id].state is not None
|
||||||
|
}
|
||||||
|
base_changed_at = {
|
||||||
|
entity_id: entities[entity_id].last_changed
|
||||||
|
for entity_id in base_context
|
||||||
|
}
|
||||||
|
context_weights = _context_weights_for(record)
|
||||||
|
for entity_id, weight in sensor_weights.items():
|
||||||
|
if entity_id in selected_context_ids:
|
||||||
|
context_weights[entity_id] = max(0.0, min(1.0, weight))
|
||||||
|
scenarios = _simulation_contexts(
|
||||||
|
base_context,
|
||||||
|
sensor_states=sensor_states,
|
||||||
|
state_options=state_options,
|
||||||
|
selected_context_ids=selected_context_ids,
|
||||||
|
include_current=include_current,
|
||||||
|
)
|
||||||
|
outcomes: list[SimulationOutcome] = []
|
||||||
|
for index, context in enumerate(scenarios[:64], start=1):
|
||||||
|
prediction_context: dict[str, str | None] = dict(context)
|
||||||
|
changed_at = dict(base_changed_at)
|
||||||
|
for entity_id, state in context.items():
|
||||||
|
if base_context.get(entity_id) != state:
|
||||||
|
changed_at[entity_id] = now
|
||||||
|
prediction = predict_behavior(
|
||||||
|
record.behavior.patterns,
|
||||||
|
current_context=prediction_context,
|
||||||
|
current_context_changed_at=changed_at,
|
||||||
|
context_weights=context_weights,
|
||||||
|
now=now,
|
||||||
|
min_support=self._settings.min_behavior_actions,
|
||||||
|
window_minutes=self._settings.prediction_window_minutes,
|
||||||
|
causal_window_seconds=self._settings.prediction_interval_seconds * 2,
|
||||||
|
timezone_name=self._settings.timezone,
|
||||||
|
)
|
||||||
|
if prediction is not None:
|
||||||
|
would_execute, blockers = self._assess_safety(record, actuator.state, prediction, now)
|
||||||
|
recommendation = (
|
||||||
|
f"Bestes Szenario: {prediction.target_state} mit {prediction.confidence:.0%}."
|
||||||
|
if would_execute
|
||||||
|
else (
|
||||||
|
f"Vorhersage {prediction.target_state} mit {prediction.confidence:.0%}, "
|
||||||
|
"aber blockiert: " + " ".join(blockers)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
would_execute = False
|
||||||
|
blockers = ["Keine fällige Vorhersage."]
|
||||||
|
recommendation = "Dieses Szenario erzeugt keine fällige Vorhersage."
|
||||||
|
outcomes.append(
|
||||||
|
SimulationOutcome(
|
||||||
|
scenario_id=f"scenario-{index}",
|
||||||
|
actuator_entity_id=actuator_entity_id,
|
||||||
|
sensor_states=context,
|
||||||
|
sensor_weights={
|
||||||
|
entity_id: round(context_weights.get(entity_id, 1.0), 4)
|
||||||
|
for entity_id in context
|
||||||
|
},
|
||||||
|
prediction=prediction,
|
||||||
|
decision_factors=_decision_factors_for(
|
||||||
|
record,
|
||||||
|
prediction_context,
|
||||||
|
prediction,
|
||||||
|
context_weights=context_weights,
|
||||||
|
),
|
||||||
|
would_execute=would_execute,
|
||||||
|
blockers=blockers,
|
||||||
|
score=round(prediction.confidence if prediction is not None else 0.0, 4),
|
||||||
|
recommendation=recommendation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sorted(
|
||||||
|
outcomes,
|
||||||
|
key=lambda item: (
|
||||||
|
item.prediction is None,
|
||||||
|
-item.score,
|
||||||
|
item.scenario_id,
|
||||||
|
),
|
||||||
|
)[:max_results]
|
||||||
|
|
||||||
def record_feedback(
|
def record_feedback(
|
||||||
self,
|
self,
|
||||||
actuator_entity_id: str,
|
actuator_entity_id: str,
|
||||||
@@ -1379,15 +1492,21 @@ def _decision_factors_for(
|
|||||||
record: ActuatorRecord,
|
record: ActuatorRecord,
|
||||||
current_context: dict[str, str | None],
|
current_context: dict[str, str | None],
|
||||||
prediction: BehaviorPrediction | None,
|
prediction: BehaviorPrediction | None,
|
||||||
|
*,
|
||||||
|
context_weights: dict[str, float] | None = None,
|
||||||
) -> list[DecisionFactor]:
|
) -> list[DecisionFactor]:
|
||||||
factors: list[DecisionFactor] = []
|
factors: list[DecisionFactor] = []
|
||||||
|
weights = context_weights or {}
|
||||||
candidates = {
|
candidates = {
|
||||||
candidate.entity_id: candidate
|
candidate.entity_id: candidate
|
||||||
for candidate in [*record.numeric_candidates, *record.context_candidates]
|
for candidate in [*record.numeric_candidates, *record.context_candidates]
|
||||||
}
|
}
|
||||||
for entity_id, state in current_context.items():
|
for entity_id, state in current_context.items():
|
||||||
candidate = candidates.get(entity_id)
|
candidate = candidates.get(entity_id)
|
||||||
weight = candidate.effective_weight if candidate is not None else 1.0
|
weight = weights.get(
|
||||||
|
entity_id,
|
||||||
|
candidate.effective_weight if candidate is not None else 1.0,
|
||||||
|
)
|
||||||
relevance = candidate.confidence if candidate is not None else 0.5
|
relevance = candidate.confidence if candidate is not None else 0.5
|
||||||
contribution = round(min(1.0, weight * relevance), 4)
|
contribution = round(min(1.0, weight * relevance), 4)
|
||||||
factors.append(
|
factors.append(
|
||||||
@@ -1423,6 +1542,62 @@ def _decision_factors_for(
|
|||||||
return sorted(factors, key=lambda item: (-item.contribution, item.label))[:12]
|
return sorted(factors, key=lambda item: (-item.contribution, item.label))[:12]
|
||||||
|
|
||||||
|
|
||||||
|
def _context_weights_for(record: ActuatorRecord) -> dict[str, float]:
|
||||||
|
weights = {
|
||||||
|
candidate.entity_id: candidate.effective_weight
|
||||||
|
for candidate in [*record.numeric_candidates, *record.context_candidates]
|
||||||
|
}
|
||||||
|
override = record.manual_override
|
||||||
|
if override is not None:
|
||||||
|
for entity_id, weight in override.sensor_weights.items():
|
||||||
|
weights[entity_id] = max(0.0, min(1.0, weight))
|
||||||
|
for group in override.sensor_weight_groups:
|
||||||
|
for entity_id in group.entity_ids:
|
||||||
|
weights[entity_id] = max(0.0, min(1.0, group.weight))
|
||||||
|
return weights
|
||||||
|
|
||||||
|
|
||||||
|
def _simulation_contexts(
|
||||||
|
base_context: dict[str, str | None],
|
||||||
|
*,
|
||||||
|
sensor_states: dict[str, str],
|
||||||
|
state_options: dict[str, list[str]],
|
||||||
|
selected_context_ids: list[str],
|
||||||
|
include_current: bool,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
selected = set(selected_context_ids)
|
||||||
|
base = {
|
||||||
|
entity_id: state
|
||||||
|
for entity_id, state in base_context.items()
|
||||||
|
if entity_id in selected and state is not None
|
||||||
|
}
|
||||||
|
for entity_id, state in sensor_states.items():
|
||||||
|
if entity_id in selected:
|
||||||
|
base[entity_id] = state
|
||||||
|
option_items = [
|
||||||
|
(
|
||||||
|
entity_id,
|
||||||
|
list(dict.fromkeys(state for state in states if state))[:6],
|
||||||
|
)
|
||||||
|
for entity_id, states in state_options.items()
|
||||||
|
if entity_id in selected and states
|
||||||
|
][:6]
|
||||||
|
contexts: list[dict[str, str]] = []
|
||||||
|
if include_current or not option_items:
|
||||||
|
contexts.append(dict(base))
|
||||||
|
if option_items:
|
||||||
|
keys = [item[0] for item in option_items]
|
||||||
|
value_lists = [item[1] for item in option_items]
|
||||||
|
for values in product(*value_lists):
|
||||||
|
context = dict(base)
|
||||||
|
context.update(dict(zip(keys, values, strict=True)))
|
||||||
|
if context not in contexts:
|
||||||
|
contexts.append(context)
|
||||||
|
if len(contexts) >= 64:
|
||||||
|
break
|
||||||
|
return contexts
|
||||||
|
|
||||||
|
|
||||||
def _knowledge_lines(
|
def _knowledge_lines(
|
||||||
record: ActuatorRecord,
|
record: ActuatorRecord,
|
||||||
sample_count: int,
|
sample_count: int,
|
||||||
@@ -1756,6 +1931,7 @@ def predict_behavior(
|
|||||||
min_support: int,
|
min_support: int,
|
||||||
window_minutes: int,
|
window_minutes: int,
|
||||||
current_context_changed_at: dict[str, datetime | None] | None = None,
|
current_context_changed_at: dict[str, datetime | None] | None = None,
|
||||||
|
context_weights: dict[str, float] | None = None,
|
||||||
causal_window_seconds: int = 120,
|
causal_window_seconds: int = 120,
|
||||||
timezone_name: str = "Europe/Berlin",
|
timezone_name: str = "Europe/Berlin",
|
||||||
) -> BehaviorPrediction | None:
|
) -> BehaviorPrediction | None:
|
||||||
@@ -1786,14 +1962,10 @@ def predict_behavior(
|
|||||||
for entity_id, expected in pattern.context_states.items()
|
for entity_id, expected in pattern.context_states.items()
|
||||||
if entity_id in current_context
|
if entity_id in current_context
|
||||||
]
|
]
|
||||||
context_score = (
|
context_score = _weighted_context_score(
|
||||||
sum(
|
comparable,
|
||||||
current_context[entity_id] == expected
|
current_context,
|
||||||
for entity_id, expected in comparable
|
context_weights or {},
|
||||||
)
|
|
||||||
/ len(comparable)
|
|
||||||
if comparable
|
|
||||||
else 0.5
|
|
||||||
)
|
)
|
||||||
score = pattern.weight * (0.85 + 0.15 * context_score)
|
score = pattern.weight * (0.85 + 0.15 * context_score)
|
||||||
by_state.setdefault(pattern.target_state, []).append(score)
|
by_state.setdefault(pattern.target_state, []).append(score)
|
||||||
@@ -1817,11 +1989,10 @@ def predict_behavior(
|
|||||||
for entity_id, expected in pattern.context_states.items()
|
for entity_id, expected in pattern.context_states.items()
|
||||||
if entity_id in current_context
|
if entity_id in current_context
|
||||||
]
|
]
|
||||||
context_score = (
|
context_score = _weighted_context_score(
|
||||||
sum(current_context[entity_id] == expected for entity_id, expected in comparable)
|
comparable,
|
||||||
/ len(comparable)
|
current_context,
|
||||||
if comparable
|
context_weights or {},
|
||||||
else 0.5
|
|
||||||
)
|
)
|
||||||
score = pattern.weight * (
|
score = pattern.weight * (
|
||||||
0.45 * time_score + 0.45 * context_score + 0.10 * weekday_score
|
0.45 * time_score + 0.45 * context_score + 0.10 * weekday_score
|
||||||
@@ -1854,6 +2025,25 @@ def predict_behavior(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _weighted_context_score(
|
||||||
|
comparable: list[tuple[str, str]],
|
||||||
|
current_context: dict[str, str | None],
|
||||||
|
context_weights: dict[str, float],
|
||||||
|
) -> float:
|
||||||
|
if not comparable:
|
||||||
|
return 0.5
|
||||||
|
total_weight = 0.0
|
||||||
|
matched_weight = 0.0
|
||||||
|
for entity_id, expected in comparable:
|
||||||
|
weight = max(0.0, min(1.0, context_weights.get(entity_id, 1.0)))
|
||||||
|
total_weight += weight
|
||||||
|
if current_context.get(entity_id) == expected:
|
||||||
|
matched_weight += weight
|
||||||
|
if total_weight <= 0:
|
||||||
|
return 0.5
|
||||||
|
return matched_weight / total_weight
|
||||||
|
|
||||||
|
|
||||||
def service_for_state(domain: str, target_state: str) -> str | None:
|
def service_for_state(domain: str, target_state: str) -> str | None:
|
||||||
if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}:
|
if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}:
|
||||||
return {"on": "turn_on", "off": "turn_off"}.get(target_state)
|
return {"on": "turn_on", "off": "turn_off"}.get(target_state)
|
||||||
|
|||||||
44
app/main.py
44
app/main.py
@@ -117,7 +117,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.7.0",
|
version="1.7.2",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
app.state.settings = load_settings()
|
app.state.settings = load_settings()
|
||||||
@@ -255,6 +255,9 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
|||||||
ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
|
ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
|
||||||
auth_token = cast(str, settings.ha_token)
|
auth_token = cast(str, settings.ha_token)
|
||||||
ws_status = getattr(app.state, "ws_status", None)
|
ws_status = getattr(app.state, "ws_status", None)
|
||||||
|
reconnect_delay = 1.0
|
||||||
|
relevant_entity_ids: set[str] = set()
|
||||||
|
relevant_loaded_at = 0.0
|
||||||
while True:
|
while True:
|
||||||
if ws_status is not None:
|
if ws_status is not None:
|
||||||
ws_status.status = "connecting"
|
ws_status.status = "connecting"
|
||||||
@@ -283,6 +286,9 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
|||||||
|
|
||||||
logger.info("WebSocket-Verbindung zu Home Assistant hergestellt")
|
logger.info("WebSocket-Verbindung zu Home Assistant hergestellt")
|
||||||
state_cache = await asyncio.to_thread(_load_ha_state_cache, ha_reader)
|
state_cache = await asyncio.to_thread(_load_ha_state_cache, ha_reader)
|
||||||
|
relevant_entity_ids = await asyncio.to_thread(_relevant_entity_ids, store)
|
||||||
|
relevant_loaded_at = asyncio.get_running_loop().time()
|
||||||
|
reconnect_delay = 1.0
|
||||||
if ws_status is not None:
|
if ws_status is not None:
|
||||||
ws_status.status = "connected"
|
ws_status.status = "connected"
|
||||||
ws_status.error = None
|
ws_status.error = None
|
||||||
@@ -309,6 +315,12 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
|||||||
entity_id = event_data.get("entity_id")
|
entity_id = event_data.get("entity_id")
|
||||||
if not entity_id:
|
if not entity_id:
|
||||||
continue
|
continue
|
||||||
|
loop_time = asyncio.get_running_loop().time()
|
||||||
|
if loop_time - relevant_loaded_at >= 10:
|
||||||
|
relevant_entity_ids = await asyncio.to_thread(_relevant_entity_ids, store)
|
||||||
|
relevant_loaded_at = loop_time
|
||||||
|
if entity_id not in relevant_entity_ids:
|
||||||
|
continue
|
||||||
new_state = event_data.get("new_state")
|
new_state = event_data.get("new_state")
|
||||||
_update_ha_state_cache(state_cache, entity_id, new_state)
|
_update_ha_state_cache(state_cache, entity_id, new_state)
|
||||||
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
|
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
|
||||||
@@ -328,17 +340,24 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
|||||||
websockets.exceptions.InvalidStatus,
|
websockets.exceptions.InvalidStatus,
|
||||||
OSError,
|
OSError,
|
||||||
) as exc:
|
) as exc:
|
||||||
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 1s...", exc)
|
delay = reconnect_delay
|
||||||
|
logger.warning(
|
||||||
|
"WebSocket-Verbindung unterbrochen: %s. Wiederholung in %.0fs...",
|
||||||
|
exc,
|
||||||
|
delay,
|
||||||
|
)
|
||||||
if ws_status is not None:
|
if ws_status is not None:
|
||||||
ws_status.status = "reconnecting"
|
ws_status.status = "reconnecting"
|
||||||
ws_status.error = str(exc)
|
ws_status.error = str(exc)
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(delay)
|
||||||
|
reconnect_delay = min(reconnect_delay * 2, 60.0)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc)
|
logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc)
|
||||||
if ws_status is not None:
|
if ws_status is not None:
|
||||||
ws_status.status = "error"
|
ws_status.status = "error"
|
||||||
ws_status.error = str(exc)
|
ws_status.error = str(exc)
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(reconnect_delay)
|
||||||
|
reconnect_delay = min(reconnect_delay * 2, 60.0)
|
||||||
|
|
||||||
|
|
||||||
# Fallback: periodische Vorhersage falls Event-Stream ausfällt
|
# Fallback: periodische Vorhersage falls Event-Stream ausfällt
|
||||||
@@ -353,7 +372,7 @@ async def _fallback_prediction(app: FastAPI) -> None:
|
|||||||
await asyncio.sleep(
|
await asyncio.sleep(
|
||||||
app.state.settings.prediction_interval_seconds
|
app.state.settings.prediction_interval_seconds
|
||||||
if websocket_connected
|
if websocket_connected
|
||||||
else min(5, app.state.settings.prediction_interval_seconds)
|
else max(30, app.state.settings.prediction_interval_seconds)
|
||||||
)
|
)
|
||||||
# Nur ausführen, wenn WebSocket nicht verbunden ist
|
# Nur ausführen, wenn WebSocket nicht verbunden ist
|
||||||
ws_status = getattr(app.state, "ws_status", None)
|
ws_status = getattr(app.state, "ws_status", None)
|
||||||
@@ -389,15 +408,14 @@ def _update_ha_state_cache(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_relevant_state_change(store: ActuatorStore, entity_id: str) -> bool:
|
def _relevant_entity_ids(store: ActuatorStore) -> set[str]:
|
||||||
|
result: set[str] = set()
|
||||||
for record in store.list():
|
for record in store.list():
|
||||||
if record.actuator_entity_id == entity_id:
|
result.add(record.actuator_entity_id)
|
||||||
return True
|
if record.assignment.selected_numeric_entity_id:
|
||||||
if record.assignment.selected_numeric_entity_id == entity_id:
|
result.add(record.assignment.selected_numeric_entity_id)
|
||||||
return True
|
result.update(record.assignment.selected_context_entity_ids)
|
||||||
if entity_id in record.assignment.selected_context_entity_ids:
|
return result
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _ha_entity_from_event(
|
def _ha_entity_from_event(
|
||||||
|
|||||||
@@ -1252,6 +1252,42 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
<button class="secondary" onclick="saveWeightOverrides('${escapeHtml(record.actuator_entity_id)}', true)">Als Gruppe speichern</button>
|
<button class="secondary" onclick="saveWeightOverrides('${escapeHtml(record.actuator_entity_id)}', true)">Als Gruppe speichern</button>
|
||||||
</details>
|
</details>
|
||||||
`;
|
`;
|
||||||
|
const simulationControls = weightedCandidates.length ? `
|
||||||
|
<details class="manual-context" open>
|
||||||
|
<summary>Aktor-Simulation</summary>
|
||||||
|
<p class="muted">Teste Sensorzustände und Gewichtungen, ohne Home Assistant zu schalten.</p>
|
||||||
|
<div class="card-list">
|
||||||
|
${weightedCandidates.map(candidate => {
|
||||||
|
const effective = Math.round((candidate.effective_weight ?? 1) * 100);
|
||||||
|
const currentState = candidate.state || "";
|
||||||
|
const stateOptions = candidate.domain === "binary_sensor"
|
||||||
|
? "off,on"
|
||||||
|
: currentState;
|
||||||
|
return `
|
||||||
|
<article class="actuator-card">
|
||||||
|
<div class="card-title">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>
|
||||||
|
<div class="entity-id">${escapeHtml(candidate.entity_id)}</div>
|
||||||
|
</div>
|
||||||
|
<span class="chip">Simulation</span>
|
||||||
|
</div>
|
||||||
|
<label for="sim-state-${escapeHtml(candidate.entity_id)}">Simulierter Zustand</label>
|
||||||
|
<input id="sim-state-${escapeHtml(candidate.entity_id)}" data-sim-state-entity="${escapeHtml(candidate.entity_id)}" value="${escapeHtml(currentState)}" placeholder="on, off, 12 ...">
|
||||||
|
<label for="sim-options-${escapeHtml(candidate.entity_id)}">Zustände vergleichen</label>
|
||||||
|
<input id="sim-options-${escapeHtml(candidate.entity_id)}" data-sim-options-entity="${escapeHtml(candidate.entity_id)}" value="${escapeHtml(stateOptions)}" placeholder="on,off">
|
||||||
|
<label for="sim-weight-${escapeHtml(candidate.entity_id)}">Simulierte Gewichtung in %</label>
|
||||||
|
<input id="sim-weight-${escapeHtml(candidate.entity_id)}" data-sim-weight-entity="${escapeHtml(candidate.entity_id)}" type="number" min="0" max="100" step="5" value="${effective}">
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}).join("")}
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="secondary" onclick="simulateActuator('${escapeHtml(record.actuator_entity_id)}')">Bestes Szenario berechnen</button>
|
||||||
|
</div>
|
||||||
|
<div id="simulation-result" class="decision-list"></div>
|
||||||
|
</details>
|
||||||
|
` : "<p class='muted'>Für die Simulation müssen zuerst Kontextsensoren ausgewählt sein.</p>";
|
||||||
const currentContextControls = contexts.length
|
const currentContextControls = contexts.length
|
||||||
? `<ul>${contexts.map(entityId => `
|
? `<ul>${contexts.map(entityId => `
|
||||||
<li>
|
<li>
|
||||||
@@ -1510,6 +1546,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
<h3>Sensor-Gewichtung</h3>
|
<h3>Sensor-Gewichtung</h3>
|
||||||
${weightControls}
|
${weightControls}
|
||||||
${weightGroupControls}
|
${weightGroupControls}
|
||||||
|
${simulationControls}
|
||||||
<h3>Verwendete Sensoren/Zustände ändern</h3>
|
<h3>Verwendete Sensoren/Zustände ändern</h3>
|
||||||
${currentContextControls}
|
${currentContextControls}
|
||||||
${manualAssignment}
|
${manualAssignment}
|
||||||
@@ -1610,6 +1647,58 @@ async function saveWeightOverrides(actuatorId, includeNewGroup = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function simulateActuator(actuatorId) {
|
||||||
|
const sensorStates = {};
|
||||||
|
const sensorWeights = {};
|
||||||
|
const stateOptions = {};
|
||||||
|
for (const input of document.querySelectorAll("[data-sim-state-entity]")) {
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (value) sensorStates[input.dataset.simStateEntity] = value;
|
||||||
|
}
|
||||||
|
for (const input of document.querySelectorAll("[data-sim-weight-entity]")) {
|
||||||
|
const value = Number(input.value);
|
||||||
|
if (Number.isFinite(value)) {
|
||||||
|
sensorWeights[input.dataset.simWeightEntity] = Math.max(0, Math.min(100, value)) / 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const input of document.querySelectorAll("[data-sim-options-entity]")) {
|
||||||
|
const values = input.value.split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||||
|
if (values.length) stateOptions[input.dataset.simOptionsEntity] = values;
|
||||||
|
}
|
||||||
|
const box = document.getElementById("simulation-result");
|
||||||
|
box.innerHTML = "<p class='muted'>Simulation läuft ...</p>";
|
||||||
|
try {
|
||||||
|
const results = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/simulate`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
sensor_states: sensorStates,
|
||||||
|
sensor_weights: sensorWeights,
|
||||||
|
state_options: stateOptions,
|
||||||
|
max_results: 6,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
box.innerHTML = results.length ? results.map((result, index) => {
|
||||||
|
const prediction = result.prediction;
|
||||||
|
const factors = result.decision_factors || [];
|
||||||
|
return `
|
||||||
|
<div class="decision-row">
|
||||||
|
<header>
|
||||||
|
<strong>${index === 0 ? "Bestes Szenario" : `Szenario ${index + 1}`}</strong>
|
||||||
|
<span class="chip">${prediction ? `${Math.round(prediction.confidence * 100)} % · ${escapeHtml(prediction.target_state)}` : "keine Vorhersage"}</span>
|
||||||
|
</header>
|
||||||
|
<p>${escapeHtml(result.recommendation || "")}</p>
|
||||||
|
<p class="muted">Zustände: ${Object.entries(result.sensor_states || {}).map(([entity, state]) => `${escapeHtml(entity)}=${escapeHtml(state)}`).join(", ") || "keine"}</p>
|
||||||
|
<p class="muted">Gewichtung: ${Object.entries(result.sensor_weights || {}).map(([entity, weight]) => `${escapeHtml(entity)}=${Math.round(weight * 100)} %`).join(", ") || "Standard"}</p>
|
||||||
|
${result.blockers?.length ? `<p class="warn">${result.blockers.map(escapeHtml).join(" ")}</p>` : "<p class='ok'>Würde nach Sicherheitsprüfung schalten.</p>"}
|
||||||
|
${factors.length ? `<ul>${factors.slice(0, 4).map(factor => `<li>${escapeHtml(factor.label)}: ${Math.round((factor.contribution || 0) * 100)} % Beitrag</li>`).join("")}</ul>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("") : "<p class='muted'>Keine Simulationsergebnisse.</p>";
|
||||||
|
} catch (error) {
|
||||||
|
box.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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.7.0"
|
version = "1.7.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 = [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -10,7 +11,7 @@ from fastapi.testclient import TestClient
|
|||||||
from app.api.v1.actuators import _deduplicate_actuator_ids
|
from app.api.v1.actuators import _deduplicate_actuator_ids
|
||||||
from app.actuators.cache_db import DashboardCache
|
from app.actuators.cache_db import DashboardCache
|
||||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||||
from app.actuators.models import JobStatus, ModelSnapshot
|
from app.actuators.models import BehaviorPattern, JobStatus, ModelSnapshot
|
||||||
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
|
||||||
@@ -272,6 +273,91 @@ def test_weight_override_endpoint_updates_sensor_relevance(tmp_path: Path) -> No
|
|||||||
assert numeric["sensor.abstellkammer_illuminance"]["effective_weight"] == 0.75
|
assert numeric["sensor.abstellkammer_illuminance"]["effective_weight"] == 0.75
|
||||||
|
|
||||||
|
|
||||||
|
def test_actuator_simulation_ranks_sensor_states_without_switching(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"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
store = app.state.actuator_store
|
||||||
|
record = store.get("light.abstellkammer")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
local = now.astimezone(ZoneInfo("Europe/Berlin"))
|
||||||
|
local_minute = local.hour * 60 + local.minute
|
||||||
|
patterns = [
|
||||||
|
BehaviorPattern(
|
||||||
|
target_state="on",
|
||||||
|
minute_of_day=local_minute,
|
||||||
|
weekday=now.weekday(),
|
||||||
|
context_states={
|
||||||
|
"sensor.abstellkammer_illuminance": "12",
|
||||||
|
"binary_sensor.abstellkammer_motion": "on",
|
||||||
|
},
|
||||||
|
source="user",
|
||||||
|
weight=1.0,
|
||||||
|
observed_at=now,
|
||||||
|
)
|
||||||
|
for _ in range(3)
|
||||||
|
]
|
||||||
|
patterns.extend(
|
||||||
|
[
|
||||||
|
BehaviorPattern(
|
||||||
|
target_state="off",
|
||||||
|
minute_of_day=local_minute,
|
||||||
|
weekday=now.weekday(),
|
||||||
|
context_states={
|
||||||
|
"sensor.abstellkammer_illuminance": "12",
|
||||||
|
"binary_sensor.abstellkammer_motion": "off",
|
||||||
|
},
|
||||||
|
source="user",
|
||||||
|
weight=0.5,
|
||||||
|
observed_at=now,
|
||||||
|
)
|
||||||
|
for _ in range(3)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
store.upsert(
|
||||||
|
record.model_copy(
|
||||||
|
update={
|
||||||
|
"behavior": record.behavior.model_copy(
|
||||||
|
update={
|
||||||
|
"patterns": patterns,
|
||||||
|
"sample_count": len(patterns),
|
||||||
|
"high_confidence_sample_count": len(patterns),
|
||||||
|
"activation_ready": True,
|
||||||
|
"activation_reason": "Testfreigabe.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/v1/actuators/light.abstellkammer/simulate",
|
||||||
|
json={
|
||||||
|
"state_options": {"binary_sensor.abstellkammer_motion": ["off", "on"]},
|
||||||
|
"sensor_weights": {
|
||||||
|
"binary_sensor.abstellkammer_motion": 1.0,
|
||||||
|
"sensor.abstellkammer_illuminance": 0.25,
|
||||||
|
},
|
||||||
|
"max_results": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert len(payload) == 2
|
||||||
|
assert payload[0]["prediction"]["target_state"] == "on"
|
||||||
|
assert payload[0]["sensor_states"]["binary_sensor.abstellkammer_motion"] == "on"
|
||||||
|
assert payload[0]["sensor_weights"]["sensor.abstellkammer_illuminance"] == 0.25
|
||||||
|
assert app.state.ha_reader.service_calls == []
|
||||||
|
|
||||||
|
|
||||||
def test_safety_profile_can_block_actuator_manually(tmp_path: Path) -> None:
|
def test_safety_profile_can_block_actuator_manually(tmp_path: Path) -> None:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
_install_service(tmp_path)
|
_install_service(tmp_path)
|
||||||
|
|||||||
@@ -126,6 +126,43 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
|
|||||||
assert mock_app.state.ws_status.error is None
|
assert mock_app.state.ws_status.error is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ha_event_listener_skips_unrelated_state_change(tmp_path: Path) -> None:
|
||||||
|
async def run_test() -> None:
|
||||||
|
fake_ws = _FakeWebSocket(
|
||||||
|
[
|
||||||
|
'{"type":"auth_required"}',
|
||||||
|
'{"type":"auth_ok"}',
|
||||||
|
(
|
||||||
|
'{"type":"event","event":{"event_type":"state_changed",'
|
||||||
|
'"data":{"entity_id":"sensor.unused","new_state":{"state":"on"}}}}'
|
||||||
|
),
|
||||||
|
asyncio.CancelledError(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("websockets.connect", return_value=fake_ws):
|
||||||
|
try:
|
||||||
|
await _ha_event_listener(mock_app, mock_client)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_app.state.settings = MagicMock()
|
||||||
|
mock_app.state.settings.ha_url = "http://homeassistant:8123"
|
||||||
|
mock_app.state.settings.ha_token = "test-token"
|
||||||
|
mock_app.state.ws_status = MagicMock()
|
||||||
|
mock_engine = _RecordingBehaviorEngine(tmp_path)
|
||||||
|
mock_app.state.behavior_engine = mock_engine
|
||||||
|
mock_app.state.ha_reader = _FakeHaReader()
|
||||||
|
mock_store = ActuatorStore(tmp_path / "store")
|
||||||
|
mock_store.configure("light.test")
|
||||||
|
mock_app.state.actuator_store = mock_store
|
||||||
|
mock_client = MagicMock()
|
||||||
|
|
||||||
|
anyio.run(run_test)
|
||||||
|
assert mock_engine.state_changes == []
|
||||||
|
|
||||||
|
|
||||||
def test_lifespan_skips_event_listener_without_ha_config() -> None:
|
def test_lifespan_skips_event_listener_without_ha_config() -> None:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.state.settings = MagicMock()
|
app.state.settings = MagicMock()
|
||||||
|
|||||||
Reference in New Issue
Block a user