Compare commits
194 Commits
dynamic_da
...
caroutlet
| Author | SHA1 | Date | |
|---|---|---|---|
|
fec97e54c1
|
|||
|
743e84560d
|
|||
|
f25ab6a3a1
|
|||
|
b08a3f2564
|
|||
|
db43854156
|
|||
|
3d759bd3ff
|
|||
|
7193c2be7f
|
|||
|
02596f4796
|
|||
|
e316ec0f58
|
|||
|
18481d9970
|
|||
|
84fe6eea96
|
|||
|
84e401778e
|
|||
|
4ee3c13d3e
|
|||
|
d685366c09
|
|||
|
07b28e2f1f
|
|||
|
39bfb66098
|
|||
|
75860cd1c2
|
|||
|
bcbb58ea36
|
|||
|
b38ed75261
|
|||
|
feb055b2ea
|
|||
|
cce730b2fa
|
|||
|
a26901037d
|
|||
|
4889f5ed8b
|
|||
|
804e9bf742
|
|||
|
f60d5d03e9
|
|||
|
eff88e1d2f
|
|||
|
d027163087
|
|||
|
4051ca22a4
|
|||
|
2608e935b8
|
|||
|
51f3b4f227
|
|||
|
006359687f
|
|||
|
f26d304890
|
|||
|
6feec48ac6
|
|||
|
ed6ed66a37
|
|||
|
09498dd0e5
|
|||
|
41f5e06e30
|
|||
|
7769c6066a
|
|||
|
5f23e28cc0
|
|||
|
cc083c1055
|
|||
|
37b773143f
|
|||
|
27c0990400
|
|||
|
b150cd895e
|
|||
|
f67831c8bd
|
|||
|
b61e7293ae
|
|||
|
a85fd1ccf0
|
|||
|
19a3dfdd65
|
|||
|
57b4d7d762
|
|||
|
826d73990b
|
|||
|
86587402b6
|
|||
|
110b903dc8
|
|||
|
a8f6ad800d
|
|||
|
52a50e1cd4
|
|||
|
9d7e26677f
|
|||
|
ebd459bfc1
|
|||
|
e49c5affe9
|
|||
|
5595cf4f37
|
|||
|
f61417a631
|
|||
|
6d62732d1d
|
|||
|
73814df01e
|
|||
|
4b7ac9b82b
|
|||
|
850d810cb3
|
|||
|
5bdfbacc3c
|
|||
|
3d9043b8fa
|
|||
|
1890a83939
|
|||
|
23edd42fca
|
|||
|
5fbaab3c11
|
|||
|
2eefbcd44b
|
|||
|
d09caa9d92
|
|||
|
127b5857c3
|
|||
|
c2dcb733d8
|
|||
|
168b2c4b12
|
|||
|
66872703c1
|
|||
|
d4b44fa73e
|
|||
|
87a86524d4
|
|||
|
91fdfde280
|
|||
|
d138d7bf0a
|
|||
|
8b08ded0c6
|
|||
|
6a0601742a
|
|||
|
99580f8ff9
|
|||
|
381f8521d4
|
|||
|
a382d58601
|
|||
|
0ca5ffaca0
|
|||
|
ec193a92f8
|
|||
|
c473ea341e
|
|||
|
0b6b9a01fb
|
|||
|
734ee18e85
|
|||
|
d9d17d37e5
|
|||
|
29e2fd441b
|
|||
|
f902f40386
|
|||
|
6483cf0740
|
|||
|
2863f5f5a6
|
|||
|
3e02decc02
|
|||
|
3e9e388ddd
|
|||
|
1f6fe134d5
|
|||
|
05884bb99a
|
|||
|
5d08ec970a
|
|||
|
732c333966
|
|||
|
c685e65be5
|
|||
|
2eb9d323de
|
|||
|
d9d2033dd7
|
|||
|
04c8cc8577
|
|||
|
e140227f7a
|
|||
|
cc25a22025
|
|||
|
af6ab012dd
|
|||
|
a2c2ef7ddd
|
|||
|
ecf31c7f8b
|
|||
|
74d4fea695
|
|||
|
c38fdab0ad
|
|||
|
094cef0bd7
|
|||
|
1027b905b5
|
|||
|
204a70a438
|
|||
|
3a2702e9aa
|
|||
|
9e66e16986
|
|||
|
aaee480e57
|
|||
|
d0b5184270
|
|||
|
5bf37a19ad
|
|||
|
2e24c259cb
|
|||
|
bbf280bdf4
|
|||
|
a7d778b211
|
|||
|
a7d8afc98b
|
|||
|
a4ae8a2f6c
|
|||
|
6152385339
|
|||
|
c2b7328219
|
|||
|
99362b346f
|
|||
|
77d29c3a42
|
|||
|
ef3b1177d2
|
|||
|
8bbe9c164f
|
|||
|
65f8a0c7cb
|
|||
|
cbe7e11cf2
|
|||
|
9bf336fa11
|
|||
|
b82217a666
|
|||
|
5851414ba5
|
|||
|
4c5475e930
|
|||
|
b6b441c0ca
|
|||
|
d3d96ed3e9
|
|||
|
2e2963488b
|
|||
|
7928bc596f
|
|||
|
3874eaed83
|
|||
|
0f43f37823
|
|||
|
93e70da97d
|
|||
|
62d302bf41
|
|||
|
3d6130f2c2
|
|||
|
2a8d569bb5
|
|||
|
6a5f814cb4
|
|||
|
cc3c15078c
|
|||
|
7772dac000
|
|||
|
97ea853483
|
|||
|
86d1933c1f
|
|||
|
9458381593
|
|||
|
f389115841
|
|||
|
19a6a603d5
|
|||
|
e728dd58e4
|
|||
|
6310fedeea
|
|||
|
e113616abf
|
|||
|
e8cd34f88f
|
|||
|
1bd175c912
|
|||
|
cc566c9e73
|
|||
|
2eb4f3c376
|
|||
|
b57ddb1589
|
|||
|
a49d56df60
|
|||
|
5a7b16f7aa
|
|||
|
e69822719a
|
|||
|
25a6b98d41
|
|||
|
5f7af7574c
|
|||
|
0c73e36e82
|
|||
|
01b60671db
|
|||
|
b60fdfced4
|
|||
|
0cd0c6de41
|
|||
|
ecf5aebc3c
|
|||
|
79d87aff6a
|
|||
|
b1e9b201d1
|
|||
|
1eff8a2044
|
|||
|
8fd0921a08
|
|||
|
7304a017c2
|
|||
|
db6da4815c
|
|||
|
54f53705c0
|
|||
|
f8144496b3
|
|||
|
50e7402152
|
|||
|
eb822c0318
|
|||
|
acb5e0a209
|
|||
|
4b196c1278
|
|||
|
7e04991d64
|
|||
|
cc3364068a
|
|||
|
c1cbca39bf
|
|||
|
6271f46019
|
|||
|
6bf8ac3f99
|
|||
|
b7efae61c4
|
|||
|
e76cb3dc21
|
|||
|
c004bcee24
|
|||
|
723441bd19
|
|||
|
e28633cb9a
|
|||
|
cb555a1f67
|
|||
|
478450794f
|
|||
|
0000e81d7a
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -61,3 +61,6 @@ Thumbs.db
|
||||
|
||||
# Poetry
|
||||
poetry.lock
|
||||
|
||||
apps/homekit/homekit.state
|
||||
|
||||
|
||||
49
.woodpecker.yml
Normal file
49
.woodpecker.yml
Normal file
@@ -0,0 +1,49 @@
|
||||
matrix:
|
||||
APP:
|
||||
- ui
|
||||
- api
|
||||
- abstraction
|
||||
- rules
|
||||
|
||||
env:
|
||||
NAMESPACE: "homea2"
|
||||
|
||||
steps:
|
||||
build:
|
||||
image: plugins/kaniko
|
||||
settings:
|
||||
repo: ${FORGE_NAME}/${CI_REPO}/${APP}
|
||||
registry:
|
||||
from_secret: container_registry
|
||||
auto_tag: true
|
||||
username:
|
||||
from_secret: container_registry_username
|
||||
password:
|
||||
from_secret: container_registry_password
|
||||
dockerfile: apps/${APP}/Dockerfile
|
||||
when:
|
||||
event: [push, tag]
|
||||
ref:
|
||||
exclude:
|
||||
- refs/tags/*-configchange
|
||||
|
||||
create_namespace:
|
||||
image: quay.io/wollud1969/k8s-admin-helper:0.3.4
|
||||
environment:
|
||||
KUBE_CONFIG_CONTENT:
|
||||
from_secret: kube_config
|
||||
commands:
|
||||
- kubectl create namespace ${NAMESPACE} || echo "Namespace ${NAMESPACE} already exists"
|
||||
when:
|
||||
- event: [tag]
|
||||
|
||||
configuration:
|
||||
image: quay.io/wollud1969/k8s-admin-helper:0.3.4
|
||||
environment:
|
||||
KUBE_CONFIG_CONTENT:
|
||||
from_secret: kube_config
|
||||
commands:
|
||||
|
||||
when:
|
||||
- event: [tag]
|
||||
|
||||
43
.woodpecker/build.yml
Normal file
43
.woodpecker/build.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
matrix:
|
||||
APP:
|
||||
- ui
|
||||
- api
|
||||
- abstraction
|
||||
- rules
|
||||
|
||||
steps:
|
||||
show:
|
||||
image: quay.io/wollud1969/networktools:latest
|
||||
environment:
|
||||
forge_name:
|
||||
from_secret: forge_name
|
||||
container_registry:
|
||||
from_secret: container_registry
|
||||
container_registry_username:
|
||||
from_secret: container_registry_username
|
||||
container_registry_password:
|
||||
from_secret: container_registry_password
|
||||
commands:
|
||||
- echo $${forge_name} | base64
|
||||
- echo $${container_registry} | base64
|
||||
- echo $${container_registry_username} | base64
|
||||
- echo $${container_registry_password} | base64
|
||||
|
||||
|
||||
|
||||
|
||||
build:
|
||||
image: plugins/kaniko
|
||||
settings:
|
||||
repo: ${FORGE_NAME}/${CI_REPO}/${APP}
|
||||
auto_tag: true
|
||||
dockerfile: apps/${APP}/Dockerfile
|
||||
username:
|
||||
from_secret: docker_hub_username
|
||||
password:
|
||||
from_secret: docker_hub_password
|
||||
when:
|
||||
event: [push, tag]
|
||||
ref:
|
||||
exclude:
|
||||
- refs/tags/*-configchange
|
||||
37
.woodpecker/predeploy.yml
Normal file
37
.woodpecker/predeploy.yml
Normal file
@@ -0,0 +1,37 @@
|
||||
steps:
|
||||
create_namespace:
|
||||
image: quay.io/wollud1969/k8s-admin-helper:0.3.4
|
||||
environment:
|
||||
KUBE_CONFIG_CONTENT:
|
||||
from_secret: kube_config
|
||||
NAMESPACE: "homea2"
|
||||
commands:
|
||||
- printf "$KUBE_CONFIG_CONTENT" > /tmp/kubeconfig
|
||||
- export KUBECONFIG=/tmp/kubeconfig
|
||||
- kubectl create namespace $NAMESPACE || echo "Namespace $NAMESPACE already exists"
|
||||
when:
|
||||
event: [tag]
|
||||
ref:
|
||||
exclude:
|
||||
- refs/tags/*-configchange
|
||||
|
||||
apply_configuration:
|
||||
image: quay.io/wollud1969/k8s-admin-helper:0.3.4
|
||||
environment:
|
||||
KUBE_CONFIG_CONTENT:
|
||||
from_secret: kube_config
|
||||
NAMESPACE: "homea2"
|
||||
commands:
|
||||
- printf "$KUBE_CONFIG_CONTENT" > /tmp/kubeconfig
|
||||
- export KUBECONFIG=/tmp/kubeconfig
|
||||
- kubectl create configmap home-automation-config
|
||||
--from-file=devices=config/devices.yaml
|
||||
--from-file=groups=config/groups.yaml
|
||||
--from-file=layout=config/layout.yaml
|
||||
--from-file=rules=config/rules.yaml
|
||||
--from-file=scenes=config/scenes.yaml
|
||||
--namespace=$NAMESPACE
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
when:
|
||||
event: [tag]
|
||||
|
||||
53
PORTS.md
53
PORTS.md
@@ -1,53 +0,0 @@
|
||||
# Port Configuration
|
||||
|
||||
This document describes the port allocation for the home automation services.
|
||||
|
||||
## Port Scan Results (31. Oktober 2025)
|
||||
|
||||
### Ports in Use
|
||||
- **8000**: In use (likely API server)
|
||||
- **8021**: In use (system service)
|
||||
- **8080**: In use (system service)
|
||||
- **8100**: In use (system service)
|
||||
- **8200**: In use (system service)
|
||||
- **8770**: In use (system service)
|
||||
|
||||
### Free Ports Found
|
||||
- **8001**: FREE ✓
|
||||
- **8002**: FREE ✓
|
||||
- **8003**: FREE ✓
|
||||
- **8004**: FREE ✓
|
||||
- **8005**: FREE ✓
|
||||
|
||||
## Service Port Allocation
|
||||
|
||||
| Service | Port | Purpose |
|
||||
|---------|------|---------|
|
||||
| API | 8001 | FastAPI REST API for capabilities and health checks |
|
||||
| UI | 8002 | FastAPI web interface with Jinja2 templates |
|
||||
| (Reserved) | 8003 | Available for future services |
|
||||
| (Reserved) | 8004 | Available for future services |
|
||||
| (Reserved) | 8005 | Available for future services |
|
||||
|
||||
## Access URLs
|
||||
|
||||
- **API**: http://localhost:8001
|
||||
- Health: http://localhost:8001/health
|
||||
- Spec: http://localhost:8001/spec
|
||||
- Docs: http://localhost:8001/docs
|
||||
|
||||
- **UI**: http://localhost:8002
|
||||
- Main page: http://localhost:8002/
|
||||
|
||||
## Starting Services
|
||||
|
||||
```bash
|
||||
# Start API
|
||||
poetry run uvicorn apps.api.main:app --reload --port 8001
|
||||
|
||||
# Start UI
|
||||
poetry run uvicorn apps.ui.main:app --reload --port 8002
|
||||
|
||||
# Start Abstraction Worker (no port - MQTT client)
|
||||
poetry run python -m apps.abstraction.main
|
||||
```
|
||||
339
README.md
339
README.md
@@ -1,29 +1,63 @@
|
||||
# Home Automation Monorepo
|
||||
|
||||
A Python-based home automation system built with Poetry in a monorepo structure.
|
||||
A Python-based home automation system built with Poetry in a monorepo structure. Features a microservices architecture with MQTT/Redis messaging, dynamic UI with realtime updates, and flexible device configuration.
|
||||
|
||||
## Features
|
||||
|
||||
- **Dynamic Dashboard**: Responsive web UI with realtime device status via Server-Sent Events
|
||||
- **MQTT Integration**: Device communication through MQTT broker with vendor abstraction
|
||||
- **Realtime Updates**: Live device state updates via Redis Pub/Sub and SSE
|
||||
- **Flexible Layout**: Configure rooms and device tiles via YAML
|
||||
- **Multiple Device Support**: Lights with power and brightness control
|
||||
- **Clean Architecture**: Separation of concerns with API-first design
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
home-automation/
|
||||
├── apps/ # Applications
|
||||
│ ├── api/ # API service
|
||||
│ ├── abstraction/ # Abstraction layer
|
||||
│ ├── rules/ # Rules engine
|
||||
│ └── ui/ # User interface
|
||||
│ ├── api/ # API Gateway (FastAPI, port 8001)
|
||||
│ │ └── main.py # REST API, SSE endpoint, device management
|
||||
│ ├── abstraction/ # MQTT ↔ Redis Bridge
|
||||
│ │ └── main.py # Protocol translation layer
|
||||
│ ├── rules/ # Rules Engine (planned)
|
||||
│ └── ui/ # Web Interface (FastAPI, port 8002)
|
||||
│ ├── main.py # Jinja2 templates, API client
|
||||
│ ├── api_client.py # HTTP client for API Gateway
|
||||
│ ├── templates/ # HTML templates
|
||||
│ │ ├── dashboard.html # Dynamic dashboard
|
||||
│ │ └── index.html # Legacy static UI
|
||||
│ └── static/ # CSS and assets
|
||||
├── packages/ # Shared packages
|
||||
│ └── home_capabilities/ # Home capabilities library
|
||||
│ └── home_capabilities/ # Core libraries
|
||||
│ ├── light.py # Light device models
|
||||
│ └── layout.py # UI layout models
|
||||
├── config/ # Configuration files
|
||||
│ ├── devices.yaml # Device definitions with features
|
||||
│ └── layout.yaml # UI room/device layout
|
||||
├── tools/ # Development tools
|
||||
│ └── sim_test_lampe.py # Multi-device MQTT simulator
|
||||
├── infra/ # Infrastructure
|
||||
│ ├── docker-compose.yml
|
||||
│ └── README.md
|
||||
├── pyproject.toml # Poetry configuration
|
||||
├── PORTS.md # Port allocation
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.11+
|
||||
- Poetry
|
||||
- Python 3.11+ (tested with 3.14.0)
|
||||
- Poetry 2.2.1+
|
||||
- MQTT Broker (e.g., Mosquitto)
|
||||
- Redis Server
|
||||
|
||||
### External Services
|
||||
|
||||
This system requires the following external services:
|
||||
|
||||
- **MQTT Broker**: `172.16.2.16:1883` (configured in `config/devices.yaml`)
|
||||
- **Redis Server**: `172.23.1.116:6379/8` (configured in `config/devices.yaml`)
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -42,8 +76,57 @@ home-automation/
|
||||
poetry shell
|
||||
```
|
||||
|
||||
4. Configure devices and layout:
|
||||
- Edit `config/devices.yaml` for device definitions and MQTT/Redis settings
|
||||
- Edit `config/layout.yaml` for UI room organization
|
||||
|
||||
## Configuration
|
||||
|
||||
### devices.yaml
|
||||
|
||||
Defines available devices with their features and MQTT topics:
|
||||
|
||||
```yaml
|
||||
devices:
|
||||
- device_id: test_lampe_1
|
||||
type: light
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
set: "vendor/test_lampe_1/set"
|
||||
state: "vendor/test_lampe_1/state"
|
||||
```
|
||||
|
||||
### layout.yaml
|
||||
|
||||
Organizes devices into rooms for the UI:
|
||||
|
||||
```yaml
|
||||
rooms:
|
||||
- name: Wohnzimmer
|
||||
devices:
|
||||
- device_id: test_lampe_1
|
||||
title: Stehlampe
|
||||
icon: "LIGHT"
|
||||
rank: 10 # Lower rank = higher priority
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Dependencies
|
||||
|
||||
Key packages installed:
|
||||
|
||||
- **Web Framework**: FastAPI 0.120.3, Uvicorn 0.38.0
|
||||
- **Data Validation**: Pydantic 2.12.3
|
||||
- **MQTT**: aiomqtt 2.4.0 (async), paho-mqtt 2.1.0 (sync)
|
||||
- **Redis**: redis 7.0.1
|
||||
- **HTTP Client**: httpx 0.28.1
|
||||
- **Templates**: Jinja2 3.1.6
|
||||
- **Config**: PyYAML 6.0.3
|
||||
- **Testing**: beautifulsoup4 4.14.2
|
||||
|
||||
### Code Quality Tools
|
||||
|
||||
This project uses the following tools configured in `pyproject.toml`:
|
||||
@@ -65,8 +148,105 @@ poetry run ruff check .
|
||||
poetry run mypy .
|
||||
```
|
||||
|
||||
### Adding New Devices
|
||||
|
||||
1. Add device to `config/devices.yaml`:
|
||||
```yaml
|
||||
- device_id: new_device
|
||||
type: light
|
||||
features:
|
||||
power: true
|
||||
brightness: false
|
||||
topics:
|
||||
set: "vendor/new_device/set"
|
||||
state: "vendor/new_device/state"
|
||||
```
|
||||
|
||||
2. Add device to rooms in `config/layout.yaml`:
|
||||
```yaml
|
||||
rooms:
|
||||
- name: Kitchen
|
||||
devices:
|
||||
- device_id: new_device
|
||||
title: Kitchen Light
|
||||
icon: "LIGHT"
|
||||
rank: 5
|
||||
```
|
||||
|
||||
3. Restart API and UI services (they will auto-reload if using `--reload`)
|
||||
|
||||
4. Device will appear in dashboard automatically!
|
||||
|
||||
### Extending Features
|
||||
|
||||
To add new device capabilities:
|
||||
|
||||
1. Update Pydantic models in `packages/home_capabilities/`
|
||||
2. Add feature to `devices.yaml`
|
||||
3. Extend dashboard template for UI controls
|
||||
4. Update simulator or create new simulator for testing
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Issues
|
||||
|
||||
- **SSE not connecting**: Check API server is running on port 8001
|
||||
- **Device not responding**: Check MQTT broker connectivity
|
||||
- **No updates in UI**: Check abstraction layer and Redis connection
|
||||
|
||||
### Check Logs
|
||||
|
||||
```bash
|
||||
# API logs
|
||||
tail -f /tmp/api.log
|
||||
|
||||
# Abstraction layer logs
|
||||
tail -f /tmp/abstraction.log
|
||||
|
||||
# UI logs
|
||||
tail -f /tmp/ui.log
|
||||
```
|
||||
|
||||
### Common Commands
|
||||
|
||||
```bash
|
||||
# Check if services are running
|
||||
ps aux | grep -E "uvicorn|abstraction"
|
||||
|
||||
# Check port usage
|
||||
lsof -i :8001
|
||||
lsof -i :8002
|
||||
|
||||
# Test MQTT connection
|
||||
mosquitto_pub -h 172.16.2.16 -t test -m "hello"
|
||||
```
|
||||
|
||||
### Running Applications
|
||||
|
||||
#### Quick Start - All Services
|
||||
|
||||
Start all services in the background:
|
||||
|
||||
```bash
|
||||
# 1. Start MQTT Abstraction Layer
|
||||
poetry run python -m apps.abstraction.main > /tmp/abstraction.log 2>&1 &
|
||||
|
||||
# 2. Start API Gateway
|
||||
poetry run uvicorn apps.api.main:app --host 0.0.0.0 --port 8001 > /tmp/api.log 2>&1 &
|
||||
|
||||
# 3. Start UI
|
||||
poetry run uvicorn apps.ui.main:app --host 0.0.0.0 --port 8002 > /tmp/ui.log 2>&1 &
|
||||
|
||||
# 4. Start Device Simulator (optional)
|
||||
poetry run python tools/sim_test_lampe.py &
|
||||
```
|
||||
|
||||
Stop all services:
|
||||
|
||||
```bash
|
||||
pkill -f "uvicorn apps" && pkill -f "apps.abstraction.main" && pkill -f "sim_test_lampe"
|
||||
```
|
||||
|
||||
#### Port Configuration
|
||||
|
||||
See `PORTS.md` for detailed port allocation.
|
||||
@@ -93,7 +273,10 @@ The API will be available at:
|
||||
|
||||
Available endpoints:
|
||||
- `GET /health` - Health check endpoint
|
||||
- `GET /spec` - Capabilities specification
|
||||
- `GET /devices` - List all devices with features
|
||||
- `GET /layout` - Get UI layout configuration
|
||||
- `POST /devices/{device_id}/set` - Control a device
|
||||
- `GET /realtime` - Server-Sent Events stream for live updates
|
||||
|
||||
#### UI Server
|
||||
|
||||
@@ -108,20 +291,142 @@ poetry run python -m apps.ui.main
|
||||
```
|
||||
|
||||
The UI will be available at:
|
||||
- Main page: http://localhost:8002
|
||||
- `GET /spec` - Capabilities specification
|
||||
- **Dynamic Dashboard**: http://localhost:8002/dashboard (or http://localhost:8002)
|
||||
- Realtime device status via SSE
|
||||
- Toggle buttons with state reflection
|
||||
- Brightness sliders for dimmable lights
|
||||
- Event log for all updates
|
||||
- Responsive layout from `config/layout.yaml`
|
||||
- **Legacy Static UI**: http://localhost:8002/index.html
|
||||
- Fixed layout with test_lampe_1 and test_lampe_2
|
||||
|
||||
#### Abstraction Layer
|
||||
|
||||
The MQTT-Redis bridge translates between protocols:
|
||||
|
||||
```bash
|
||||
poetry run python -m apps.abstraction.main
|
||||
```
|
||||
|
||||
Functions:
|
||||
- Subscribes to vendor-specific MQTT topics (`vendor/*/state`)
|
||||
- Publishes state changes to Redis Pub/Sub (`ui:updates`)
|
||||
- Enables decoupling of UI from MQTT
|
||||
|
||||
#### Device Simulator
|
||||
|
||||
Test your setup with the multi-device simulator:
|
||||
|
||||
```bash
|
||||
poetry run python tools/sim_test_lampe.py
|
||||
```
|
||||
|
||||
Simulates:
|
||||
- `test_lampe_1`: Light with power and brightness control
|
||||
- `test_lampe_2`: Simple light with power only
|
||||
- `test_lampe_3`: Simple light with power only
|
||||
|
||||
The simulator:
|
||||
- Subscribes to `vendor/test_lampe_*/set` topics
|
||||
- Maintains device state (power, brightness)
|
||||
- Publishes state to `vendor/test_lampe_*/state` (retained)
|
||||
- Handles graceful shutdown (sets all lights to off)
|
||||
|
||||
#### Other Applications
|
||||
|
||||
```bash
|
||||
# Abstraction
|
||||
poetry run python -m apps.abstraction.main
|
||||
|
||||
# Rules
|
||||
# Rules Engine (planned)
|
||||
poetry run python -m apps.rules.main
|
||||
```
|
||||
|
||||
# UI
|
||||
poetry run python -m apps.ui.main
|
||||
## Architecture
|
||||
|
||||
### Message Flow
|
||||
|
||||
```
|
||||
User Action (UI)
|
||||
→ HTTP POST to API Gateway (/devices/{id}/set)
|
||||
→ MQTT Publish (home/light/{id}/set)
|
||||
→ Abstraction Layer receives
|
||||
→ MQTT Publish (vendor/{id}/set)
|
||||
→ Device/Simulator receives
|
||||
→ Device State Update
|
||||
→ MQTT Publish (vendor/{id}/state, retained)
|
||||
→ Abstraction Layer receives
|
||||
→ Redis Pub/Sub (ui:updates)
|
||||
→ API Gateway /realtime SSE
|
||||
→ UI Updates (EventSource)
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **API Gateway** (`apps/api/main.py`)
|
||||
- Single source of truth for configuration
|
||||
- REST endpoints for device control
|
||||
- SSE endpoint for realtime updates
|
||||
- Reads `devices.yaml` and `layout.yaml`
|
||||
|
||||
2. **UI** (`apps/ui/main.py`)
|
||||
- Pure API consumer (no direct file access)
|
||||
- Fetches devices and layout via HTTP
|
||||
- Renders dynamic dashboard with Jinja2
|
||||
- Connects to SSE for live updates
|
||||
|
||||
3. **Abstraction Layer** (`apps/abstraction/main.py`)
|
||||
- Protocol translation (MQTT ↔ Redis)
|
||||
- Subscribes to vendor topics
|
||||
- Publishes to Redis for UI updates
|
||||
|
||||
4. **Device Simulator** (`tools/sim_test_lampe.py`)
|
||||
- Emulates physical devices
|
||||
- Responds to SET commands
|
||||
- Publishes STATE updates
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing
|
||||
|
||||
1. Start all services (see Quick Start above)
|
||||
|
||||
2. Open the dashboard: http://localhost:8002
|
||||
|
||||
3. Toggle a light and watch:
|
||||
- Button changes state (Einschalten ↔ Ausschalten)
|
||||
- Status updates in realtime
|
||||
- Event log shows all messages
|
||||
|
||||
4. Check MQTT traffic:
|
||||
```bash
|
||||
# Subscribe to all topics
|
||||
mosquitto_sub -h 172.16.2.16 -t '#' -v
|
||||
|
||||
# Publish test command
|
||||
mosquitto_pub -h 172.16.2.16 -t 'vendor/test_lampe_1/set' \
|
||||
-m '{"power":"on","brightness":75}'
|
||||
```
|
||||
|
||||
5. Check Redis Pub/Sub:
|
||||
```bash
|
||||
redis-cli -h 172.23.1.116 -p 6379 -n 8
|
||||
> SUBSCRIBE ui:updates
|
||||
```
|
||||
|
||||
### API Testing
|
||||
|
||||
```bash
|
||||
# Get all devices
|
||||
curl http://localhost:8001/devices | python3 -m json.tool
|
||||
|
||||
# Get layout
|
||||
curl http://localhost:8001/layout | python3 -m json.tool
|
||||
|
||||
# Control a device
|
||||
curl -X POST http://localhost:8001/devices/test_lampe_1/set \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"light","payload":{"power":"on"}}'
|
||||
|
||||
# Test SSE stream
|
||||
curl -N http://localhost:8001/realtime
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
44
apps/abstraction/Dockerfile
Normal file
44
apps/abstraction/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
# Abstraction Layer Dockerfile
|
||||
# MQTT ↔ Device Protocol Translation Worker
|
||||
|
||||
FROM python:3.14-alpine
|
||||
|
||||
# Prevent Python from writing .pyc files and enable unbuffered output
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
MQTT_BROKER=172.16.2.16 \
|
||||
MQTT_PORT=1883 \
|
||||
REDIS_HOST=localhost \
|
||||
REDIS_PORT=6379 \
|
||||
REDIS_DB=0
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 10001 -S app && \
|
||||
adduser -u 10001 -S app -G app
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
gcc \
|
||||
musl-dev \
|
||||
linux-headers
|
||||
|
||||
# Install Python dependencies
|
||||
COPY apps/abstraction/requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY apps/__init__.py /app/apps/__init__.py
|
||||
COPY apps/abstraction/ /app/apps/abstraction/
|
||||
COPY packages/ /app/packages/
|
||||
|
||||
# Change ownership to app user
|
||||
RUN chown -R app:app /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER app
|
||||
|
||||
# Run application
|
||||
CMD ["python", "-m", "apps.abstraction.main"]
|
||||
@@ -12,11 +12,46 @@ The abstraction layer is an asyncio-based worker that manages device communicati
|
||||
|
||||
## Running
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
# Start the abstraction worker
|
||||
poetry run python -m apps.abstraction.main
|
||||
```
|
||||
|
||||
### Docker Container
|
||||
|
||||
#### Build Image
|
||||
|
||||
```bash
|
||||
docker build -t abstraction:dev -f apps/abstraction/Dockerfile .
|
||||
```
|
||||
|
||||
#### Run Container
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v $(pwd)/config:/app/config:ro \
|
||||
-e MQTT_BROKER=172.23.1.102 \
|
||||
-e MQTT_PORT=1883 \
|
||||
-e REDIS_HOST=172.23.1.116 \
|
||||
-e REDIS_PORT=6379 \
|
||||
-e REDIS_DB=8 \
|
||||
abstraction:dev
|
||||
```
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MQTT_BROKER` | `172.16.2.16` | MQTT broker hostname/IP |
|
||||
| `MQTT_PORT` | `1883` | MQTT broker port |
|
||||
| `REDIS_HOST` | `localhost` | Redis server hostname/IP |
|
||||
| `REDIS_PORT` | `6379` | Redis server port |
|
||||
| `REDIS_DB` | `0` | Redis database number |
|
||||
|
||||
### What the Worker Does
|
||||
|
||||
The worker will:
|
||||
1. Load configuration from `config/devices.yaml`
|
||||
2. Connect to MQTT broker (172.16.2.16:1883)
|
||||
|
||||
@@ -4,16 +4,26 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
import yaml
|
||||
import socket
|
||||
import uuid
|
||||
from aiomqtt import Client
|
||||
from pydantic import ValidationError
|
||||
|
||||
from packages.home_capabilities import LightState, ThermostatState, ContactState, TempHumidityState, RelayState, ThreePhasePowerState
|
||||
from apps.abstraction.transformation import (
|
||||
transform_abstract_to_vendor,
|
||||
transform_vendor_to_abstract
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
level=logging.DEBUG,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -32,8 +42,8 @@ def load_config(config_path: Path) -> dict[str, Any]:
|
||||
logger.warning(f"Config file not found: {config_path}, using defaults")
|
||||
return {
|
||||
"mqtt": {
|
||||
"broker": "172.16.2.16",
|
||||
"port": 1883,
|
||||
"broker": os.getenv("MQTT_BROKER", "localhost"),
|
||||
"port": int(os.getenv("MQTT_PORT", "1883")),
|
||||
"client_id": "home-automation-abstraction",
|
||||
"keepalive": 60
|
||||
},
|
||||
@@ -79,11 +89,12 @@ def validate_devices(devices: list[dict[str, Any]]) -> None:
|
||||
if "topics" not in device:
|
||||
raise ValueError(f"Device {device_id} missing 'topics'")
|
||||
|
||||
if "set" not in device["topics"]:
|
||||
raise ValueError(f"Device {device_id} missing 'topics.set'")
|
||||
|
||||
# 'state' topic is required for all devices
|
||||
if "state" not in device["topics"]:
|
||||
raise ValueError(f"Device {device_id} missing 'topics.state'")
|
||||
|
||||
# 'set' topic is optional (read-only devices like contact sensors don't have it)
|
||||
# No validation needed for topics.set
|
||||
|
||||
# Log loaded devices
|
||||
device_ids = [d["device_id"] for d in devices]
|
||||
@@ -121,6 +132,7 @@ async def handle_abstract_set(
|
||||
mqtt_client: Client,
|
||||
device_id: str,
|
||||
device_type: str,
|
||||
device_technology: str,
|
||||
vendor_topic: str,
|
||||
payload: dict[str, Any]
|
||||
) -> None:
|
||||
@@ -129,13 +141,53 @@ async def handle_abstract_set(
|
||||
Args:
|
||||
mqtt_client: MQTT client instance
|
||||
device_id: Device identifier
|
||||
device_type: Device type (e.g., 'light')
|
||||
device_type: Device type (e.g., 'light', 'thermostat')
|
||||
device_technology: Technology identifier (e.g., 'zigbee2mqtt')
|
||||
vendor_topic: Vendor-specific SET topic
|
||||
payload: Message payload
|
||||
"""
|
||||
# Extract actual payload (remove type wrapper if present)
|
||||
vendor_payload = payload.get("payload", payload)
|
||||
vendor_message = json.dumps(vendor_payload)
|
||||
abstract_payload = payload.get("payload", payload)
|
||||
|
||||
# Validate payload based on device type
|
||||
try:
|
||||
if device_type == "light":
|
||||
# Validate light SET payload (power and/or brightness)
|
||||
LightState.model_validate(abstract_payload)
|
||||
elif device_type == "relay":
|
||||
# Validate relay SET payload (power only)
|
||||
RelayState.model_validate(abstract_payload)
|
||||
elif device_type == "thermostat":
|
||||
# For thermostat SET: only allow mode and target fields
|
||||
allowed_set_fields = {"mode", "target"}
|
||||
invalid_fields = set(abstract_payload.keys()) - allowed_set_fields
|
||||
if invalid_fields:
|
||||
logger.warning(
|
||||
f"Thermostat SET {device_id} contains invalid fields {invalid_fields}, "
|
||||
f"only {allowed_set_fields} allowed"
|
||||
)
|
||||
return
|
||||
|
||||
# Validate against ThermostatState (current/battery/window_open are optional)
|
||||
ThermostatState.model_validate(abstract_payload)
|
||||
elif device_type in {"contact", "contact_sensor"}:
|
||||
# Contact sensors are read-only - SET commands should not occur
|
||||
logger.warning(f"Contact sensor {device_id} received SET command - ignoring (read-only device)")
|
||||
return
|
||||
except ValidationError as e:
|
||||
logger.error(f"Validation failed for {device_type} SET {device_id}: {e}")
|
||||
return
|
||||
|
||||
# Transform abstract payload to vendor-specific format
|
||||
vendor_payload = transform_abstract_to_vendor(device_type, device_technology, abstract_payload)
|
||||
|
||||
# For MAX! thermostats and Shelly relays, vendor_payload is a plain string
|
||||
# For other devices, it's a dict that needs JSON encoding
|
||||
if (device_technology == "max" and device_type == "thermostat") or \
|
||||
(device_technology == "shelly" and device_type == "relay"):
|
||||
vendor_message = vendor_payload # Already a string
|
||||
else:
|
||||
vendor_message = json.dumps(vendor_payload)
|
||||
|
||||
logger.info(f"→ vendor SET {device_id}: {vendor_topic} ← {vendor_message}")
|
||||
await mqtt_client.publish(vendor_topic, vendor_message, qos=1)
|
||||
@@ -146,7 +198,8 @@ async def handle_vendor_state(
|
||||
redis_client: aioredis.Redis,
|
||||
device_id: str,
|
||||
device_type: str,
|
||||
payload: dict[str, Any],
|
||||
device_technology: str,
|
||||
payload: str,
|
||||
redis_channel: str = "ui:updates"
|
||||
) -> None:
|
||||
"""Handle vendor STATE message and publish to abstract topic + Redis.
|
||||
@@ -155,22 +208,53 @@ async def handle_vendor_state(
|
||||
mqtt_client: MQTT client instance
|
||||
redis_client: Redis client instance
|
||||
device_id: Device identifier
|
||||
device_type: Device type (e.g., 'light')
|
||||
payload: State payload
|
||||
device_type: Device type (e.g., 'light', 'thermostat')
|
||||
device_technology: Technology identifier (e.g., 'zigbee2mqtt')
|
||||
payload: string Message payload
|
||||
redis_channel: Redis channel for UI updates
|
||||
"""
|
||||
# Transform vendor-specific payload to abstract format
|
||||
abstract_payload = transform_vendor_to_abstract(device_type, device_technology, payload)
|
||||
|
||||
# Validate state payload based on device type
|
||||
try:
|
||||
if device_type == "light":
|
||||
LightState.model_validate(abstract_payload)
|
||||
elif device_type == "relay":
|
||||
RelayState.model_validate(abstract_payload)
|
||||
elif device_type == "thermostat":
|
||||
# Validate thermostat state: mode, target, current (required), battery, window_open
|
||||
ThermostatState.model_validate(abstract_payload)
|
||||
elif device_type in {"contact", "contact_sensor"}:
|
||||
# Validate contact sensor state
|
||||
ContactState.model_validate(abstract_payload)
|
||||
elif device_type in {"temp_humidity", "temp_humidity_sensor"}:
|
||||
# Validate temperature & humidity sensor state
|
||||
TempHumidityState.model_validate(abstract_payload)
|
||||
elif device_type == "three_phase_powermeter":
|
||||
# Validate three-phase powermeter state
|
||||
ThreePhasePowerState.model_validate(abstract_payload)
|
||||
except ValidationError as e:
|
||||
logger.error(f"Validation failed for {device_type} STATE {device_id}: {e}")
|
||||
return
|
||||
|
||||
# Normalize device type for topic (use 'contact' for both 'contact' and 'contact_sensor')
|
||||
topic_type = "contact" if device_type in {"contact", "contact_sensor"} else device_type
|
||||
topic_type = "temp_humidity" if device_type in {"temp_humidity", "temp_humidity_sensor"} else topic_type
|
||||
|
||||
# Publish to abstract state topic (retained)
|
||||
abstract_topic = f"home/{device_type}/{device_id}/state"
|
||||
abstract_message = json.dumps(payload)
|
||||
abstract_topic = f"home/{topic_type}/{device_id}/state"
|
||||
abstract_message = json.dumps(abstract_payload)
|
||||
|
||||
logger.info(f"← abstract STATE {device_id}: {abstract_topic} → {abstract_message}")
|
||||
await mqtt_client.publish(abstract_topic, abstract_message, qos=1, retain=True)
|
||||
|
||||
# Publish to Redis for UI updates
|
||||
# Publish to Redis for UI updates with timestamp
|
||||
ui_update = {
|
||||
"type": "state",
|
||||
"device_id": device_id,
|
||||
"payload": payload
|
||||
"payload": abstract_payload,
|
||||
"ts": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
redis_message = json.dumps(ui_update)
|
||||
|
||||
@@ -186,9 +270,12 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
|
||||
redis_client: Redis client for UI updates
|
||||
"""
|
||||
mqtt_config = config.get("mqtt", {})
|
||||
broker = mqtt_config.get("broker", "172.16.2.16")
|
||||
port = mqtt_config.get("port", 1883)
|
||||
broker = os.getenv("MQTT_BROKER") or mqtt_config.get("broker", "localhost")
|
||||
port = int(os.getenv("MQTT_PORT", mqtt_config.get("port", 1883)))
|
||||
client_id = mqtt_config.get("client_id", "home-automation-abstraction")
|
||||
# Append a short suffix (ENV override possible) so multiple processes don't collide
|
||||
client_suffix = os.environ.get("MQTT_CLIENT_ID_SUFFIX") or uuid.uuid4().hex[:6]
|
||||
unique_client_id = f"{client_id}-{client_suffix}"
|
||||
keepalive = mqtt_config.get("keepalive", 60)
|
||||
|
||||
redis_config = config.get("redis", {})
|
||||
@@ -206,18 +293,26 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
|
||||
async with Client(
|
||||
hostname=broker,
|
||||
port=port,
|
||||
identifier=client_id,
|
||||
keepalive=keepalive
|
||||
identifier=unique_client_id,
|
||||
keepalive=keepalive,
|
||||
timeout=10.0 # Add explicit timeout for operations
|
||||
) as client:
|
||||
logger.info(f"Connected to MQTT broker as {client_id}")
|
||||
logger.info(f"Connected to MQTT broker as {unique_client_id}")
|
||||
|
||||
# Subscribe to abstract SET topics for all devices
|
||||
# Subscribe to topics for all devices
|
||||
for device in devices.values():
|
||||
abstract_set_topic = f"home/{device['type']}/{device['device_id']}/set"
|
||||
await client.subscribe(abstract_set_topic)
|
||||
logger.info(f"Subscribed to abstract SET: {abstract_set_topic}")
|
||||
device_id = device['device_id']
|
||||
device_type = device['type']
|
||||
|
||||
# Subscribe to vendor STATE topics
|
||||
# Subscribe to abstract SET topic only if device has a SET topic (not read-only)
|
||||
if "set" in device["topics"]:
|
||||
abstract_set_topic = f"home/{device_type}/{device_id}/set"
|
||||
await client.subscribe(abstract_set_topic)
|
||||
logger.info(f"Subscribed to abstract SET: {abstract_set_topic}")
|
||||
else:
|
||||
logger.info(f"Skipping SET subscription for read-only device: {device_id}")
|
||||
|
||||
# Subscribe to vendor STATE topics (all devices have state)
|
||||
vendor_state_topic = device["topics"]["state"]
|
||||
await client.subscribe(vendor_state_topic)
|
||||
logger.info(f"Subscribed to vendor STATE: {vendor_state_topic}")
|
||||
@@ -225,44 +320,59 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
|
||||
# Reset retry delay on successful connection
|
||||
retry_delay = 1
|
||||
|
||||
# Track last activity for connection health
|
||||
last_activity = asyncio.get_event_loop().time()
|
||||
connection_timeout = keepalive * 2 # 2x keepalive as timeout
|
||||
|
||||
# Process messages
|
||||
async for message in client.messages:
|
||||
topic = str(message.topic)
|
||||
payload_str = message.payload.decode()
|
||||
|
||||
try:
|
||||
payload = json.loads(payload_str)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Invalid JSON on {topic}: {payload_str}")
|
||||
continue
|
||||
|
||||
# Check if this is an abstract SET message
|
||||
if topic.startswith("home/") and topic.endswith("/set"):
|
||||
# Extract device_type and device_id from topic
|
||||
parts = topic.split("/")
|
||||
if len(parts) == 4: # home/<type>/<id>/set
|
||||
device_type = parts[1]
|
||||
device_id = parts[2]
|
||||
|
||||
if device_id in devices:
|
||||
device = devices[device_id]
|
||||
vendor_topic = device["topics"]["set"]
|
||||
await handle_abstract_set(
|
||||
client, device_id, device_type, vendor_topic, payload
|
||||
)
|
||||
|
||||
# Check if this is a vendor STATE message
|
||||
else:
|
||||
# Find device by vendor state topic
|
||||
for device_id, device in devices.items():
|
||||
if topic == device["topics"]["state"]:
|
||||
await handle_vendor_state(
|
||||
client, redis_client, device_id, device["type"], payload, redis_channel
|
||||
)
|
||||
break
|
||||
last_activity = asyncio.get_event_loop().time()
|
||||
topic = str(message.topic)
|
||||
payload_str = message.payload.decode()
|
||||
retain = getattr(message, 'retain', None)
|
||||
logger.debug(f"MQTT message received on ({retain=}) {topic}: {payload_str}")
|
||||
|
||||
# Check if this is an abstract SET message
|
||||
if topic.startswith("home/") and topic.endswith("/set"):
|
||||
|
||||
payload = json.loads(payload_str)
|
||||
|
||||
# Extract device_type and device_id from topic
|
||||
parts = topic.split("/")
|
||||
if len(parts) == 4: # home/<type>/<id>/set
|
||||
device_type = parts[1]
|
||||
device_id = parts[2]
|
||||
|
||||
if device_id in devices:
|
||||
device = devices[device_id]
|
||||
vendor_topic = device["topics"]["set"]
|
||||
device_technology = device.get("technology", "unknown")
|
||||
await handle_abstract_set(
|
||||
client, device_id, device_type, device_technology, vendor_topic, payload
|
||||
)
|
||||
|
||||
# Check if this is a vendor STATE message
|
||||
else:
|
||||
# Find device by vendor state topic for other technologies
|
||||
for device_id, device in devices.items():
|
||||
if topic == device["topics"]["state"]:
|
||||
device_technology = device.get("technology", "unknown")
|
||||
await handle_vendor_state(
|
||||
client, redis_client, device_id, device["type"],
|
||||
device_technology, payload_str, redis_channel
|
||||
)
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Failed to decode JSON payload on topic {topic}: {payload_str}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("MQTT worker cancelled")
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"MQTT error: {e}")
|
||||
logger.debug(f"Traceback: {traceback.format_exc()}")
|
||||
logger.info(f"Reconnecting in {retry_delay}s...")
|
||||
await asyncio.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, max_retry_delay)
|
||||
|
||||
5
apps/abstraction/requirements.txt
Normal file
5
apps/abstraction/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
pydantic>=2
|
||||
aiomqtt==2.0.1
|
||||
redis==5.0.1
|
||||
pyyaml==6.0.1
|
||||
tenacity==8.2.3
|
||||
680
apps/abstraction/transformation.py
Normal file
680
apps/abstraction/transformation.py
Normal file
@@ -0,0 +1,680 @@
|
||||
"""Payload transformation functions for vendor-specific device communication.
|
||||
|
||||
This module implements a registry-pattern for vendor-specific transformations:
|
||||
- Each (device_type, technology, direction) tuple maps to a specific handler function
|
||||
- Handlers transform payloads between abstract and vendor-specific formats
|
||||
- Unknown combinations fall back to pass-through (no transformation)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HANDLER FUNCTIONS: simulator technology
|
||||
# ============================================================================
|
||||
|
||||
def _transform_light_simulator_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Transform abstract light payload to simulator format.
|
||||
|
||||
Simulator uses same format as abstract protocol (no transformation needed).
|
||||
"""
|
||||
return payload
|
||||
|
||||
|
||||
def _transform_light_simulator_to_abstract(payload: str) -> dict[str, Any]:
|
||||
"""Transform simulator light payload to abstract format.
|
||||
|
||||
Simulator uses same format as abstract protocol (no transformation needed).
|
||||
"""
|
||||
|
||||
payload = json.loads(payload)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _transform_thermostat_simulator_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Transform abstract thermostat payload to simulator format.
|
||||
|
||||
Simulator uses same format as abstract protocol (no transformation needed).
|
||||
"""
|
||||
return payload
|
||||
|
||||
|
||||
def _transform_thermostat_simulator_to_abstract(payload: str) -> dict[str, Any]:
|
||||
"""Transform simulator thermostat payload to abstract format.
|
||||
|
||||
Simulator uses same format as abstract protocol (no transformation needed).
|
||||
"""
|
||||
|
||||
payload = json.loads(payload)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HANDLER FUNCTIONS: zigbee2mqtt technology
|
||||
# ============================================================================
|
||||
|
||||
def _transform_light_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Transform abstract light payload to zigbee2mqtt format.
|
||||
|
||||
Transformations:
|
||||
- power: 'on'/'off' -> state: 'ON'/'OFF'
|
||||
- brightness: 0-100 -> brightness: 0-254
|
||||
|
||||
Example:
|
||||
- Abstract: {'power': 'on', 'brightness': 100}
|
||||
- zigbee2mqtt: {'state': 'ON', 'brightness': 254}
|
||||
"""
|
||||
vendor_payload = payload.copy()
|
||||
|
||||
# Transform power -> state with uppercase values
|
||||
if "power" in vendor_payload:
|
||||
power_value = vendor_payload.pop("power")
|
||||
vendor_payload["state"] = power_value.upper() if isinstance(power_value, str) else power_value
|
||||
|
||||
# Transform brightness: 0-100 (%) -> 0-254 (zigbee2mqtt range)
|
||||
if "brightness" in vendor_payload:
|
||||
abstract_brightness = vendor_payload["brightness"]
|
||||
if isinstance(abstract_brightness, (int, float)):
|
||||
# Convert percentage (0-100) to zigbee2mqtt range (0-254)
|
||||
vendor_payload["brightness"] = round(abstract_brightness * 254 / 100)
|
||||
|
||||
return vendor_payload
|
||||
|
||||
|
||||
def _transform_light_zigbee2mqtt_to_abstract(payload: str) -> dict[str, Any]:
|
||||
"""Transform zigbee2mqtt light payload to abstract format.
|
||||
|
||||
Transformations:
|
||||
- state: 'ON'/'OFF' -> power: 'on'/'off'
|
||||
- brightness: 0-254 -> brightness: 0-100
|
||||
|
||||
Example:
|
||||
- zigbee2mqtt: {'state': 'ON', 'brightness': 254}
|
||||
- Abstract: {'power': 'on', 'brightness': 100}
|
||||
"""
|
||||
abstract_payload = json.loads(payload)
|
||||
|
||||
# Transform state -> power with lowercase values
|
||||
if "state" in abstract_payload:
|
||||
state_value = abstract_payload.pop("state")
|
||||
abstract_payload["power"] = state_value.lower() if isinstance(state_value, str) else state_value
|
||||
|
||||
# Transform brightness: 0-254 (zigbee2mqtt range) -> 0-100 (%)
|
||||
if "brightness" in abstract_payload:
|
||||
vendor_brightness = abstract_payload["brightness"]
|
||||
if isinstance(vendor_brightness, (int, float)):
|
||||
# Convert zigbee2mqtt range (0-254) to percentage (0-100)
|
||||
abstract_payload["brightness"] = round(vendor_brightness * 100 / 254)
|
||||
|
||||
return abstract_payload
|
||||
|
||||
|
||||
def _transform_thermostat_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Transform abstract thermostat payload to zigbee2mqtt format.
|
||||
|
||||
Transformations:
|
||||
- target -> current_heating_setpoint (as string)
|
||||
- mode is ignored (zigbee2mqtt thermostats use system_mode in state only)
|
||||
|
||||
Example:
|
||||
- Abstract: {'target': 22.0}
|
||||
- zigbee2mqtt: {'current_heating_setpoint': '22.0'}
|
||||
"""
|
||||
vendor_payload = {}
|
||||
|
||||
if "target" in payload:
|
||||
# zigbee2mqtt expects current_heating_setpoint as string
|
||||
vendor_payload["current_heating_setpoint"] = str(payload["target"])
|
||||
|
||||
return vendor_payload
|
||||
|
||||
|
||||
def _transform_thermostat_zigbee2mqtt_to_abstract(payload: str) -> dict[str, Any]:
|
||||
"""Transform zigbee2mqtt thermostat payload to abstract format.
|
||||
|
||||
Transformations:
|
||||
- current_heating_setpoint -> target (as float)
|
||||
- local_temperature -> current (as float)
|
||||
- system_mode -> mode
|
||||
|
||||
Example:
|
||||
- zigbee2mqtt: {'current_heating_setpoint': 15, 'local_temperature': 23, 'system_mode': 'heat'}
|
||||
- Abstract: {'target': 15.0, 'current': 23.0, 'mode': 'heat'}
|
||||
"""
|
||||
payload = json.loads(payload)
|
||||
abstract_payload = {}
|
||||
|
||||
# Extract target temperature
|
||||
if "current_heating_setpoint" in payload:
|
||||
setpoint = payload["current_heating_setpoint"]
|
||||
abstract_payload["target"] = float(setpoint)
|
||||
|
||||
# Extract current temperature
|
||||
if "local_temperature" in payload:
|
||||
current = payload["local_temperature"]
|
||||
abstract_payload["current"] = float(current)
|
||||
|
||||
# Extract mode
|
||||
if "system_mode" in payload:
|
||||
abstract_payload["mode"] = payload["system_mode"]
|
||||
|
||||
return abstract_payload
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HANDLER FUNCTIONS: contact_sensor - zigbee2mqtt technology
|
||||
# ============================================================================
|
||||
|
||||
def _transform_contact_sensor_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Transform abstract contact sensor payload to zigbee2mqtt format.
|
||||
|
||||
Contact sensors are read-only, so this should not be called for SET commands.
|
||||
Returns payload as-is for compatibility.
|
||||
"""
|
||||
logger.warning("Contact sensors are read-only - SET commands should not be used")
|
||||
return payload
|
||||
|
||||
|
||||
def _transform_contact_sensor_zigbee2mqtt_to_abstract(payload: str) -> dict[str, Any]:
|
||||
"""Transform zigbee2mqtt contact sensor payload to abstract format.
|
||||
|
||||
Transformations:
|
||||
- contact: bool -> "open" | "closed"
|
||||
- zigbee2mqtt semantics: False = OPEN, True = CLOSED (inverted!)
|
||||
- battery: pass through (already 0-100)
|
||||
- linkquality: pass through
|
||||
- device_temperature: pass through (if present)
|
||||
- voltage: pass through (if present)
|
||||
|
||||
Example:
|
||||
- zigbee2mqtt: {"contact": false, "battery": 100, "linkquality": 87}
|
||||
- Abstract: {"contact": "open", "battery": 100, "linkquality": 87}
|
||||
"""
|
||||
payload = json.loads(payload)
|
||||
abstract_payload = {}
|
||||
|
||||
# Transform contact state (inverted logic!)
|
||||
if "contact" in payload:
|
||||
contact_bool = payload["contact"]
|
||||
# zigbee2mqtt: False = OPEN, True = CLOSED
|
||||
abstract_payload["contact"] = "closed" if contact_bool else "open"
|
||||
|
||||
# Pass through optional fields
|
||||
if "battery" in payload:
|
||||
abstract_payload["battery"] = payload["battery"]
|
||||
|
||||
if "linkquality" in payload:
|
||||
abstract_payload["linkquality"] = payload["linkquality"]
|
||||
|
||||
if "device_temperature" in payload:
|
||||
abstract_payload["device_temperature"] = payload["device_temperature"]
|
||||
|
||||
if "voltage" in payload:
|
||||
abstract_payload["voltage"] = payload["voltage"]
|
||||
|
||||
return abstract_payload
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HANDLER FUNCTIONS: contact_sensor - max technology (Homegear MAX!)
|
||||
# ============================================================================
|
||||
|
||||
def _transform_contact_sensor_max_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Transform abstract contact sensor payload to MAX! format.
|
||||
|
||||
Contact sensors are read-only, so this should not be called for SET commands.
|
||||
Returns payload as-is for compatibility.
|
||||
"""
|
||||
logger.warning("Contact sensors are read-only - SET commands should not be used")
|
||||
return payload
|
||||
|
||||
|
||||
def _transform_contact_sensor_max_to_abstract(payload: str) -> dict[str, Any]:
|
||||
"""Transform MAX! (Homegear) contact sensor payload to abstract format.
|
||||
|
||||
MAX! sends "true"/"false" (string or bool) on STATE topic.
|
||||
|
||||
Transformations:
|
||||
- "true" or True -> "open" (window/door open)
|
||||
- "false" or False -> "closed" (window/door closed)
|
||||
|
||||
Example:
|
||||
- MAX!: "true" or True
|
||||
- Abstract: {"contact": "open"}
|
||||
"""
|
||||
try:
|
||||
contact_value = payload.strip().lower() == "true"
|
||||
|
||||
# MAX! semantics: True = OPEN, False = CLOSED
|
||||
return {
|
||||
"contact": "open" if contact_value else "closed"
|
||||
}
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.error(f"MAX! contact sensor failed to parse: {payload}, error: {e}")
|
||||
return {
|
||||
"contact": "closed" # Default to closed on error
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HANDLER FUNCTIONS: temp_humidity_sensor - zigbee2mqtt technology
|
||||
# ============================================================================
|
||||
|
||||
def _transform_temp_humidity_sensor_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Transform abstract temp/humidity sensor payload to zigbee2mqtt format.
|
||||
|
||||
Temp/humidity sensors are read-only, so this should not be called for SET commands.
|
||||
Returns payload as-is for compatibility.
|
||||
"""
|
||||
return payload
|
||||
|
||||
|
||||
def _transform_temp_humidity_sensor_zigbee2mqtt_to_abstract(payload: str) -> dict[str, Any]:
|
||||
"""Transform zigbee2mqtt temp/humidity sensor payload to abstract format.
|
||||
|
||||
Passthrough - zigbee2mqtt provides temperature, humidity, battery, linkquality directly.
|
||||
"""
|
||||
payload = json.loads(payload)
|
||||
return payload
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HANDLER FUNCTIONS: temp_humidity_sensor - MAX! technology
|
||||
# ============================================================================
|
||||
|
||||
def _transform_temp_humidity_sensor_max_to_vendor(payload: str) -> dict[str, Any]:
|
||||
"""Transform abstract temp/humidity sensor payload to MAX! format.
|
||||
|
||||
Temp/humidity sensors are read-only, so this should not be called for SET commands.
|
||||
Returns payload as-is for compatibility.
|
||||
"""
|
||||
|
||||
payload = json.loads(payload)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _transform_temp_humidity_sensor_max_to_abstract(payload: str) -> dict[str, Any]:
|
||||
"""Transform MAX! temp/humidity sensor payload to abstract format.
|
||||
|
||||
Passthrough - MAX! provides temperature, humidity, battery directly.
|
||||
"""
|
||||
payload = json.loads(payload)
|
||||
return payload
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HANDLER FUNCTIONS: relay - zigbee2mqtt technology
|
||||
# ============================================================================
|
||||
|
||||
def _transform_relay_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Transform abstract relay payload to zigbee2mqtt format.
|
||||
|
||||
Relay only has power on/off, same transformation as light.
|
||||
- power: 'on'/'off' -> state: 'ON'/'OFF'
|
||||
"""
|
||||
vendor_payload = payload.copy()
|
||||
|
||||
if "power" in vendor_payload:
|
||||
power_value = vendor_payload.pop("power")
|
||||
vendor_payload["state"] = power_value.upper() if isinstance(power_value, str) else power_value
|
||||
|
||||
return vendor_payload
|
||||
|
||||
|
||||
def _transform_relay_zigbee2mqtt_to_abstract(payload: str) -> dict[str, Any]:
|
||||
"""Transform zigbee2mqtt relay payload to abstract format.
|
||||
|
||||
Relay only has power on/off, same transformation as light.
|
||||
- state: 'ON'/'OFF' -> power: 'on'/'off'
|
||||
"""
|
||||
payload = json.loads(payload)
|
||||
abstract_payload = payload.copy()
|
||||
|
||||
if "state" in abstract_payload:
|
||||
state_value = abstract_payload.pop("state")
|
||||
abstract_payload["power"] = state_value.lower() if isinstance(state_value, str) else state_value
|
||||
|
||||
return abstract_payload
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HANDLER FUNCTIONS: relay - shelly technology
|
||||
# ============================================================================
|
||||
|
||||
def _transform_relay_shelly_to_vendor(payload: dict[str, Any]) -> str:
|
||||
"""Transform abstract relay payload to Shelly format.
|
||||
|
||||
Shelly expects plain text 'on' or 'off' (not JSON).
|
||||
- power: 'on'/'off' -> 'on'/'off' (plain string)
|
||||
|
||||
Example:
|
||||
- Abstract: {'power': 'on'}
|
||||
- Shelly: 'on'
|
||||
"""
|
||||
power = payload.get("power", "off")
|
||||
return power
|
||||
|
||||
|
||||
def _transform_relay_shelly_to_abstract(payload: str) -> dict[str, Any]:
|
||||
"""Transform Shelly relay payload to abstract format.
|
||||
|
||||
Shelly sends plain text 'on' or 'off' (not JSON).
|
||||
- 'on'/'off' -> power: 'on'/'off'
|
||||
|
||||
Example:
|
||||
- Shelly: 'on'
|
||||
- Abstract: {'power': 'on'}
|
||||
"""
|
||||
return {"power": payload.strip()}
|
||||
|
||||
# ============================================================================
|
||||
# HANDLER FUNCTIONS: relay - hottis_modbus technology
|
||||
# ============================================================================
|
||||
|
||||
def _transform_relay_hottis_modbus_to_vendor(payload: dict[str, Any]) -> str:
|
||||
"""Transform abstract relay payload to Hottis Modbus format.
|
||||
|
||||
Hottis Modbus expects plain text 'on' or 'off' (not JSON).
|
||||
- power: 'on'/'off' -> 'on'/'off' (plain string)
|
||||
|
||||
Example:
|
||||
- Abstract: {'power': 'on'}
|
||||
- Hottis Modbus: 'on'
|
||||
"""
|
||||
power = payload.get("power", "off")
|
||||
return power
|
||||
|
||||
|
||||
def _transform_relay_hottis_modbus_to_abstract(payload: str) -> dict[str, Any]:
|
||||
"""Transform Hottis Modbus relay payload to abstract format.
|
||||
|
||||
Hottis Modbus sends plain text 'on' or 'off' (not JSON).
|
||||
- 'on'/'off' -> power: 'on'/'off'
|
||||
|
||||
Example:
|
||||
- Hottis Modbus: 'on'
|
||||
- Abstract: {'power': 'on'}
|
||||
"""
|
||||
return {"power": payload.strip()}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HANDLER FUNCTIONS: three_phase_powermeter - hottis_modbus technology
|
||||
# ============================================================================
|
||||
|
||||
def _transform_three_phase_powermeter_hottis_modbus_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Transform abstract three_phase_powermeter payload to hottis_modbus format.
|
||||
|
||||
energy: float = Field(..., description="Total energy in kWh")
|
||||
total_power: float = Field(..., description="Total power in W")
|
||||
phase1_power: float = Field(..., description="Power for phase 1 in W")
|
||||
phase2_power: float = Field(..., description="Power for phase 2 in W")
|
||||
phase3_power: float = Field(..., description="Power for phase 3 in W")
|
||||
phase1_voltage: float = Field(..., description="Voltage for phase 1 in V")
|
||||
phase2_voltage: float = Field(..., description="Voltage for phase 2 in V")
|
||||
phase3_voltage: float = Field(..., description="Voltage for phase 3 in V")
|
||||
phase1_current: float = Field(..., description="Current for phase 1 in A")
|
||||
phase2_current: float = Field(..., description="Current for phase 2 in A")
|
||||
phase3_current: float = Field(..., description="Current for phase 3 in A")
|
||||
|
||||
|
||||
"""
|
||||
|
||||
vendor_payload = {
|
||||
"energy": payload.get("energy", 0.0),
|
||||
"total_power": payload.get("total_power", 0.0),
|
||||
"phase1_power": payload.get("phase1_power", 0.0),
|
||||
"phase2_power": payload.get("phase2_power", 0.0),
|
||||
"phase3_power": payload.get("phase3_power", 0.0),
|
||||
"phase1_voltage": payload.get("phase1_voltage", 0.0),
|
||||
"phase2_voltage": payload.get("phase2_voltage", 0.0),
|
||||
"phase3_voltage": payload.get("phase3_voltage", 0.0),
|
||||
"phase1_current": payload.get("phase1_current", 0.0),
|
||||
"phase2_current": payload.get("phase2_current", 0.0),
|
||||
"phase3_current": payload.get("phase3_current", 0.0),
|
||||
}
|
||||
|
||||
return vendor_payload
|
||||
|
||||
|
||||
def _transform_three_phase_powermeter_hottis_modbus_to_abstract(payload: str) -> dict[str, Any]:
|
||||
"""Transform hottis_modbus three_phase_powermeter payload to abstract format.
|
||||
|
||||
Transformations:
|
||||
- Direct mapping of all power meter fields
|
||||
|
||||
Example:
|
||||
- hottis_modbus: {'energy': 123.45, 'total_power': 1500.0, 'phase1_power': 500.0, ...}
|
||||
- Abstract: {'energy': 123.45, 'total_power': 1500.0, 'phase1_power': 500.0, ...}
|
||||
"""
|
||||
payload = json.loads(payload)
|
||||
abstract_payload = {
|
||||
"energy": payload.get("energy", 0.0),
|
||||
"total_power": payload.get("total_power", 0.0),
|
||||
"phase1_power": payload.get("phase1_power", 0.0),
|
||||
"phase2_power": payload.get("phase2_power", 0.0),
|
||||
"phase3_power": payload.get("phase3_power", 0.0),
|
||||
"phase1_voltage": payload.get("phase1_voltage", 0.0),
|
||||
"phase2_voltage": payload.get("phase2_voltage", 0.0),
|
||||
"phase3_voltage": payload.get("phase3_voltage", 0.0),
|
||||
"phase1_current": payload.get("phase1_current", 0.0),
|
||||
"phase2_current": payload.get("phase2_current", 0.0),
|
||||
"phase3_current": payload.get("phase3_current", 0.0),
|
||||
}
|
||||
|
||||
return abstract_payload
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HANDLER FUNCTIONS: max technology (Homegear MAX!)
|
||||
# ============================================================================
|
||||
|
||||
def _transform_thermostat_max_to_vendor(payload: dict[str, Any]) -> str:
|
||||
"""Transform abstract thermostat payload to MAX! (Homegear) format.
|
||||
|
||||
MAX! expects only the integer temperature value (no JSON).
|
||||
|
||||
Transformations:
|
||||
- Extract 'target' temperature from payload
|
||||
- Convert float to integer (MAX! only accepts integers)
|
||||
- Return as plain string value
|
||||
|
||||
Example:
|
||||
- Abstract: {'mode': 'heat', 'target': 22.5}
|
||||
- MAX!: "22"
|
||||
|
||||
Note: MAX! ignores mode - it's always in heating mode
|
||||
"""
|
||||
if "target" not in payload:
|
||||
logger.warning(f"MAX! thermostat payload missing 'target': {payload}")
|
||||
return "21" # Default fallback
|
||||
|
||||
target_temp = payload["target"]
|
||||
|
||||
# Convert to integer (MAX! protocol requirement)
|
||||
if isinstance(target_temp, (int, float)):
|
||||
int_temp = int(round(target_temp))
|
||||
return str(int_temp)
|
||||
|
||||
logger.warning(f"MAX! invalid target temperature type: {type(target_temp)}, value: {target_temp}")
|
||||
return "21"
|
||||
|
||||
|
||||
def _transform_thermostat_max_to_abstract(payload: str) -> dict[str, Any]:
|
||||
"""Transform MAX! (Homegear) thermostat payload to abstract format.
|
||||
|
||||
MAX! sends only the integer temperature value (no JSON).
|
||||
|
||||
Transformations:
|
||||
- Parse plain string/int value
|
||||
- Convert to float for abstract protocol
|
||||
- Wrap in abstract payload structure with mode='heat'
|
||||
|
||||
Example:
|
||||
- MAX!: "22" or 22
|
||||
- Abstract: {'target': 22.0, 'mode': 'heat'}
|
||||
|
||||
Note: MAX! doesn't send current temperature via SET_TEMPERATURE topic
|
||||
"""
|
||||
|
||||
# Handle both string and numeric input
|
||||
target_temp = float(payload.strip())
|
||||
|
||||
return {
|
||||
"target": target_temp,
|
||||
"mode": "heat" # MAX! is always in heating mode
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# REGISTRY: Maps (device_type, technology, direction) -> handler function
|
||||
# ============================================================================
|
||||
|
||||
TransformHandler = Callable[[dict[str, Any]], dict[str, Any]]
|
||||
|
||||
TRANSFORM_HANDLERS: dict[tuple[str, str, str], TransformHandler] = {
|
||||
# Light transformations
|
||||
("light", "simulator", "to_vendor"): _transform_light_simulator_to_vendor,
|
||||
("light", "simulator", "to_abstract"): _transform_light_simulator_to_abstract,
|
||||
("light", "zigbee2mqtt", "to_vendor"): _transform_light_zigbee2mqtt_to_vendor,
|
||||
("light", "zigbee2mqtt", "to_abstract"): _transform_light_zigbee2mqtt_to_abstract,
|
||||
|
||||
# Thermostat transformations
|
||||
("thermostat", "simulator", "to_vendor"): _transform_thermostat_simulator_to_vendor,
|
||||
("thermostat", "simulator", "to_abstract"): _transform_thermostat_simulator_to_abstract,
|
||||
("thermostat", "zigbee2mqtt", "to_vendor"): _transform_thermostat_zigbee2mqtt_to_vendor,
|
||||
("thermostat", "zigbee2mqtt", "to_abstract"): _transform_thermostat_zigbee2mqtt_to_abstract,
|
||||
("thermostat", "max", "to_vendor"): _transform_thermostat_max_to_vendor,
|
||||
("thermostat", "max", "to_abstract"): _transform_thermostat_max_to_abstract,
|
||||
|
||||
# Contact sensor transformations (support both 'contact' and 'contact_sensor' types)
|
||||
("contact_sensor", "zigbee2mqtt", "to_vendor"): _transform_contact_sensor_zigbee2mqtt_to_vendor,
|
||||
("contact_sensor", "zigbee2mqtt", "to_abstract"): _transform_contact_sensor_zigbee2mqtt_to_abstract,
|
||||
("contact_sensor", "max", "to_vendor"): _transform_contact_sensor_max_to_vendor,
|
||||
("contact_sensor", "max", "to_abstract"): _transform_contact_sensor_max_to_abstract,
|
||||
("contact", "zigbee2mqtt", "to_vendor"): _transform_contact_sensor_zigbee2mqtt_to_vendor,
|
||||
("contact", "zigbee2mqtt", "to_abstract"): _transform_contact_sensor_zigbee2mqtt_to_abstract,
|
||||
("contact", "max", "to_vendor"): _transform_contact_sensor_max_to_vendor,
|
||||
("contact", "max", "to_abstract"): _transform_contact_sensor_max_to_abstract,
|
||||
|
||||
# Temperature & humidity sensor transformations (support both type aliases)
|
||||
("temp_humidity_sensor", "zigbee2mqtt", "to_vendor"): _transform_temp_humidity_sensor_zigbee2mqtt_to_vendor,
|
||||
("temp_humidity_sensor", "zigbee2mqtt", "to_abstract"): _transform_temp_humidity_sensor_zigbee2mqtt_to_abstract,
|
||||
("temp_humidity_sensor", "max", "to_vendor"): _transform_temp_humidity_sensor_max_to_vendor,
|
||||
("temp_humidity_sensor", "max", "to_abstract"): _transform_temp_humidity_sensor_max_to_abstract,
|
||||
("temp_humidity", "zigbee2mqtt", "to_vendor"): _transform_temp_humidity_sensor_zigbee2mqtt_to_vendor,
|
||||
("temp_humidity", "zigbee2mqtt", "to_abstract"): _transform_temp_humidity_sensor_zigbee2mqtt_to_abstract,
|
||||
("temp_humidity", "max", "to_vendor"): _transform_temp_humidity_sensor_max_to_vendor,
|
||||
("temp_humidity", "max", "to_abstract"): _transform_temp_humidity_sensor_max_to_abstract,
|
||||
|
||||
# Relay transformations
|
||||
("relay", "zigbee2mqtt", "to_vendor"): _transform_relay_zigbee2mqtt_to_vendor,
|
||||
("relay", "zigbee2mqtt", "to_abstract"): _transform_relay_zigbee2mqtt_to_abstract,
|
||||
("relay", "shelly", "to_vendor"): _transform_relay_shelly_to_vendor,
|
||||
("relay", "shelly", "to_abstract"): _transform_relay_shelly_to_abstract,
|
||||
("relay", "hottis_modbus", "to_vendor"): _transform_relay_hottis_modbus_to_vendor,
|
||||
("relay", "hottis_modbus", "to_abstract"): _transform_relay_hottis_modbus_to_abstract,
|
||||
|
||||
# Three-Phase Powermeter transformations
|
||||
("three_phase_powermeter", "hottis_modbus", "to_vendor"): _transform_three_phase_powermeter_hottis_modbus_to_vendor,
|
||||
("three_phase_powermeter", "hottis_modbus", "to_abstract"): _transform_three_phase_powermeter_hottis_modbus_to_abstract,
|
||||
}
|
||||
|
||||
|
||||
def _get_transform_handler(
|
||||
device_type: str,
|
||||
device_technology: str,
|
||||
direction: str
|
||||
) -> TransformHandler:
|
||||
"""Get transformation handler for given device type, technology and direction.
|
||||
|
||||
Args:
|
||||
device_type: Type of device (e.g., "light", "thermostat")
|
||||
device_technology: Technology/vendor (e.g., "simulator", "zigbee2mqtt")
|
||||
direction: Transformation direction ("to_vendor" or "to_abstract")
|
||||
|
||||
Returns:
|
||||
Handler function for transformation, or pass-through if not found
|
||||
"""
|
||||
key = (device_type, device_technology, direction)
|
||||
handler = TRANSFORM_HANDLERS.get(key)
|
||||
|
||||
if handler is None:
|
||||
logger.warning(
|
||||
f"No transformation handler for {key}, using pass-through. "
|
||||
f"Available: {list(TRANSFORM_HANDLERS.keys())}"
|
||||
)
|
||||
return lambda payload: payload # Pass-through fallback
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PUBLIC API: Main transformation functions
|
||||
# ============================================================================
|
||||
|
||||
def transform_abstract_to_vendor(
|
||||
device_type: str,
|
||||
device_technology: str,
|
||||
abstract_payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Transform abstract payload to vendor-specific format.
|
||||
|
||||
Args:
|
||||
device_type: Type of device (e.g., "light", "thermostat")
|
||||
device_technology: Technology/vendor (e.g., "simulator", "zigbee2mqtt")
|
||||
abstract_payload: Payload in abstract home protocol format
|
||||
|
||||
Returns:
|
||||
Payload in vendor-specific format
|
||||
"""
|
||||
logger.debug(
|
||||
f"transform_abstract_to_vendor IN: type={device_type}, tech={device_technology}, "
|
||||
f"payload={abstract_payload}"
|
||||
)
|
||||
|
||||
handler = _get_transform_handler(device_type, device_technology, "to_vendor")
|
||||
vendor_payload = handler(abstract_payload)
|
||||
|
||||
logger.debug(
|
||||
f"transform_abstract_to_vendor OUT: type={device_type}, tech={device_technology}, "
|
||||
f"payload={vendor_payload}"
|
||||
)
|
||||
return vendor_payload
|
||||
|
||||
|
||||
def transform_vendor_to_abstract(
|
||||
device_type: str,
|
||||
device_technology: str,
|
||||
vendor_payload: str
|
||||
) -> dict[str, Any]:
|
||||
"""Transform vendor-specific payload to abstract format.
|
||||
|
||||
Args:
|
||||
device_type: Type of device (e.g., "light", "thermostat")
|
||||
device_technology: Technology/vendor (e.g., "simulator", "zigbee2mqtt")
|
||||
vendor_payload: Payload in vendor-specific format
|
||||
|
||||
Returns:
|
||||
Payload in abstract home protocol format
|
||||
"""
|
||||
logger.debug(
|
||||
f"transform_vendor_to_abstract IN: type={device_type}, tech={device_technology}, "
|
||||
f"payload={vendor_payload}"
|
||||
)
|
||||
|
||||
handler = _get_transform_handler(device_type, device_technology, "to_abstract")
|
||||
abstract_payload = handler(vendor_payload)
|
||||
|
||||
logger.debug(
|
||||
f"transform_vendor_to_abstract OUT: type={device_type}, tech={device_technology}, "
|
||||
f"payload={abstract_payload}"
|
||||
)
|
||||
return abstract_payload
|
||||
48
apps/api/Dockerfile
Normal file
48
apps/api/Dockerfile
Normal file
@@ -0,0 +1,48 @@
|
||||
# API Service Dockerfile
|
||||
# FastAPI + Redis + MQTT Gateway
|
||||
|
||||
FROM python:3.14-alpine
|
||||
|
||||
# Prevent Python from writing .pyc files and enable unbuffered output
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
MQTT_BROKER=172.16.2.16 \
|
||||
MQTT_PORT=1883 \
|
||||
REDIS_HOST=localhost \
|
||||
REDIS_PORT=6379 \
|
||||
REDIS_DB=0 \
|
||||
REDIS_CHANNEL=ui:updates
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 10001 -S app && \
|
||||
adduser -u 10001 -S app -G app
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
gcc \
|
||||
musl-dev \
|
||||
linux-headers
|
||||
|
||||
# Install Python dependencies
|
||||
COPY apps/api/requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY apps/__init__.py /app/apps/__init__.py
|
||||
COPY apps/api/ /app/apps/api/
|
||||
COPY packages/ /app/packages/
|
||||
|
||||
# Change ownership to app user
|
||||
RUN chown -R app:app /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER app
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8001
|
||||
|
||||
# Run application
|
||||
CMD ["python", "-m", "uvicorn", "apps.api.main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
@@ -20,7 +20,7 @@ poetry run uvicorn apps.api.main:app --reload
|
||||
### Production Mode
|
||||
|
||||
```bash
|
||||
poetry run uvicorn apps.api.main:app --host 0.0.0.0 --port 8000
|
||||
poetry run uvicorn apps.api.main:app --host 0.0.0.0 --port 8001
|
||||
```
|
||||
|
||||
### Using Python directly
|
||||
@@ -29,6 +29,56 @@ poetry run uvicorn apps.api.main:app --host 0.0.0.0 --port 8000
|
||||
poetry run python -m apps.api.main
|
||||
```
|
||||
|
||||
### Docker Container
|
||||
|
||||
#### Build Image
|
||||
|
||||
```bash
|
||||
docker build -t api:dev -f apps/api/Dockerfile .
|
||||
```
|
||||
|
||||
#### Run Container
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8001:8001 \
|
||||
-v $(pwd)/config:/app/config:ro \
|
||||
-e MQTT_BROKER=172.23.1.102 \
|
||||
-e MQTT_PORT=1883 \
|
||||
-e REDIS_HOST=172.23.1.116 \
|
||||
-e REDIS_PORT=6379 \
|
||||
-e REDIS_DB=8 \
|
||||
-e REDIS_CHANNEL=ui:updates \
|
||||
api:dev
|
||||
```
|
||||
|
||||
**Mit Docker Network (empfohlen für Linux):**
|
||||
```bash
|
||||
docker network create home-automation
|
||||
docker run --rm -p 8001:8001 \
|
||||
--network home-automation \
|
||||
--name api \
|
||||
-v $(pwd)/config:/app/config:ro \
|
||||
-e MQTT_BROKER=172.16.2.16 \
|
||||
-e REDIS_HOST=172.23.1.116 \
|
||||
-e REDIS_DB=8 \
|
||||
api:dev
|
||||
```
|
||||
|
||||
**Hinweise:**
|
||||
- **Linux**: Port wird auf `0.0.0.0:8001` gebunden (von überall erreichbar)
|
||||
- **macOS/finch**: Port wird auf `127.0.0.1:8001` gebunden (nur localhost)
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MQTT_BROKER` | `172.16.2.16` | MQTT broker hostname/IP |
|
||||
| `MQTT_PORT` | `1883` | MQTT broker port |
|
||||
| `REDIS_HOST` | `localhost` | Redis server hostname/IP |
|
||||
| `REDIS_PORT` | `6379` | Redis server port |
|
||||
| `REDIS_DB` | `0` | Redis database number |
|
||||
| `REDIS_CHANNEL` | `ui:updates` | Redis pub/sub channel |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### `GET /health`
|
||||
|
||||
444
apps/api/main.py
444
apps/api/main.py
@@ -1,5 +1,3 @@
|
||||
"""API main entry point."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
@@ -15,10 +13,47 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from packages.home_capabilities import CAP_VERSION, LightState
|
||||
from packages.home_capabilities import (
|
||||
LIGHT_VERSION,
|
||||
THERMOSTAT_VERSION,
|
||||
CONTACT_SENSOR_VERSION,
|
||||
TEMP_HUMIDITY_SENSOR_VERSION,
|
||||
RELAY_VERSION,
|
||||
LightState,
|
||||
ThermostatState,
|
||||
ContactState,
|
||||
TempHumidityState,
|
||||
RelayState,
|
||||
load_layout,
|
||||
)
|
||||
|
||||
# Import resolvers (must be before router imports to avoid circular dependency)
|
||||
from apps.api.resolvers import (
|
||||
DeviceDTO,
|
||||
resolve_group_devices,
|
||||
resolve_scene_step_devices,
|
||||
load_device_rooms,
|
||||
get_room,
|
||||
clear_room_cache,
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# STATE CACHES
|
||||
# ============================================================================
|
||||
|
||||
# In-memory cache for last known device states
|
||||
# Will be populated from Redis pub/sub messages
|
||||
device_states: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# Background task reference
|
||||
background_task: asyncio.Task | None = None
|
||||
|
||||
app = FastAPI(
|
||||
title="Home Automation API",
|
||||
description="API for home automation system",
|
||||
@@ -30,6 +65,7 @@ app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://localhost:8002",
|
||||
"http://172.19.1.11:8002",
|
||||
"http://127.0.0.1:8002",
|
||||
],
|
||||
allow_credentials=True,
|
||||
@@ -37,6 +73,55 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.get("/devices/{device_id}/layout")
|
||||
async def get_device_layout(device_id: str):
|
||||
"""Gibt die layout-spezifischen Informationen für ein einzelnes Gerät zurück."""
|
||||
layout = load_layout()
|
||||
for room in layout.rooms:
|
||||
for device in room.devices:
|
||||
if device.device_id == device_id:
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"room": room.name,
|
||||
"title": device.title,
|
||||
"icon": device.icon,
|
||||
"rank": device.rank,
|
||||
}
|
||||
raise HTTPException(status_code=404, detail="Device layout not found")
|
||||
|
||||
@app.get("/devices/{device_id}/state")
|
||||
async def get_device_state(device_id: str):
|
||||
try:
|
||||
logger.debug(f"Fetching state for device {device_id}")
|
||||
state = device_states[device_id]
|
||||
return state
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Device state not found")
|
||||
|
||||
# --- Minimal-invasive: Einzelgerät-Layout-Endpunkt ---
|
||||
@app.get("/devices/{device_id}/layout")
|
||||
async def get_device_layout(device_id: str):
|
||||
"""Gibt die layout-spezifischen Informationen für ein einzelnes Gerät zurück."""
|
||||
layout = load_layout()
|
||||
for room in layout.get("rooms", []):
|
||||
for device in room.get("devices", []):
|
||||
if device.get("device_id") == device_id:
|
||||
# Rückgabe: Layout-Infos + Raumname
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"room": room.get("name"),
|
||||
"title": device.get("title"),
|
||||
"icon": device.get("icon"),
|
||||
"rank": device.get("rank"),
|
||||
}
|
||||
raise HTTPException(status_code=404, detail="Device layout not found")
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Include routers after app is initialized to avoid circular imports."""
|
||||
from apps.api.routes.groups_scenes import router as groups_scenes_router
|
||||
app.include_router(groups_scenes_router, prefix="")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
@@ -48,6 +133,77 @@ async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def redis_state_listener():
|
||||
"""Background task that listens to Redis pub/sub and updates state cache."""
|
||||
redis_client = None
|
||||
pubsub = None
|
||||
|
||||
try:
|
||||
redis_url, redis_channel = get_redis_settings()
|
||||
logger.info(f"Starting Redis state listener for channel {redis_channel}")
|
||||
|
||||
redis_client = await aioredis.from_url(redis_url, decode_responses=True)
|
||||
pubsub = redis_client.pubsub()
|
||||
await pubsub.subscribe(redis_channel)
|
||||
|
||||
logger.info("Redis state listener connected")
|
||||
|
||||
while True:
|
||||
try:
|
||||
message = await asyncio.wait_for(
|
||||
pubsub.get_message(ignore_subscribe_messages=True),
|
||||
timeout=1.0
|
||||
)
|
||||
|
||||
if message and message["type"] == "message":
|
||||
data = message["data"]
|
||||
try:
|
||||
state_data = json.loads(data)
|
||||
if state_data.get("type") == "state" and state_data.get("device_id"):
|
||||
device_id = state_data["device_id"]
|
||||
payload = state_data.get("payload", {})
|
||||
device_states[device_id] = payload
|
||||
logger.debug(f"Updated state cache for {device_id}: {payload}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse state data: {e}")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
pass # No message, continue
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Redis state listener cancelled")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Redis state listener error: {e}")
|
||||
finally:
|
||||
if pubsub:
|
||||
await pubsub.unsubscribe(redis_channel)
|
||||
await pubsub.close()
|
||||
if redis_client:
|
||||
await redis_client.close()
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Start background tasks on application startup."""
|
||||
global background_task
|
||||
background_task = asyncio.create_task(redis_state_listener())
|
||||
logger.info("Started background Redis state listener")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""Clean up background tasks on application shutdown."""
|
||||
global background_task
|
||||
if background_task:
|
||||
background_task.cancel()
|
||||
try:
|
||||
await background_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("Stopped background Redis state listener")
|
||||
|
||||
|
||||
@app.get("/spec")
|
||||
async def spec() -> dict[str, dict[str, str]]:
|
||||
"""Capability specification endpoint.
|
||||
@@ -57,7 +213,11 @@ async def spec() -> dict[str, dict[str, str]]:
|
||||
"""
|
||||
return {
|
||||
"capabilities": {
|
||||
"light": CAP_VERSION
|
||||
"light": LIGHT_VERSION,
|
||||
"thermostat": THERMOSTAT_VERSION,
|
||||
"contact": CONTACT_SENSOR_VERSION,
|
||||
"temp_humidity": TEMP_HUMIDITY_SENSOR_VERSION,
|
||||
"relay": RELAY_VERSION
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,20 +263,81 @@ def load_devices() -> list[dict[str, Any]]:
|
||||
def get_mqtt_settings() -> tuple[str, int]:
|
||||
"""Get MQTT broker settings from environment.
|
||||
|
||||
Supports both MQTT_BROKER and MQTT_HOST for compatibility.
|
||||
|
||||
Returns:
|
||||
tuple: (host, port)
|
||||
"""
|
||||
host = os.environ.get("MQTT_HOST", "172.16.2.16")
|
||||
host = os.environ.get("MQTT_BROKER") or os.environ.get("MQTT_HOST", "172.16.2.16")
|
||||
port = int(os.environ.get("MQTT_PORT", "1883"))
|
||||
return host, port
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MQTT PUBLISH
|
||||
# ============================================================================
|
||||
|
||||
async def publish_abstract_set(device_type: str, device_id: str, payload: dict[str, Any]) -> None:
|
||||
"""
|
||||
Publish an abstract set command via MQTT.
|
||||
|
||||
This function encapsulates MQTT publishing logic so that group/scene
|
||||
execution doesn't need to know MQTT topic details.
|
||||
|
||||
Topic format: home/{device_type}/{device_id}/set
|
||||
Message format: {"type": device_type, "payload": payload}
|
||||
|
||||
Args:
|
||||
device_type: Device type (light, thermostat, relay, etc.)
|
||||
device_id: Device identifier
|
||||
payload: Command payload (e.g., {"power": "on", "brightness": 50})
|
||||
|
||||
Example:
|
||||
>>> await publish_abstract_set("light", "kueche_deckenlampe", {"power": "on", "brightness": 35})
|
||||
# Publishes to: home/light/kueche_deckenlampe/set
|
||||
# Message: {"type": "light", "payload": {"power": "on", "brightness": 35}}
|
||||
"""
|
||||
mqtt_host, mqtt_port = get_mqtt_settings()
|
||||
topic = f"home/{device_type}/{device_id}/set"
|
||||
message = {
|
||||
"type": device_type,
|
||||
"payload": payload
|
||||
}
|
||||
|
||||
try:
|
||||
async with Client(hostname=mqtt_host, port=mqtt_port) as client:
|
||||
await client.publish(
|
||||
topic=topic,
|
||||
payload=json.dumps(message),
|
||||
qos=1
|
||||
)
|
||||
logger.info(f"Published to {topic}: {message}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to publish to {topic}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def get_redis_settings() -> tuple[str, str]:
|
||||
"""Get Redis settings from configuration.
|
||||
|
||||
Prioritizes environment variables over config file:
|
||||
- REDIS_HOST, REDIS_PORT, REDIS_DB → redis://host:port/db
|
||||
- REDIS_CHANNEL → pub/sub channel name
|
||||
|
||||
Returns:
|
||||
tuple: (url, channel)
|
||||
"""
|
||||
# Check environment variables first
|
||||
redis_host = os.getenv("REDIS_HOST")
|
||||
redis_port = os.getenv("REDIS_PORT", "6379")
|
||||
redis_db = os.getenv("REDIS_DB", "0")
|
||||
redis_channel = os.getenv("REDIS_CHANNEL", "ui:updates")
|
||||
|
||||
if redis_host:
|
||||
url = f"redis://{redis_host}:{redis_port}/{redis_db}"
|
||||
return url, redis_channel
|
||||
|
||||
# Fallback to config file
|
||||
config_path = Path(__file__).parent.parent.parent / "config" / "devices.yaml"
|
||||
|
||||
if config_path.exists():
|
||||
@@ -144,6 +365,32 @@ async def publish_mqtt(topic: str, payload: dict[str, Any]) -> None:
|
||||
await client.publish(topic, message, qos=1)
|
||||
|
||||
|
||||
@app.get("/devices/states")
|
||||
async def get_device_states() -> dict[str, dict[str, Any]]:
|
||||
"""Get current states of all devices from in-memory cache.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary mapping device_id to state payload
|
||||
"""
|
||||
logger.debug("Fetching all device states")
|
||||
return device_states
|
||||
|
||||
|
||||
@app.get("/devices/{device_id}")
|
||||
async def get_device(device_id: str) -> DeviceInfo:
|
||||
logger.debug(f"Fetching info for device {device_id}")
|
||||
devices = load_devices()
|
||||
device = next((d for d in devices if d["device_id"] == device_id), None)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
return DeviceInfo(
|
||||
device_id=device["device_id"],
|
||||
type=device["type"],
|
||||
name=device.get("name", device["device_id"]),
|
||||
features=device.get("features", {})
|
||||
)
|
||||
|
||||
|
||||
@app.get("/devices")
|
||||
async def get_devices() -> list[DeviceInfo]:
|
||||
"""Get list of available devices.
|
||||
@@ -151,6 +398,7 @@ async def get_devices() -> list[DeviceInfo]:
|
||||
Returns:
|
||||
list: List of device information including features
|
||||
"""
|
||||
logger.debug("Fetching list of devices")
|
||||
devices = load_devices()
|
||||
return [
|
||||
DeviceInfo(
|
||||
@@ -163,6 +411,7 @@ async def get_devices() -> list[DeviceInfo]:
|
||||
]
|
||||
|
||||
|
||||
|
||||
@app.get("/layout")
|
||||
async def get_layout() -> dict[str, Any]:
|
||||
"""Get UI layout configuration.
|
||||
@@ -170,8 +419,6 @@ async def get_layout() -> dict[str, Any]:
|
||||
Returns:
|
||||
dict: Layout configuration with rooms and device tiles
|
||||
"""
|
||||
from packages.home_capabilities import load_layout
|
||||
|
||||
try:
|
||||
layout = load_layout()
|
||||
|
||||
@@ -200,6 +447,23 @@ async def get_layout() -> dict[str, Any]:
|
||||
return {"rooms": []}
|
||||
|
||||
|
||||
@app.get("/devices/{device_id}/room")
|
||||
async def get_device_room(device_id: str) -> dict[str, str | None]:
|
||||
"""Get the room name for a specific device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Returns:
|
||||
dict: {"device_id": str, "room": str | null}
|
||||
"""
|
||||
room = get_room(device_id)
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"room": room
|
||||
}
|
||||
|
||||
|
||||
@app.post("/devices/{device_id}/set", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str]:
|
||||
"""Set device state.
|
||||
@@ -224,6 +488,13 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str
|
||||
detail=f"Device {device_id} not found"
|
||||
)
|
||||
|
||||
# Check if device is read-only (contact sensors, etc.)
|
||||
if "topics" in device and "set" not in device["topics"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
||||
detail="Device is read-only"
|
||||
)
|
||||
|
||||
# Validate payload based on device type
|
||||
if request.type == "light":
|
||||
try:
|
||||
@@ -233,6 +504,42 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid payload for light: {e}"
|
||||
)
|
||||
elif request.type == "relay":
|
||||
try:
|
||||
RelayState(**request.payload)
|
||||
except ValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid payload for relay: {e}"
|
||||
)
|
||||
elif request.type == "thermostat":
|
||||
try:
|
||||
# For thermostat SET: only allow mode and target
|
||||
allowed_set_fields = {"mode", "target"}
|
||||
invalid_fields = set(request.payload.keys()) - allowed_set_fields
|
||||
if invalid_fields:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Thermostat SET only allows {allowed_set_fields}, got invalid fields: {invalid_fields}"
|
||||
)
|
||||
ThermostatState(**request.payload)
|
||||
except ValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid payload for thermostat: {e}"
|
||||
)
|
||||
elif request.type in {"contact", "contact_sensor"}:
|
||||
# Contact sensors are read-only
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
||||
detail="Contact sensors are read-only devices"
|
||||
)
|
||||
elif request.type in {"temp_humidity", "temp_humidity_sensor"}:
|
||||
# Temperature & humidity sensors are read-only
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
||||
detail="Temperature & humidity sensors are read-only devices"
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
@@ -252,7 +559,13 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str
|
||||
|
||||
|
||||
async def event_generator(request: Request) -> AsyncGenerator[str, None]:
|
||||
"""Generate SSE events from Redis Pub/Sub.
|
||||
"""Generate SSE events from Redis Pub/Sub with Safari compatibility.
|
||||
|
||||
Safari-compatible features:
|
||||
- Immediate retry hint on connection
|
||||
- Regular heartbeats every 15s (comment-only, no data)
|
||||
- Proper flushing after each yield
|
||||
- Graceful disconnect handling
|
||||
|
||||
Args:
|
||||
request: FastAPI request object for disconnect detection
|
||||
@@ -260,70 +573,125 @@ async def event_generator(request: Request) -> AsyncGenerator[str, None]:
|
||||
Yields:
|
||||
str: SSE formatted event strings
|
||||
"""
|
||||
redis_url, redis_channel = get_redis_settings()
|
||||
redis_client = await aioredis.from_url(redis_url, decode_responses=True)
|
||||
pubsub = redis_client.pubsub()
|
||||
redis_client = None
|
||||
pubsub = None
|
||||
|
||||
try:
|
||||
await pubsub.subscribe(redis_channel)
|
||||
# Send retry hint immediately for EventSource reconnect behavior
|
||||
yield "retry: 2500\n\n"
|
||||
|
||||
# Create heartbeat task
|
||||
# Try to connect to Redis
|
||||
redis_url, redis_channel = get_redis_settings()
|
||||
try:
|
||||
redis_client = await aioredis.from_url(redis_url, decode_responses=True)
|
||||
pubsub = redis_client.pubsub()
|
||||
await pubsub.subscribe(redis_channel)
|
||||
logger.info(f"SSE client connected, subscribed to {redis_channel}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis unavailable, running in heartbeat-only mode: {e}")
|
||||
redis_client = None
|
||||
pubsub = None
|
||||
|
||||
# Heartbeat tracking
|
||||
last_heartbeat = asyncio.get_event_loop().time()
|
||||
heartbeat_interval = 15 # Safari-friendly: shorter interval
|
||||
|
||||
while True:
|
||||
# Check if client disconnected
|
||||
if await request.is_disconnected():
|
||||
logger.info("SSE client disconnected")
|
||||
break
|
||||
|
||||
# Get message with timeout for heartbeat
|
||||
try:
|
||||
message = await asyncio.wait_for(
|
||||
pubsub.get_message(ignore_subscribe_messages=True),
|
||||
timeout=1.0
|
||||
)
|
||||
|
||||
if message and message["type"] == "message":
|
||||
# Send data event
|
||||
data = message["data"]
|
||||
yield f"event: message\ndata: {data}\n\n"
|
||||
last_heartbeat = asyncio.get_event_loop().time()
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
# Try to get message from Redis (if available)
|
||||
if pubsub:
|
||||
try:
|
||||
message = await asyncio.wait_for(
|
||||
pubsub.get_message(ignore_subscribe_messages=True),
|
||||
timeout=0.1
|
||||
)
|
||||
|
||||
if message and message["type"] == "message":
|
||||
data = message["data"]
|
||||
logger.debug(f"Sending SSE message: {data[:100]}...")
|
||||
|
||||
# Update in-memory cache with latest state
|
||||
try:
|
||||
state_data = json.loads(data)
|
||||
if state_data.get("type") == "state" and state_data.get("device_id"):
|
||||
device_states[state_data["device_id"]] = state_data.get("payload", {})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse state data for cache: {e}")
|
||||
|
||||
yield f"event: message\ndata: {data}\n\n"
|
||||
last_heartbeat = asyncio.get_event_loop().time()
|
||||
continue # Skip sleep, check for more messages immediately
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
pass # No message, continue to heartbeat check
|
||||
except Exception as e:
|
||||
logger.error(f"Redis error: {e}")
|
||||
# Continue with heartbeats even if Redis fails
|
||||
|
||||
# Send heartbeat every 25 seconds
|
||||
# Sleep briefly to avoid busy loop
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Send heartbeat if interval elapsed
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
if current_time - last_heartbeat >= 25:
|
||||
yield "event: ping\ndata: heartbeat\n\n"
|
||||
if current_time - last_heartbeat >= heartbeat_interval:
|
||||
# Comment-style ping (Safari-compatible, no event type)
|
||||
yield ": ping\n\n"
|
||||
last_heartbeat = current_time
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("SSE connection cancelled by client")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"SSE error: {e}")
|
||||
raise
|
||||
finally:
|
||||
await pubsub.unsubscribe(redis_channel)
|
||||
await pubsub.close()
|
||||
await redis_client.close()
|
||||
# Cleanup Redis connection
|
||||
if pubsub:
|
||||
try:
|
||||
await pubsub.unsubscribe(redis_channel)
|
||||
await pubsub.aclose()
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing pubsub: {e}")
|
||||
|
||||
if redis_client:
|
||||
try:
|
||||
await redis_client.aclose()
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing redis: {e}")
|
||||
|
||||
logger.info("SSE connection closed")
|
||||
|
||||
|
||||
@app.get("/realtime")
|
||||
async def realtime_events(request: Request) -> StreamingResponse:
|
||||
"""Server-Sent Events endpoint for real-time updates.
|
||||
|
||||
Safari-compatible SSE implementation:
|
||||
- Immediate retry hint (2.5s reconnect delay)
|
||||
- Heartbeat every 15s using comment syntax ": ping"
|
||||
- Proper Cache-Control headers
|
||||
- No buffering (nginx compatibility)
|
||||
- Graceful Redis fallback (heartbeat-only mode)
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
|
||||
Returns:
|
||||
StreamingResponse: SSE stream of Redis messages
|
||||
StreamingResponse: SSE stream with Redis messages and heartbeats
|
||||
"""
|
||||
return StreamingResponse(
|
||||
event_generator(request),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no", # Disable nginx buffering
|
||||
}
|
||||
)
|
||||
|
||||
return {"message": f"Command sent to {device_id}"}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
6
apps/api/requirements.txt
Normal file
6
apps/api/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
pydantic>=2
|
||||
redis==5.0.1
|
||||
aiomqtt==2.0.1
|
||||
pyyaml==6.0.1
|
||||
286
apps/api/resolvers.py
Normal file
286
apps/api/resolvers.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""Group and scene resolution logic."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from packages.home_capabilities import (
|
||||
GroupConfig,
|
||||
GroupsConfigRoot,
|
||||
SceneStep,
|
||||
get_group_by_id,
|
||||
load_layout,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TYPE DEFINITIONS
|
||||
# ============================================================================
|
||||
|
||||
class DeviceDTO(TypedDict, total=False):
|
||||
"""Device Data Transfer Object.
|
||||
|
||||
Represents a device as returned by /devices endpoint or load_devices().
|
||||
|
||||
Required fields:
|
||||
device_id: Unique device identifier
|
||||
type: Device type (light, thermostat, relay, etc.)
|
||||
|
||||
Optional fields:
|
||||
name: Human-readable device name
|
||||
features: Device capabilities (power, brightness, etc.)
|
||||
technology: MQTT, zigbee2mqtt, simulator, etc.
|
||||
topics: MQTT topic configuration
|
||||
metadata: Additional device information
|
||||
"""
|
||||
device_id: str
|
||||
type: str
|
||||
name: str
|
||||
features: dict[str, Any]
|
||||
technology: str
|
||||
topics: dict[str, str]
|
||||
metadata: dict[str, Any]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DEVICE-ROOM MAPPING
|
||||
# ============================================================================
|
||||
|
||||
# Global cache for device -> room mapping
|
||||
_device_room_cache: dict[str, str] = {}
|
||||
|
||||
|
||||
def load_device_rooms(path: str | Path | None = None) -> dict[str, str]:
|
||||
"""
|
||||
Load device-to-room mapping from layout configuration.
|
||||
|
||||
This function extracts a mapping of device_id -> room_name from the layout.yaml
|
||||
file, which is useful for resolving selectors like {room: "Küche"}.
|
||||
|
||||
Args:
|
||||
path: Optional path to layout.yaml. If None, uses default path
|
||||
(config/layout.yaml relative to workspace root)
|
||||
|
||||
Returns:
|
||||
Dictionary mapping device_id to room_name. Returns empty dict if:
|
||||
- layout.yaml doesn't exist
|
||||
- layout.yaml is malformed
|
||||
- layout.yaml is empty
|
||||
|
||||
Example:
|
||||
>>> mapping = load_device_rooms()
|
||||
>>> mapping['kueche_lampe1']
|
||||
'Küche'
|
||||
"""
|
||||
global _device_room_cache
|
||||
|
||||
try:
|
||||
# Load the layout using existing function
|
||||
layout = load_layout(path)
|
||||
|
||||
# Build device -> room mapping
|
||||
device_rooms: dict[str, str] = {}
|
||||
for room in layout.rooms:
|
||||
for device in room.devices:
|
||||
device_rooms[device.device_id] = room.name
|
||||
|
||||
# Update global cache
|
||||
_device_room_cache = device_rooms.copy()
|
||||
|
||||
logger.info(f"Loaded device-room mapping: {len(device_rooms)} devices")
|
||||
return device_rooms
|
||||
|
||||
except (FileNotFoundError, ValueError, Exception) as e:
|
||||
logger.warning(f"Failed to load device-room mapping: {e}")
|
||||
logger.warning("Returning empty device-room mapping")
|
||||
_device_room_cache = {}
|
||||
return {}
|
||||
|
||||
|
||||
def get_room(device_id: str) -> str | None:
|
||||
"""
|
||||
Get the room name for a given device ID.
|
||||
|
||||
This function uses the cached device-room mapping loaded by load_device_rooms().
|
||||
If the cache is empty, it will attempt to load it first.
|
||||
|
||||
Args:
|
||||
device_id: The device identifier to lookup
|
||||
|
||||
Returns:
|
||||
Room name if device is found, None otherwise
|
||||
|
||||
Example:
|
||||
>>> get_room('kueche_lampe1')
|
||||
'Küche'
|
||||
>>> get_room('nonexistent_device')
|
||||
None
|
||||
"""
|
||||
# Check if cache is populated
|
||||
if not _device_room_cache:
|
||||
logger.debug("Device-room cache empty, loading from layout...")
|
||||
# Load mapping (this updates the global _device_room_cache)
|
||||
load_device_rooms()
|
||||
|
||||
# Access the cache after potential reload
|
||||
return _device_room_cache.get(device_id)
|
||||
|
||||
|
||||
def clear_room_cache() -> None:
|
||||
"""
|
||||
Clear the cached device-room mapping.
|
||||
|
||||
This is useful for testing or when the layout configuration has changed
|
||||
and needs to be reloaded.
|
||||
"""
|
||||
_device_room_cache.clear()
|
||||
logger.debug("Cleared device-room cache")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GROUP & SCENE RESOLUTION
|
||||
# ============================================================================
|
||||
|
||||
def resolve_group_devices(
|
||||
group: GroupConfig,
|
||||
devices: list[DeviceDTO],
|
||||
device_rooms: dict[str, str]
|
||||
) -> list[DeviceDTO]:
|
||||
"""
|
||||
Resolve devices for a group based on device_ids or selector.
|
||||
|
||||
Args:
|
||||
group: Group configuration with device_ids or selector
|
||||
devices: List of all available devices
|
||||
device_rooms: Mapping of device_id -> room_name
|
||||
|
||||
Returns:
|
||||
List of devices matching the group criteria (no duplicates)
|
||||
|
||||
Example:
|
||||
>>> # Group with explicit device_ids
|
||||
>>> group = GroupConfig(id="test", name="Test", device_ids=["lamp1", "lamp2"])
|
||||
>>> resolve_group_devices(group, all_devices, {})
|
||||
[{"device_id": "lamp1", ...}, {"device_id": "lamp2", ...}]
|
||||
|
||||
>>> # Group with selector (all lights in kitchen)
|
||||
>>> group = GroupConfig(
|
||||
... id="kitchen_lights",
|
||||
... name="Kitchen Lights",
|
||||
... selector=GroupSelector(type="light", room="Küche")
|
||||
... )
|
||||
>>> resolve_group_devices(group, all_devices, device_rooms)
|
||||
[{"device_id": "kueche_deckenlampe", ...}, ...]
|
||||
"""
|
||||
# Case 1: Explicit device_ids
|
||||
if group.device_ids:
|
||||
device_id_set = set(group.device_ids)
|
||||
return [d for d in devices if d["device_id"] in device_id_set]
|
||||
|
||||
# Case 2: Selector-based filtering
|
||||
if group.selector:
|
||||
filtered = []
|
||||
|
||||
for device in devices:
|
||||
# Filter by type (required in selector)
|
||||
if device["type"] != group.selector.type:
|
||||
continue
|
||||
|
||||
# Filter by room (optional)
|
||||
if group.selector.room:
|
||||
device_room = device_rooms.get(device["device_id"])
|
||||
if device_room != group.selector.room:
|
||||
continue
|
||||
|
||||
# Filter by tags (optional, future feature)
|
||||
# if group.selector.tags:
|
||||
# device_tags = device.get("metadata", {}).get("tags", [])
|
||||
# if not any(tag in device_tags for tag in group.selector.tags):
|
||||
# continue
|
||||
|
||||
filtered.append(device)
|
||||
|
||||
return filtered
|
||||
|
||||
# No device_ids and no selector → empty list
|
||||
return []
|
||||
|
||||
|
||||
def resolve_scene_step_devices(
|
||||
step: SceneStep,
|
||||
groups_config: GroupsConfigRoot,
|
||||
devices: list[DeviceDTO],
|
||||
device_rooms: dict[str, str]
|
||||
) -> list[DeviceDTO]:
|
||||
"""
|
||||
Resolve devices for a scene step based on group_id or selector.
|
||||
|
||||
Args:
|
||||
step: Scene step with group_id or selector
|
||||
groups_config: Groups configuration for group lookup
|
||||
devices: List of all available devices
|
||||
device_rooms: Mapping of device_id -> room_name
|
||||
|
||||
Returns:
|
||||
List of devices matching the step criteria
|
||||
|
||||
Raises:
|
||||
ValueError: If group_id is specified but group not found
|
||||
|
||||
Example:
|
||||
>>> # Step with group_id
|
||||
>>> step = SceneStep(group_id="kitchen_lights", action={...})
|
||||
>>> resolve_scene_step_devices(step, groups_cfg, all_devices, device_rooms)
|
||||
[{"device_id": "kueche_deckenlampe", ...}, ...]
|
||||
|
||||
>>> # Step with selector
|
||||
>>> step = SceneStep(
|
||||
... selector=SceneSelector(type="light", room="Küche"),
|
||||
... action={...}
|
||||
... )
|
||||
>>> resolve_scene_step_devices(step, groups_cfg, all_devices, device_rooms)
|
||||
[{"device_id": "kueche_deckenlampe", ...}, ...]
|
||||
"""
|
||||
# Case 1: Group reference
|
||||
if step.group_id:
|
||||
# Look up the group
|
||||
group = get_group_by_id(groups_config, step.group_id)
|
||||
|
||||
if not group:
|
||||
raise ValueError(
|
||||
f"Scene step references unknown group_id: '{step.group_id}'. "
|
||||
f"Available groups: {[g.id for g in groups_config.groups]}"
|
||||
)
|
||||
|
||||
# Resolve the group's devices
|
||||
return resolve_group_devices(group, devices, device_rooms)
|
||||
|
||||
# Case 2: Direct selector
|
||||
if step.selector:
|
||||
filtered = []
|
||||
|
||||
for device in devices:
|
||||
# Filter by type (optional in scene selector)
|
||||
if step.selector.type and device["type"] != step.selector.type:
|
||||
continue
|
||||
|
||||
# Filter by room (optional)
|
||||
if step.selector.room:
|
||||
device_room = device_rooms.get(device["device_id"])
|
||||
if device_room != step.selector.room:
|
||||
continue
|
||||
|
||||
# Filter by tags (optional, future feature)
|
||||
# if step.selector.tags:
|
||||
# device_tags = device.get("metadata", {}).get("tags", [])
|
||||
# if not any(tag in device_tags for tag in step.selector.tags):
|
||||
# continue
|
||||
|
||||
filtered.append(device)
|
||||
|
||||
return filtered
|
||||
|
||||
# Should not reach here due to SceneStep validation (must have group_id or selector)
|
||||
return []
|
||||
1
apps/api/routes/__init__.py
Normal file
1
apps/api/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API routes package."""
|
||||
454
apps/api/routes/groups_scenes.py
Normal file
454
apps/api/routes/groups_scenes.py
Normal file
@@ -0,0 +1,454 @@
|
||||
"""Groups and Scenes API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from packages.home_capabilities import (
|
||||
GroupConfig,
|
||||
GroupsConfigRoot,
|
||||
SceneConfig,
|
||||
ScenesConfigRoot,
|
||||
get_group_by_id,
|
||||
get_scene_by_id,
|
||||
load_groups,
|
||||
load_scenes,
|
||||
)
|
||||
|
||||
# Import from parent modules
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from resolvers import (
|
||||
DeviceDTO,
|
||||
resolve_group_devices,
|
||||
resolve_scene_step_devices,
|
||||
load_device_rooms,
|
||||
)
|
||||
from main import load_devices, publish_abstract_set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# REQUEST/RESPONSE MODELS
|
||||
# ============================================================================
|
||||
|
||||
class GroupResponse(BaseModel):
|
||||
"""Response model for a group."""
|
||||
id: str
|
||||
name: str
|
||||
device_count: int
|
||||
devices: list[str]
|
||||
selector: dict[str, Any] | None = None
|
||||
capabilities: dict[str, bool]
|
||||
|
||||
|
||||
class GroupSetRequest(BaseModel):
|
||||
"""Request to set state for all devices in a group."""
|
||||
action: dict[str, Any] # e.g., {"type": "light", "payload": {"power": "on", "brightness": 50}}
|
||||
|
||||
|
||||
class SceneResponse(BaseModel):
|
||||
"""Response model for a scene."""
|
||||
id: str
|
||||
name: str
|
||||
steps: int
|
||||
|
||||
|
||||
class SceneRunRequest(BaseModel):
|
||||
"""Request to execute a scene (currently empty, future: override params)."""
|
||||
pass
|
||||
|
||||
|
||||
class SceneExecutionResponse(BaseModel):
|
||||
"""Response after scene execution."""
|
||||
scene_id: str
|
||||
scene_name: str
|
||||
steps_executed: int
|
||||
devices_affected: int
|
||||
execution_plan: list[dict[str, Any]]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GROUPS ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/groups", response_model=list[GroupResponse], tags=["groups"])
|
||||
async def list_groups() -> list[GroupResponse]:
|
||||
"""
|
||||
List all available groups.
|
||||
|
||||
Returns:
|
||||
list[GroupResponse]: List of groups with their devices
|
||||
"""
|
||||
try:
|
||||
# Load configuration
|
||||
groups_config = load_groups(Path(__file__).parent.parent.parent.parent / "config" / "groups.yaml")
|
||||
devices = load_devices()
|
||||
device_rooms = load_device_rooms()
|
||||
|
||||
# Build response for each group
|
||||
response = []
|
||||
for group in groups_config.groups:
|
||||
# Resolve devices for this group
|
||||
resolved_devices = resolve_group_devices(group, devices, device_rooms)
|
||||
device_ids = [d["device_id"] for d in resolved_devices]
|
||||
|
||||
# Convert selector to dict if present
|
||||
selector_dict = None
|
||||
if group.selector:
|
||||
selector_dict = {
|
||||
"type": group.selector.type,
|
||||
"room": group.selector.room,
|
||||
"tags": group.selector.tags,
|
||||
}
|
||||
|
||||
response.append(GroupResponse(
|
||||
id=group.id,
|
||||
name=group.name,
|
||||
device_count=len(device_ids),
|
||||
devices=device_ids,
|
||||
selector=selector_dict,
|
||||
capabilities=group.capabilities,
|
||||
))
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading groups: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to load groups: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}", response_model=GroupResponse, tags=["groups"])
|
||||
async def get_group(group_id: str) -> GroupResponse:
|
||||
"""
|
||||
Get details for a specific group.
|
||||
|
||||
Args:
|
||||
group_id: Group identifier
|
||||
|
||||
Returns:
|
||||
GroupResponse: Group details with resolved devices
|
||||
"""
|
||||
try:
|
||||
# Load configuration
|
||||
groups_config = load_groups(Path(__file__).parent.parent.parent.parent / "config" / "groups.yaml")
|
||||
devices = load_devices()
|
||||
device_rooms = load_device_rooms()
|
||||
|
||||
# Find the group
|
||||
group = get_group_by_id(groups_config, group_id)
|
||||
if not group:
|
||||
available_groups = [g.id for g in groups_config.groups]
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Group '{group_id}' not found. Available groups: {available_groups}"
|
||||
)
|
||||
|
||||
# Resolve devices
|
||||
resolved_devices = resolve_group_devices(group, devices, device_rooms)
|
||||
device_ids = [d["device_id"] for d in resolved_devices]
|
||||
|
||||
# Convert selector to dict if present
|
||||
selector_dict = None
|
||||
if group.selector:
|
||||
selector_dict = {
|
||||
"type": group.selector.type,
|
||||
"room": group.selector.room,
|
||||
"tags": group.selector.tags,
|
||||
}
|
||||
|
||||
return GroupResponse(
|
||||
id=group.id,
|
||||
name=group.name,
|
||||
device_count=len(device_ids),
|
||||
devices=device_ids,
|
||||
selector=selector_dict,
|
||||
capabilities=group.capabilities,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting group {group_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get group: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/set", status_code=status.HTTP_202_ACCEPTED, tags=["groups"])
|
||||
async def set_group(group_id: str, request: GroupSetRequest) -> dict[str, Any]:
|
||||
"""
|
||||
Set state for all devices in a group.
|
||||
|
||||
This endpoint resolves the group to its devices and would send
|
||||
the action to each device. Currently returns execution plan.
|
||||
|
||||
Args:
|
||||
group_id: Group identifier
|
||||
request: Action to apply to all devices in the group
|
||||
|
||||
Returns:
|
||||
dict: Execution plan (devices and actions to be executed)
|
||||
"""
|
||||
try:
|
||||
# Load configuration
|
||||
groups_config = load_groups(Path(__file__).parent.parent.parent.parent / "config" / "groups.yaml")
|
||||
devices = load_devices()
|
||||
device_rooms = load_device_rooms()
|
||||
|
||||
# Find the group
|
||||
group = get_group_by_id(groups_config, group_id)
|
||||
if not group:
|
||||
available_groups = [g.id for g in groups_config.groups]
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Group '{group_id}' not found. Available groups: {available_groups}"
|
||||
)
|
||||
|
||||
# Resolve devices
|
||||
resolved_devices = resolve_group_devices(group, devices, device_rooms)
|
||||
|
||||
if not resolved_devices:
|
||||
logger.warning(f"Group '{group_id}' resolved to 0 devices")
|
||||
|
||||
# Execute actions via MQTT
|
||||
execution_plan = []
|
||||
for device in resolved_devices:
|
||||
device_type = device["type"]
|
||||
device_id = device["device_id"]
|
||||
payload = request.action.get("payload", {})
|
||||
|
||||
# Publish MQTT command
|
||||
try:
|
||||
await publish_abstract_set(device_type, device_id, payload)
|
||||
execution_plan.append({
|
||||
"device_id": device_id,
|
||||
"device_type": device_type,
|
||||
"action": request.action,
|
||||
"status": "published"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to publish to {device_id}: {e}")
|
||||
execution_plan.append({
|
||||
"device_id": device_id,
|
||||
"device_type": device_type,
|
||||
"action": request.action,
|
||||
"status": "failed",
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
return {
|
||||
"group_id": group_id,
|
||||
"group_name": group.name,
|
||||
"devices_affected": len(resolved_devices),
|
||||
"execution_plan": execution_plan,
|
||||
"status": "executed"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting group {group_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to set group: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SCENES ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/scenes", response_model=list[SceneResponse], tags=["scenes"])
|
||||
async def list_scenes() -> list[SceneResponse]:
|
||||
"""
|
||||
List all available scenes.
|
||||
|
||||
Returns:
|
||||
list[SceneResponse]: List of scenes
|
||||
"""
|
||||
try:
|
||||
# Load configuration
|
||||
scenes_config = load_scenes(Path(__file__).parent.parent.parent.parent / "config" / "scenes.yaml")
|
||||
|
||||
# Build response for each scene
|
||||
response = []
|
||||
for scene in scenes_config.scenes:
|
||||
response.append(SceneResponse(
|
||||
id=scene.id,
|
||||
name=scene.name,
|
||||
steps=len(scene.steps),
|
||||
))
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading scenes: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to load scenes: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scenes/{scene_id}", response_model=SceneResponse, tags=["scenes"])
|
||||
async def get_scene(scene_id: str) -> SceneResponse:
|
||||
"""
|
||||
Get details for a specific scene.
|
||||
|
||||
Args:
|
||||
scene_id: Scene identifier
|
||||
|
||||
Returns:
|
||||
SceneResponse: Scene details
|
||||
"""
|
||||
try:
|
||||
# Load configuration
|
||||
scenes_config = load_scenes(Path(__file__).parent.parent.parent.parent / "config" / "scenes.yaml")
|
||||
|
||||
# Find the scene
|
||||
scene = get_scene_by_id(scenes_config, scene_id)
|
||||
if not scene:
|
||||
available_scenes = [s.id for s in scenes_config.scenes]
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Scene '{scene_id}' not found. Available scenes: {available_scenes}"
|
||||
)
|
||||
|
||||
return SceneResponse(
|
||||
id=scene.id,
|
||||
name=scene.name,
|
||||
steps=len(scene.steps),
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting scene {scene_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get scene: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/scenes/{scene_id}/run", response_model=SceneExecutionResponse, tags=["scenes"])
|
||||
async def run_scene(scene_id: str, request: SceneRunRequest | None = None) -> SceneExecutionResponse:
|
||||
"""
|
||||
Execute a scene.
|
||||
|
||||
This endpoint resolves each step in the scene to its target devices
|
||||
and would execute the actions. Currently returns execution plan.
|
||||
|
||||
Args:
|
||||
scene_id: Scene identifier
|
||||
request: Optional execution parameters (reserved for future use)
|
||||
|
||||
Returns:
|
||||
SceneExecutionResponse: Execution plan and summary
|
||||
"""
|
||||
try:
|
||||
# Load configuration
|
||||
scenes_config = load_scenes(Path(__file__).parent.parent.parent.parent / "config" / "scenes.yaml")
|
||||
groups_config = load_groups(Path(__file__).parent.parent.parent.parent / "config" / "groups.yaml")
|
||||
devices = load_devices()
|
||||
device_rooms = load_device_rooms()
|
||||
|
||||
# Find the scene
|
||||
scene = get_scene_by_id(scenes_config, scene_id)
|
||||
if not scene:
|
||||
available_scenes = [s.id for s in scenes_config.scenes]
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Scene '{scene_id}' not found. Available scenes: {available_scenes}"
|
||||
)
|
||||
|
||||
# Execute scene steps
|
||||
execution_plan = []
|
||||
total_devices = 0
|
||||
|
||||
for i, step in enumerate(scene.steps, 1):
|
||||
# Resolve devices for this step
|
||||
resolved_devices = resolve_scene_step_devices(step, groups_config, devices, device_rooms)
|
||||
total_devices += len(resolved_devices)
|
||||
|
||||
# Extract action payload
|
||||
action_payload = step.action.get("payload", {})
|
||||
|
||||
# Execute for each device
|
||||
step_executions = []
|
||||
for device in resolved_devices:
|
||||
device_type = device["type"]
|
||||
device_id = device["device_id"]
|
||||
|
||||
try:
|
||||
await publish_abstract_set(device_type, device_id, action_payload)
|
||||
step_executions.append({
|
||||
"device_id": device_id,
|
||||
"status": "published"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to publish to {device_id} in step {i}: {e}")
|
||||
step_executions.append({
|
||||
"device_id": device_id,
|
||||
"status": "failed",
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
# Build step info
|
||||
step_info = {
|
||||
"step": i,
|
||||
"devices_affected": len(resolved_devices),
|
||||
"device_ids": [d["device_id"] for d in resolved_devices],
|
||||
"action": step.action,
|
||||
"executions": step_executions,
|
||||
}
|
||||
|
||||
# Add targeting info
|
||||
if step.group_id:
|
||||
step_info["target"] = {"type": "group_id", "value": step.group_id}
|
||||
elif step.selector:
|
||||
step_info["target"] = {
|
||||
"type": "selector",
|
||||
"selector_type": step.selector.type,
|
||||
"room": step.selector.room,
|
||||
}
|
||||
|
||||
if step.delay_ms:
|
||||
step_info["delay_ms"] = step.delay_ms
|
||||
# Apply delay before next step
|
||||
await asyncio.sleep(step.delay_ms / 1000.0)
|
||||
|
||||
execution_plan.append(step_info)
|
||||
|
||||
return SceneExecutionResponse(
|
||||
scene_id=scene.id,
|
||||
scene_name=scene.name,
|
||||
steps_executed=len(scene.steps),
|
||||
devices_affected=total_devices,
|
||||
execution_plan=execution_plan,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
# Handle unknown group_id in scene step
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error running scene {scene_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to run scene: {str(e)}"
|
||||
)
|
||||
465
apps/homekit/README.md
Normal file
465
apps/homekit/README.md
Normal file
@@ -0,0 +1,465 @@
|
||||
# HomeKit Bridge
|
||||
|
||||
HomeKit Accessory Protocol (HAP) Bridge für das Home Automation System.
|
||||
|
||||
## Überblick
|
||||
|
||||
Diese HomeKit-Bridge ermöglicht die Steuerung aller Geräte über Apple HomeKit / Home-App auf iPhone/iPad/Mac.
|
||||
|
||||
**Architektur:**
|
||||
- Läuft auf Raspberry Pi im lokalen Netzwerk
|
||||
- Kommuniziert ausschließlich über REST-API (kein direkter MQTT/Redis-Zugriff)
|
||||
- Verwendet [HAP-Python](https://github.com/ikalchev/HAP-python) für HomeKit-Protokoll
|
||||
- Unterstützt Echtzeit-Updates über Server-Sent Events (SSE)
|
||||
|
||||
## Implementierungsanleitung für Copilot
|
||||
|
||||
<details>
|
||||
<summary>Copilot-Aufgabe: Implementiere die HomeKit-Bridge (HAP-Python) für unser System</summary>
|
||||
|
||||
### Kontext
|
||||
|
||||
- Wir verwenden HAP-Python als HomeKit-Bridge.
|
||||
- Die Bridge läuft auf einem Raspberry Pi im gleichen LAN wie die iPhones.
|
||||
- Die Bridge kommuniziert AUSSCHLIESSLICH über die REST-API mit dem restlichen System (KEIN direkter MQTT-/Redis-Zugriff).
|
||||
- Geräte- und Raum-Infos kommen aus:
|
||||
- `GET /devices`
|
||||
- `GET /layout`
|
||||
und werden über `device_id` gejoint.
|
||||
- Die Abbildung von abstrakten Gerätetypen/Features auf HomeKit-Services/Characteristics steht in der Datei `apps/homekit/homekit_mapping.md` (Markdown-Tabelle). Bitte orientiere dich daran.
|
||||
|
||||
### Ziel
|
||||
|
||||
Implementiere eine lauffähige HomeKit-Bridge in `apps/homekit`, die:
|
||||
- beim Start Geräte & Räume über die API lädt,
|
||||
- daraus anhand der Mapping-Tabelle HomeKit-Accessories erzeugt,
|
||||
- Zustandsänderungen über einen Realtime-Endpoint der API empfängt und in HomeKit spiegeln kann,
|
||||
- Set-Kommandos aus HomeKit (z.B. Licht an/aus, Helligkeit, Zieltemperatur) per `POST /devices/{id}/set` an die API sendet,
|
||||
- saubere Start-/Stop-Logs ausgibt,
|
||||
- einfache Test-/Startanweisungen für den Raspberry Pi und die Home-App (iPhone) in Kommentaren dokumentiert.
|
||||
|
||||
### WICHTIG
|
||||
|
||||
- Bitte benutze HAP-Python (pyhap).
|
||||
- Keine direkte MQTT- oder Redis-Kommunikation in der Bridge.
|
||||
- Nutze HTTP(S) zur API (z.B. mit requests oder httpx; gerne sync, HAP-Python ist selbst eventloop-orientiert).
|
||||
- Nutze die Mapping-Tabelle in `apps/homekit/homekit_mapping.md` als Leitlinie für die Accessory-Typen.
|
||||
|
||||
### Paketstruktur
|
||||
|
||||
```
|
||||
apps/homekit/
|
||||
├── __init__.py
|
||||
├── main.py # Einstiegspunkt
|
||||
├── api_client.py # REST-Client zur API
|
||||
├── device_registry.py # Join /devices + /layout → internes Device-Modell
|
||||
├── accessories/
|
||||
│ ├── base.py # gemeinsame Basisklasse/n für Accessories
|
||||
│ ├── light.py # Light-Accessories
|
||||
│ ├── thermostat.py
|
||||
│ ├── contact.py
|
||||
│ ├── sensor.py # temp_humidity etc.
|
||||
│ └── cover.py # optional, Rollladen
|
||||
└── tests/ # rudimentäre Tests/Checks
|
||||
```
|
||||
|
||||
## Teil 1: Internes Gerätemodell / Device-Registry
|
||||
|
||||
### 1.1 Erstelle in `apps/homekit/device_registry.py`:
|
||||
|
||||
**Dataklasse/Model Device:**
|
||||
- `device_id: str`
|
||||
- `type: str` - "light","thermostat","outlet","contact","temp_humidity","cover",...
|
||||
- `name: str` - Kurzname aus `GET /devices.name`
|
||||
- `friendly_name: str` - title aus `GET /layout` (fallback name)
|
||||
- `room: str | None` - Raumname aus layout
|
||||
- `features: dict[str, bool]`
|
||||
- `read_only: bool` - heuristisch: Sensor-/Kontakt-Typen sind read_only
|
||||
|
||||
**Klasse DeviceRegistry mit Funktionen:**
|
||||
- `def load_from_api(api: ApiClient) -> DeviceRegistry`:
|
||||
- ruft `GET /devices` und `GET /layout` auf,
|
||||
- joint über `device_id`,
|
||||
- erstellt Device-Instanzen.
|
||||
- `get_all(): list[Device]`
|
||||
- `get_by_id(device_id: str) -> Device | None`
|
||||
|
||||
**Akzeptanz:**
|
||||
- `load_from_api` funktioniert mit der bestehenden Struktur von `/devices` und `/layout`:
|
||||
- `/devices` liefert mindestens `{device_id, type, name, features}`
|
||||
- `/layout` liefert eine Struktur, aus der `device_id → room + title` ableitbar ist
|
||||
- Der Join über `device_id` klappt; fehlende Layout-Einträge werden toleriert
|
||||
|
||||
## Teil 2: API-Client
|
||||
|
||||
### 2.1 Erstelle in `apps/homekit/api_client.py`:
|
||||
|
||||
**Klasse ApiClient mit:**
|
||||
- `__init__(self, base_url: str, token: str | None = None, timeout: int = 5)`
|
||||
- **Methoden:**
|
||||
- `get_devices() -> list[dict]`: GET /devices
|
||||
- `get_layout() -> dict`: GET /layout
|
||||
- `get_device_state(device_id: str) -> dict`: GET /devices/{id}/state
|
||||
- `post_device_set(device_id: str, type: str, payload: dict) -> None`: POST /devices/{id}/set
|
||||
- `stream_realtime() -> Iterator[dict]`:
|
||||
- GET /realtime als SSE, yield jedes Event als dict:
|
||||
`{"type":"state","device_id":...,"payload":{...},"ts":...}`
|
||||
|
||||
**Auth:**
|
||||
- Wenn ein API-Token via ENV `HOMEKIT_API_TOKEN` gesetzt ist, nutze HTTP-Header:
|
||||
`Authorization: Bearer <token>`
|
||||
|
||||
**Akzeptanz:**
|
||||
- ApiClient ist robust:
|
||||
- bei Netzwerkfehlern gibt es sinnvolle Exceptions/Logs,
|
||||
- `stream_realtime` behandelt Reconnect (z.B. einfache Endlosschleife mit Backoff).
|
||||
- Es werden keine MQTT-Details verwendet, nur HTTP.
|
||||
|
||||
## Teil 3: HomeKit-Accessory-Klassen (HAP-Python)
|
||||
|
||||
### 3.1 Erstelle in `apps/homekit/accessories/base.py`:
|
||||
|
||||
**Basisklasse BaseDeviceAccessory(Accessory) mit:**
|
||||
- Referenz auf Device (aus DeviceRegistry)
|
||||
- Referenz auf ApiClient
|
||||
- Methoden zum:
|
||||
- Registrieren von HAP-Characteristics und Set-Handlern
|
||||
- Aktualisieren von Characteristics bei eingehenden Events
|
||||
- Logging
|
||||
|
||||
### 3.2 Erstelle spezifische Accessory-Klassen basierend auf homekit_mapping.md:
|
||||
|
||||
**LightAccessories (`apps/homekit/accessories/light.py`):**
|
||||
- On/Off (nur power)
|
||||
- Dimmable (power + brightness)
|
||||
- Color (power + brightness + color_hsb)
|
||||
|
||||
**ThermostatAccessory:**
|
||||
- CurrentTemperature, TargetTemperature, Mode (so weit in Mapping definiert)
|
||||
|
||||
**ContactAccessory:**
|
||||
- ContactSensorState (open/closed)
|
||||
|
||||
**TempHumidityAccessory:**
|
||||
- TemperatureSensor (CurrentTemperature)
|
||||
- HumiditySensor (CurrentRelativeHumidity)
|
||||
|
||||
**OutletAccessory:**
|
||||
- On/Off
|
||||
|
||||
**CoverAccessory (optional):**
|
||||
- WindowCovering mit CurrentPosition/TargetPosition
|
||||
|
||||
**Die Mapping-Tabelle in `homekit_mapping.md` ist die normative Referenz:**
|
||||
- Bitte lies die Tabelle und mappe `abstract_type + Features → passende Accessory-Klasse und Characteristics`
|
||||
- Wo die Tabelle `Status=TODO/REVIEW` hat:
|
||||
- Implementiere nur das, was eindeutig ist,
|
||||
- lasse TODO-Kommentare an den entsprechenden Stellen im Code.
|
||||
|
||||
**Akzeptanz:**
|
||||
- Für die abstrakten Typen, die bereits in `devices.yaml` und Mapping-Tabelle klar definiert sind (z.B. light, thermostat, contact, temp_humidity), existieren passende Accessory-Klassen.
|
||||
- Set-Operationen erzeugen korrekte Payloads für `POST /devices/{id}/set`:
|
||||
- Light: `{"type":"light","payload":{"power":"on"/"off", "brightness":..., "hue":..., "sat":...}}`
|
||||
- Thermostat: `{"type":"thermostat","payload":{"target":...}}`
|
||||
- Contact: read_only → keine Set-Handler
|
||||
- Temp/Humidity: read_only → keine Set-Handler
|
||||
|
||||
## Teil 4: Bridge-Setup mit HAP-Python
|
||||
|
||||
### 4.1 Implementiere in `apps/homekit/main.py`:
|
||||
|
||||
**env-Konfiguration:**
|
||||
- `HOMEKIT_NAME` (default: "Home Automation Bridge")
|
||||
- `HOMEKIT_PIN` (z.B. "031-45-154")
|
||||
- `HOMEKIT_PORT` (default 51826)
|
||||
- `API_BASE` (z.B. "http://api:8001" oder extern)
|
||||
- `HOMEKIT_API_TOKEN` (optional)
|
||||
|
||||
**Funktion `build_bridge(driver, api_client: ApiClient) -> Bridge`:**
|
||||
- DeviceRegistry via `load_from_api(api_client)` laden.
|
||||
- Für jedes Device anhand Mapping-Tabelle die passende Accessory-Klasse instanziieren.
|
||||
- Einen Bridge-Accessory (`pyhap.accessory.Bridge`) erstellen.
|
||||
- Alle Device-Accessories der Bridge hinzufügen.
|
||||
|
||||
**Realtime-Event-Loop:**
|
||||
- In einem Hintergrund-Thread oder ThreadPool:
|
||||
- `api_client.stream_realtime()` iterieren,
|
||||
- für jedes Event `device_id → Accessory` finden,
|
||||
- Characteristics updaten.
|
||||
- Thread wird beim Shutdown sauber beendet.
|
||||
|
||||
**`main()`:**
|
||||
- Logging einrichten.
|
||||
- ApiClient erstellen.
|
||||
- `AccessoryDriver(port=HOMEKIT_PORT, persist_file="homekit.state")` erstellen.
|
||||
- Bridge via `build_bridge(driver, api_client)` bauen.
|
||||
- Bridge dem Driver hinzufügen.
|
||||
- Realtime-Thread starten.
|
||||
- `driver.start()` aufrufen.
|
||||
- Auf KeyboardInterrupt reagieren und sauber stoppen.
|
||||
|
||||
**Akzeptanz:**
|
||||
- Beim Start loggt die Bridge:
|
||||
- Anzahl Devices,
|
||||
- auf welchem Port sie als HomeKit-Bridge lauscht,
|
||||
- welches API_BASE verwendet wird.
|
||||
- Die Datei `homekit.state` wird im Arbeitsverzeichnis bzw. einem konfigurierbaren Ordner abgelegt (um Pairing-Info persistent zu halten).
|
||||
- Die Bridge übersteht API-Neustarts (Realtime-Loop reconnectet) und Netzwerkflaps.
|
||||
|
||||
## Teil 5: Tests & Testanweisungen
|
||||
|
||||
### 5.1 Lege in `apps/homekit/tests/` einfache Tests/Checks an:
|
||||
|
||||
**Unit-Tests (pytest), soweit ohne echtes HomeKit möglich:**
|
||||
- Test für `DeviceRegistry.load_from_api()` mit Mock-Antworten aus `/devices` und `/layout`:
|
||||
- Korrekte Join-Logik,
|
||||
- Korrekte room/friendly_name-Zuordnung.
|
||||
- Test für set-Payload-Erzeugung pro Accessory:
|
||||
- z.B. LightAccessory: On=True → POST /devices/{id}/set wird mit korrektem Payload aufgerufen (über Mock ApiClient).
|
||||
|
||||
### Allgemein
|
||||
|
||||
- Nutze möglichst sinnvolle Typannotationen und Docstrings.
|
||||
- Hinterlasse TODO-Kommentare an Stellen, wo die Mapping-Tabelle explizit Status=TODO/REVIEW hat.
|
||||
- Ändere KEINE bestehenden API-Endpunkte; verlasse dich nur auf deren aktuelles Verhalten (GET /devices, GET /layout, /realtime, POST /devices/{id}/set).
|
||||
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
- Python 3.9+
|
||||
- Raspberry Pi im gleichen LAN wie iPhone/iPad
|
||||
- API-Server erreichbar (z.B. `http://api:8001`)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd apps/homekit
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Umgebungsvariablen
|
||||
|
||||
Erstelle eine `.env` Datei oder setze folgende Variablen:
|
||||
|
||||
```bash
|
||||
export API_BASE="http://YOUR_API_IP:8001"
|
||||
export HOMEKIT_API_TOKEN="your-token-if-needed" # optional
|
||||
export HOMEKIT_PIN="031-45-154"
|
||||
export HOMEKIT_NAME="Home Automation Bridge"
|
||||
export HOMEKIT_PORT="51826"
|
||||
export HOMEKIT_PERSIST_FILE="homekit.state"
|
||||
```
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
python -m apps.homekit.main
|
||||
```
|
||||
|
||||
**Erwartete Logs:**
|
||||
```
|
||||
Loading devices from API...
|
||||
Loaded X devices from API
|
||||
Bridge built with Y accessories
|
||||
HomeKit Bridge started on port 51826
|
||||
Starting realtime event loop...
|
||||
```
|
||||
|
||||
## Pairing mit iPhone (Home-App)
|
||||
|
||||
1. **Voraussetzungen:**
|
||||
- iPhone im gleichen WLAN wie Raspberry Pi
|
||||
- Bridge läuft und zeigt "started on port 51826"
|
||||
|
||||
2. **Home-App öffnen:**
|
||||
- Öffne die Home-App auf dem iPhone
|
||||
- Tippe auf "+" → "Gerät hinzufügen"
|
||||
- Wähle "Weitere Optionen..." oder "Code fehlt oder kann nicht gescannt werden"
|
||||
|
||||
3. **Bridge auswählen:**
|
||||
- Die Bridge sollte in der Nähe-Liste erscheinen (z.B. "Home Automation Bridge")
|
||||
- Tippe auf die Bridge
|
||||
|
||||
4. **PIN eingeben:**
|
||||
- Gib den PIN ein: `031-45-154` (oder dein `HOMEKIT_PIN`)
|
||||
- Format: `XXX-XX-XXX`
|
||||
|
||||
5. **Konfiguration abschließen:**
|
||||
- Geräte werden geladen
|
||||
- Räume werden automatisch aus `layout.yaml` übernommen
|
||||
- Geräte können nun über Home-App gesteuert werden
|
||||
|
||||
## Funktionstests
|
||||
|
||||
### Test 1: Licht einschalten
|
||||
|
||||
**Aktion:** Lampe in Home-App antippen (On)
|
||||
|
||||
**Erwartung:**
|
||||
- API-Log zeigt: `POST /devices/{id}/set` mit `{"type":"light","payload":{"power":"on"}}`
|
||||
- Physische Lampe oder Simulator schaltet ein
|
||||
|
||||
### Test 2: Helligkeit ändern (dimmbare Lampe)
|
||||
|
||||
**Aktion:** Helligkeits-Slider bewegen (z.B. 75%)
|
||||
|
||||
**Erwartung:**
|
||||
- `POST /devices/{id}/set` mit `brightness` ca. 75
|
||||
- Lampe dimmt entsprechend
|
||||
|
||||
### Test 3: Farbe ändern (Farblampe)
|
||||
|
||||
**Aktion:** Farbe in Home-App wählen
|
||||
|
||||
**Erwartung:**
|
||||
- `POST /devices/{id}/set` mit `hue`/`sat` Werten
|
||||
- Lampe wechselt Farbe
|
||||
|
||||
### Test 4: Thermostat Zieltemperatur
|
||||
|
||||
**Aktion:** Zieltemperatur auf 22°C setzen
|
||||
|
||||
**Erwartung:**
|
||||
- `POST /devices/{id}/set` mit `target=22`
|
||||
- `CurrentTemperature` wird über `/realtime` aktualisiert
|
||||
|
||||
### Test 5: Kontaktsensor (read-only)
|
||||
|
||||
**Aktion:** Fenster physisch öffnen/schließen
|
||||
|
||||
**Erwartung:**
|
||||
- `/realtime` sendet Event
|
||||
- Home-App zeigt "Offen" oder "Geschlossen"
|
||||
|
||||
### Test 6: Temperatur-/Feuchtigkeitssensor
|
||||
|
||||
**Aktion:** Werte ändern sich (z.B. Heizung an)
|
||||
|
||||
**Erwartung:**
|
||||
- `/realtime` Events aktualisieren Werte
|
||||
- Home-App zeigt aktuelle Temperatur/Luftfeuchtigkeit
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bridge erscheint nicht in Home-App
|
||||
|
||||
- **Netzwerk prüfen:** iPhone und RPi im gleichen Subnet?
|
||||
- **Firewall prüfen:** Port 51826 muss erreichbar sein
|
||||
- **Logs prüfen:** Fehler beim Start?
|
||||
- **mDNS/Bonjour:** Funktioniert Bonjour im Netzwerk?
|
||||
|
||||
### Geräte reagieren nicht
|
||||
|
||||
- **API-Logs prüfen:** Kommen POST-Requests an?
|
||||
- **Realtime-Verbindung:** Läuft `/realtime` Event-Loop? (Log-Meldungen)
|
||||
- **API-Endpoints testen:** Manuell mit `curl` testen
|
||||
|
||||
### Pairing schlägt fehl
|
||||
|
||||
- **State-Datei löschen:** `rm homekit.state` und Bridge neu starten
|
||||
- **PIN-Format prüfen:** Muss `XXX-XX-XXX` Format haben
|
||||
- **Alte Pairings löschen:** In Home-App unter "Home-Einstellungen" → "HomeKit-Geräte zurücksetzen"
|
||||
|
||||
### Realtime-Updates funktionieren nicht
|
||||
|
||||
- **SSE-Verbindung prüfen:** Logs zeigen "Starting realtime event loop..."?
|
||||
- **API-Endpoint testen:** `curl -N http://api:8001/realtime`
|
||||
- **Firewall/Proxy:** Blockiert etwas SSE-Streams?
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Dockerfile
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "-m", "apps.homekit.main"]
|
||||
```
|
||||
|
||||
### docker-compose.yml
|
||||
|
||||
```yaml
|
||||
services:
|
||||
homekit:
|
||||
build: ./apps/homekit
|
||||
environment:
|
||||
- API_BASE=http://api:8001
|
||||
- HOMEKIT_PIN=031-45-154
|
||||
- HOMEKIT_NAME=Home Automation Bridge
|
||||
ports:
|
||||
- "51826:51826"
|
||||
volumes:
|
||||
- ./data/homekit:/data
|
||||
network_mode: "host" # Wichtig für mDNS/Bonjour Discovery
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
**Wichtig:** `network_mode: "host"` ist erforderlich für mDNS/Bonjour Discovery, damit die Bridge im lokalen Netzwerk gefunden werden kann.
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ iPhone/ │
|
||||
│ Home-App │
|
||||
└──────┬──────┘
|
||||
│ HomeKit (HAP)
|
||||
│ Port 51826
|
||||
┌──────▼──────────────────┐
|
||||
│ HomeKit Bridge │
|
||||
│ (HAP-Python) │
|
||||
│ - Device Registry │
|
||||
│ - Accessory Mapping │
|
||||
│ - SSE Event Loop │
|
||||
└──────┬──────────────────┘
|
||||
│ HTTP REST API
|
||||
│ (GET /devices, POST /set, SSE /realtime)
|
||||
┌──────▼──────────────────┐
|
||||
│ API Server │
|
||||
│ (FastAPI) │
|
||||
└──────┬──────────────────┘
|
||||
│ MQTT
|
||||
┌──────▼──────────────────┐
|
||||
│ Abstraction Layer │
|
||||
│ (Zigbee2MQTT, MAX!) │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
## Weitere Dokumentation
|
||||
|
||||
- **API-Mapping:** Siehe `homekit_mapping.md` für Device-Type → HomeKit-Service Mapping
|
||||
- **API-Dokumentation:** Siehe API-Server README für Endpoint-Dokumentation
|
||||
- **HAP-Python Docs:** https://github.com/ikalchev/HAP-python
|
||||
|
||||
## Entwicklung
|
||||
|
||||
### Tests ausführen
|
||||
|
||||
```bash
|
||||
pytest apps/homekit/tests/
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
Die Bridge gibt detaillierte Logs aus:
|
||||
- `INFO`: Normale Betriebsmeldungen (Start, Device-Anzahl, etc.)
|
||||
- `DEBUG`: Detaillierte State-Updates und API-Calls
|
||||
- `ERROR`: Fehler bei API-Kommunikation oder Accessory-Updates
|
||||
|
||||
Log-Level über Environment-Variable steuern:
|
||||
```bash
|
||||
export LOG_LEVEL=DEBUG
|
||||
```
|
||||
|
||||
## Lizenz
|
||||
|
||||
Siehe Hauptprojekt-Lizenz.
|
||||
5
apps/homekit/accessories/__init__.py
Normal file
5
apps/homekit/accessories/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
HomeKit Accessories Package
|
||||
|
||||
This package contains HomeKit accessory implementations for different device types.
|
||||
"""
|
||||
48
apps/homekit/accessories/contact.py
Normal file
48
apps/homekit/accessories/contact.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Contact Sensor Accessory Implementation for HomeKit
|
||||
|
||||
Implements contact sensor (window/door sensors):
|
||||
- ContactSensorState (read-only): 0=Detected, 1=Not Detected
|
||||
"""
|
||||
|
||||
from pyhap.accessory import Accessory
|
||||
from pyhap.const import CATEGORY_SENSOR
|
||||
|
||||
|
||||
class ContactAccessory(Accessory):
|
||||
"""Contact sensor for doors and windows."""
|
||||
|
||||
category = CATEGORY_SENSOR
|
||||
|
||||
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
||||
"""
|
||||
Initialize the contact sensor accessory.
|
||||
|
||||
Args:
|
||||
driver: HAP driver instance
|
||||
device: Device object from DeviceRegistry
|
||||
api_client: ApiClient for sending commands
|
||||
display_name: Optional display name (defaults to device.friendly_name)
|
||||
"""
|
||||
name = display_name or device.friendly_name or device.name
|
||||
super().__init__(driver, name, *args, **kwargs)
|
||||
self.device = device
|
||||
self.api_client = api_client
|
||||
|
||||
# Add ContactSensor service
|
||||
self.contact_service = self.add_preload_service('ContactSensor')
|
||||
|
||||
# Get ContactSensorState characteristic
|
||||
self.contact_state_char = self.contact_service.get_characteristic('ContactSensorState')
|
||||
|
||||
# Initialize with "not detected" (closed)
|
||||
self.contact_state_char.set_value(1)
|
||||
|
||||
def update_state(self, state_payload):
|
||||
"""Update state from API event."""
|
||||
if "contact" in state_payload:
|
||||
# API sends: "open" or "closed"
|
||||
# HomeKit: 0=Contact Detected (closed), 1=Contact Not Detected (open)
|
||||
is_open = state_payload["contact"] == "open"
|
||||
homekit_state = 1 if is_open else 0
|
||||
self.contact_state_char.set_value(homekit_state)
|
||||
177
apps/homekit/accessories/light.py
Normal file
177
apps/homekit/accessories/light.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Light Accessory Implementations for HomeKit
|
||||
|
||||
Implements different light types:
|
||||
- OnOffLightAccessory: Simple on/off light
|
||||
- DimmableLightAccessory: Light with brightness control
|
||||
- ColorLightAccessory: RGB light with full color control
|
||||
"""
|
||||
|
||||
from pyhap.accessory import Accessory
|
||||
from pyhap.const import CATEGORY_LIGHTBULB
|
||||
|
||||
|
||||
class OnOffLightAccessory(Accessory):
|
||||
"""Simple On/Off Light without dimming or color."""
|
||||
|
||||
category = CATEGORY_LIGHTBULB
|
||||
|
||||
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
||||
"""
|
||||
Initialize the light accessory.
|
||||
|
||||
Args:
|
||||
driver: HAP driver instance
|
||||
device: Device object from DeviceRegistry
|
||||
api_client: ApiClient for sending commands
|
||||
display_name: Optional display name (defaults to device.friendly_name)
|
||||
"""
|
||||
name = display_name or device.friendly_name or device.name
|
||||
super().__init__(driver, name, *args, **kwargs)
|
||||
self.device = device
|
||||
self.api_client = api_client
|
||||
|
||||
# Add Lightbulb service with On characteristic
|
||||
self.lightbulb_service = self.add_preload_service('Lightbulb')
|
||||
|
||||
# Get the On characteristic and set callback
|
||||
self.on_char = self.lightbulb_service.get_characteristic('On')
|
||||
self.on_char.setter_callback = self.set_on
|
||||
|
||||
def set_on(self, value):
|
||||
"""Called when HomeKit wants to turn light on/off."""
|
||||
power_state = "on" if value else "off"
|
||||
payload = {
|
||||
"type": "light",
|
||||
"payload": {"power": power_state}
|
||||
}
|
||||
self.api_client.post_device_set(self.device.device_id, payload["type"], payload["payload"])
|
||||
|
||||
def update_state(self, state_payload):
|
||||
"""Update state from API event."""
|
||||
if "power" in state_payload:
|
||||
is_on = state_payload["power"] == "on"
|
||||
self.on_char.set_value(is_on)
|
||||
|
||||
|
||||
class DimmableLightAccessory(OnOffLightAccessory):
|
||||
"""Dimmable Light with brightness control."""
|
||||
|
||||
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
||||
# Don't call super().__init__() yet - we need to set up service first
|
||||
name = display_name or device.friendly_name or device.name
|
||||
Accessory.__init__(self, driver, name, *args, **kwargs)
|
||||
self.device = device
|
||||
self.api_client = api_client
|
||||
self.category = CATEGORY_LIGHTBULB
|
||||
|
||||
# Create Lightbulb service with all characteristics at once
|
||||
from pyhap.loader import Loader
|
||||
loader = Loader()
|
||||
|
||||
# Create the service
|
||||
lightbulb_service = loader.get_service('Lightbulb')
|
||||
|
||||
# Add On characteristic
|
||||
on_char = lightbulb_service.get_characteristic('On')
|
||||
on_char.setter_callback = self.set_on
|
||||
self.on_char = on_char
|
||||
|
||||
# Add Brightness characteristic
|
||||
brightness_char = loader.get_char('Brightness')
|
||||
brightness_char.set_value(0)
|
||||
brightness_char.setter_callback = self.set_brightness
|
||||
lightbulb_service.add_characteristic(brightness_char)
|
||||
self.brightness_char = brightness_char
|
||||
|
||||
# Now add the complete service to the accessory
|
||||
self.add_service(lightbulb_service)
|
||||
self.lightbulb_service = lightbulb_service
|
||||
|
||||
def set_brightness(self, value):
|
||||
"""Called when HomeKit wants to change brightness."""
|
||||
payload = {
|
||||
"type": "light",
|
||||
"payload": {"brightness": int(value)}
|
||||
}
|
||||
self.api_client.post_device_set(self.device.device_id, payload["type"], payload["payload"])
|
||||
|
||||
def update_state(self, state_payload):
|
||||
"""Update state from API event."""
|
||||
super().update_state(state_payload)
|
||||
if "brightness" in state_payload:
|
||||
self.brightness_char.set_value(state_payload["brightness"])
|
||||
|
||||
|
||||
class ColorLightAccessory(DimmableLightAccessory):
|
||||
"""RGB Light with full color control."""
|
||||
|
||||
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
||||
# Don't call super().__init__() - build everything from scratch
|
||||
name = display_name or device.friendly_name or device.name
|
||||
Accessory.__init__(self, driver, name, *args, **kwargs)
|
||||
self.device = device
|
||||
self.api_client = api_client
|
||||
self.category = CATEGORY_LIGHTBULB
|
||||
|
||||
# Create Lightbulb service with all characteristics at once
|
||||
from pyhap.loader import Loader
|
||||
loader = Loader()
|
||||
|
||||
# Create the service
|
||||
lightbulb_service = loader.get_service('Lightbulb')
|
||||
|
||||
# Add On characteristic
|
||||
on_char = lightbulb_service.get_characteristic('On')
|
||||
on_char.setter_callback = self.set_on
|
||||
self.on_char = on_char
|
||||
|
||||
# Add Brightness characteristic
|
||||
brightness_char = loader.get_char('Brightness')
|
||||
brightness_char.set_value(0)
|
||||
brightness_char.setter_callback = self.set_brightness
|
||||
lightbulb_service.add_characteristic(brightness_char)
|
||||
self.brightness_char = brightness_char
|
||||
|
||||
# Add Hue characteristic
|
||||
hue_char = loader.get_char('Hue')
|
||||
hue_char.set_value(0)
|
||||
hue_char.setter_callback = self.set_hue
|
||||
lightbulb_service.add_characteristic(hue_char)
|
||||
self.hue_char = hue_char
|
||||
|
||||
# Add Saturation characteristic
|
||||
saturation_char = loader.get_char('Saturation')
|
||||
saturation_char.set_value(0)
|
||||
saturation_char.setter_callback = self.set_saturation
|
||||
lightbulb_service.add_characteristic(saturation_char)
|
||||
self.saturation_char = saturation_char
|
||||
|
||||
# Now add the complete service to the accessory
|
||||
self.add_service(lightbulb_service)
|
||||
self.lightbulb_service = lightbulb_service
|
||||
|
||||
def set_hue(self, value):
|
||||
"""Called when HomeKit wants to change hue."""
|
||||
payload = {
|
||||
"type": "light",
|
||||
"payload": {"hue": int(value)}
|
||||
}
|
||||
self.api_client.post_device_set(self.device.device_id, payload["type"], payload["payload"])
|
||||
|
||||
def set_saturation(self, value):
|
||||
"""Called when HomeKit wants to change saturation."""
|
||||
payload = {
|
||||
"type": "light",
|
||||
"payload": {"sat": int(value)}
|
||||
}
|
||||
self.api_client.post_device_set(self.device.device_id, payload["type"], payload["payload"])
|
||||
|
||||
def update_state(self, state_payload):
|
||||
"""Update state from API event."""
|
||||
super().update_state(state_payload)
|
||||
if "hue" in state_payload:
|
||||
self.hue_char.set_value(state_payload["hue"])
|
||||
if "sat" in state_payload:
|
||||
self.saturation_char.set_value(state_payload["sat"])
|
||||
|
||||
57
apps/homekit/accessories/outlet.py
Normal file
57
apps/homekit/accessories/outlet.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Outlet/Relay Accessory Implementation for HomeKit
|
||||
|
||||
Implements simple relay/outlet (on/off switch):
|
||||
- On (read/write)
|
||||
- OutletInUse (always true)
|
||||
"""
|
||||
|
||||
from pyhap.accessory import Accessory
|
||||
from pyhap.const import CATEGORY_OUTLET
|
||||
|
||||
|
||||
class OutletAccessory(Accessory):
|
||||
"""Relay/Outlet for simple on/off control."""
|
||||
|
||||
category = CATEGORY_OUTLET
|
||||
|
||||
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
||||
"""
|
||||
Initialize the outlet accessory.
|
||||
|
||||
Args:
|
||||
driver: HAP driver instance
|
||||
device: Device object from DeviceRegistry
|
||||
api_client: ApiClient for sending commands
|
||||
display_name: Optional display name (defaults to device.friendly_name)
|
||||
"""
|
||||
name = display_name or device.friendly_name or device.name
|
||||
super().__init__(driver, name, *args, **kwargs)
|
||||
self.device = device
|
||||
self.api_client = api_client
|
||||
|
||||
# Add Outlet service
|
||||
self.outlet_service = self.add_preload_service('Outlet')
|
||||
|
||||
# Get On characteristic and set callback
|
||||
self.on_char = self.outlet_service.get_characteristic('On')
|
||||
self.on_char.setter_callback = self.set_on
|
||||
|
||||
# OutletInUse is always true (relay is always functional)
|
||||
self.in_use_char = self.outlet_service.get_characteristic('OutletInUse')
|
||||
self.in_use_char.set_value(True)
|
||||
|
||||
def set_on(self, value):
|
||||
"""Called when HomeKit wants to turn outlet on/off."""
|
||||
power_state = "on" if value else "off"
|
||||
payload = {
|
||||
"type": "relay",
|
||||
"payload": {"power": power_state}
|
||||
}
|
||||
self.api_client.post_device_set(self.device.device_id, payload["type"], payload["payload"])
|
||||
|
||||
def update_state(self, state_payload):
|
||||
"""Update state from API event."""
|
||||
if "power" in state_payload:
|
||||
is_on = state_payload["power"] == "on"
|
||||
self.on_char.set_value(is_on)
|
||||
46
apps/homekit/accessories/sensor.py
Normal file
46
apps/homekit/accessories/sensor.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Temperature & Humidity Sensor Accessory Implementation for HomeKit
|
||||
|
||||
Implements combined temperature and humidity sensor:
|
||||
- CurrentTemperature (read-only)
|
||||
- CurrentRelativeHumidity (read-only)
|
||||
"""
|
||||
|
||||
from pyhap.accessory import Accessory
|
||||
from pyhap.const import CATEGORY_SENSOR
|
||||
|
||||
|
||||
class TempHumidityAccessory(Accessory):
|
||||
"""Combined temperature and humidity sensor."""
|
||||
|
||||
category = CATEGORY_SENSOR
|
||||
|
||||
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
||||
"""
|
||||
Initialize the temp/humidity sensor accessory.
|
||||
|
||||
Args:
|
||||
driver: HAP driver instance
|
||||
device: Device object from DeviceRegistry
|
||||
api_client: ApiClient for sending commands
|
||||
display_name: Optional display name (defaults to device.friendly_name)
|
||||
"""
|
||||
name = display_name or device.friendly_name or device.name
|
||||
super().__init__(driver, name, *args, **kwargs)
|
||||
self.device = device
|
||||
self.api_client = api_client
|
||||
|
||||
# Add TemperatureSensor service
|
||||
self.temp_service = self.add_preload_service('TemperatureSensor')
|
||||
self.current_temp_char = self.temp_service.get_characteristic('CurrentTemperature')
|
||||
|
||||
# Add HumiditySensor service
|
||||
self.humidity_service = self.add_preload_service('HumiditySensor')
|
||||
self.current_humidity_char = self.humidity_service.get_characteristic('CurrentRelativeHumidity')
|
||||
|
||||
def update_state(self, state_payload):
|
||||
"""Update state from API event."""
|
||||
if "temperature" in state_payload:
|
||||
self.current_temp_char.set_value(float(state_payload["temperature"]))
|
||||
if "humidity" in state_payload:
|
||||
self.current_humidity_char.set_value(float(state_payload["humidity"]))
|
||||
72
apps/homekit/accessories/thermostat.py
Normal file
72
apps/homekit/accessories/thermostat.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
Thermostat Accessory Implementation for HomeKit
|
||||
|
||||
Implements thermostat control according to homekit_mapping.md:
|
||||
- CurrentTemperature (read-only)
|
||||
- TargetTemperature (read/write)
|
||||
- CurrentHeatingCoolingState (fixed: 1 = heating)
|
||||
- TargetHeatingCoolingState (fixed: 3 = auto)
|
||||
"""
|
||||
|
||||
from pyhap.accessory import Accessory
|
||||
from pyhap.const import CATEGORY_THERMOSTAT
|
||||
|
||||
|
||||
class ThermostatAccessory(Accessory):
|
||||
"""Thermostat with temperature control."""
|
||||
|
||||
category = CATEGORY_THERMOSTAT
|
||||
|
||||
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
||||
"""
|
||||
Initialize the thermostat accessory.
|
||||
|
||||
Args:
|
||||
driver: HAP driver instance
|
||||
device: Device object from DeviceRegistry
|
||||
api_client: ApiClient for sending commands
|
||||
display_name: Optional display name (defaults to device.friendly_name)
|
||||
"""
|
||||
name = display_name or device.friendly_name or device.name
|
||||
super().__init__(driver, name, *args, **kwargs)
|
||||
self.device = device
|
||||
self.api_client = api_client
|
||||
|
||||
# Add Thermostat service
|
||||
self.thermostat_service = self.add_preload_service('Thermostat')
|
||||
|
||||
# Get characteristics
|
||||
self.current_temp_char = self.thermostat_service.get_characteristic('CurrentTemperature')
|
||||
self.target_temp_char = self.thermostat_service.get_characteristic('TargetTemperature')
|
||||
self.current_heating_cooling_char = self.thermostat_service.get_characteristic('CurrentHeatingCoolingState')
|
||||
self.target_heating_cooling_char = self.thermostat_service.get_characteristic('TargetHeatingCoolingState')
|
||||
|
||||
# Set callback for target temperature
|
||||
self.target_temp_char.setter_callback = self.set_target_temperature
|
||||
|
||||
# Set fixed heating/cooling states (mode is always "auto")
|
||||
# CurrentHeatingCoolingState: 0=Off, 1=Heat, 2=Cool
|
||||
self.current_heating_cooling_char.set_value(1) # Always heating
|
||||
|
||||
# TargetHeatingCoolingState: 0=Off, 1=Heat, 2=Cool, 3=Auto
|
||||
self.target_heating_cooling_char.set_value(3) # Always auto
|
||||
|
||||
# Set temperature range (5-30°C as per UI)
|
||||
self.target_temp_char.properties['minValue'] = 5
|
||||
self.target_temp_char.properties['maxValue'] = 30
|
||||
self.target_temp_char.properties['minStep'] = 0.5
|
||||
|
||||
def set_target_temperature(self, value):
|
||||
"""Called when HomeKit wants to change target temperature."""
|
||||
payload = {
|
||||
"type": "thermostat",
|
||||
"payload": {"target": float(value)}
|
||||
}
|
||||
self.api_client.post_device_set(self.device.device_id, payload["type"], payload["payload"])
|
||||
|
||||
def update_state(self, state_payload):
|
||||
"""Update state from API event."""
|
||||
if "current" in state_payload:
|
||||
self.current_temp_char.set_value(float(state_payload["current"]))
|
||||
if "target" in state_payload:
|
||||
self.target_temp_char.set_value(float(state_payload["target"]))
|
||||
161
apps/homekit/api_client.py
Normal file
161
apps/homekit/api_client.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
API Client for HomeKit Bridge
|
||||
|
||||
Handles all HTTP communication with the REST API backend.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Iterator, Optional
|
||||
import httpx
|
||||
import json
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ApiClient:
|
||||
"""HTTP client for communicating with the home automation API."""
|
||||
|
||||
def __init__(self, base_url: str, token: Optional[str] = None, timeout: int = 5):
|
||||
"""
|
||||
Initialize API client.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of the API (e.g., "http://192.168.1.100:8001")
|
||||
token: Optional API token for authentication
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.timeout = timeout
|
||||
self.headers = {}
|
||||
|
||||
if token:
|
||||
self.headers['Authorization'] = f'Bearer {token}'
|
||||
|
||||
def get_devices(self) -> List[Dict]:
|
||||
"""
|
||||
Get list of all devices.
|
||||
|
||||
Returns:
|
||||
List of device dictionaries
|
||||
"""
|
||||
try:
|
||||
response = httpx.get(
|
||||
f'{self.base_url}/devices',
|
||||
headers=self.headers,
|
||||
timeout=self.timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get devices: {e}")
|
||||
raise
|
||||
|
||||
def get_layout(self) -> Dict:
|
||||
"""
|
||||
Get layout information (rooms and device assignments).
|
||||
|
||||
Returns:
|
||||
Layout dictionary with room structure
|
||||
"""
|
||||
try:
|
||||
response = httpx.get(
|
||||
f'{self.base_url}/layout',
|
||||
headers=self.headers,
|
||||
timeout=self.timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get layout: {e}")
|
||||
raise
|
||||
|
||||
def get_device_state(self, device_id: str) -> Dict:
|
||||
"""
|
||||
Get current state of a specific device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Returns:
|
||||
Device state dictionary
|
||||
"""
|
||||
try:
|
||||
response = httpx.get(
|
||||
f'{self.base_url}/devices/{device_id}/state',
|
||||
headers=self.headers,
|
||||
timeout=self.timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get state for {device_id}: {e}")
|
||||
raise
|
||||
|
||||
def post_device_set(self, device_id: str, device_type: str, payload: Dict) -> None:
|
||||
"""
|
||||
Send command to a device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
device_type: Device type (e.g., "light", "thermostat")
|
||||
payload: Command payload (e.g., {"power": "on", "brightness": 75})
|
||||
"""
|
||||
try:
|
||||
data = {
|
||||
"type": device_type,
|
||||
"payload": payload
|
||||
}
|
||||
response = httpx.post(
|
||||
f'{self.base_url}/devices/{device_id}/set',
|
||||
headers=self.headers,
|
||||
json=data,
|
||||
timeout=self.timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.debug(f"Set {device_id}: {payload}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set {device_id}: {e}")
|
||||
raise
|
||||
|
||||
def stream_realtime(self, reconnect_delay: int = 5) -> Iterator[Dict]:
|
||||
"""
|
||||
Stream real-time events from the API using Server-Sent Events (SSE).
|
||||
|
||||
Automatically reconnects on connection loss.
|
||||
|
||||
Args:
|
||||
reconnect_delay: Seconds to wait before reconnecting
|
||||
|
||||
Yields:
|
||||
Event dictionaries: {"type": "state", "device_id": "...", "payload": {...}, "ts": ...}
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
logger.info("Connecting to realtime event stream...")
|
||||
with httpx.stream(
|
||||
'GET',
|
||||
f'{self.base_url}/realtime',
|
||||
headers=self.headers,
|
||||
timeout=None # No timeout for streaming
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
logger.info("Connected to realtime event stream")
|
||||
|
||||
for line in response.iter_lines():
|
||||
if line.startswith('data: '):
|
||||
data_str = line[6:] # Remove 'data: ' prefix
|
||||
try:
|
||||
event = json.loads(data_str)
|
||||
yield event
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Failed to parse SSE event: {e}")
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Realtime stream error: {e}")
|
||||
logger.info(f"Reconnecting in {reconnect_delay} seconds...")
|
||||
time.sleep(reconnect_delay)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in realtime stream: {e}")
|
||||
logger.info(f"Reconnecting in {reconnect_delay} seconds...")
|
||||
time.sleep(reconnect_delay)
|
||||
138
apps/homekit/device_registry.py
Normal file
138
apps/homekit/device_registry.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Device Registry for HomeKit Bridge
|
||||
|
||||
Loads devices from API and joins with layout information.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Device:
|
||||
"""Represents a device with combined info from /devices and /layout."""
|
||||
|
||||
device_id: str
|
||||
type: str # "light", "thermostat", "relay", "contact", "temp_humidity", "cover"
|
||||
name: str # Short name from /devices
|
||||
friendly_name: str # Display title from /layout (fallback to name)
|
||||
room: Optional[str] # Room name from layout
|
||||
features: Dict[str, bool] # Feature flags (e.g., {"power": true, "brightness": true})
|
||||
read_only: bool # True for sensors that don't accept commands
|
||||
|
||||
|
||||
class DeviceRegistry:
|
||||
"""Registry of all devices loaded from the API."""
|
||||
|
||||
def __init__(self, devices: List[Device]):
|
||||
"""
|
||||
Initialize registry with devices.
|
||||
|
||||
Args:
|
||||
devices: List of Device objects
|
||||
"""
|
||||
self._devices = devices
|
||||
self._by_id = {d.device_id: d for d in devices}
|
||||
|
||||
@classmethod
|
||||
def load_from_api(cls, api_client) -> 'DeviceRegistry':
|
||||
"""
|
||||
Load devices from API and join with layout information.
|
||||
|
||||
Args:
|
||||
api_client: ApiClient instance
|
||||
|
||||
Returns:
|
||||
DeviceRegistry with all devices
|
||||
"""
|
||||
# Get devices and layout
|
||||
devices_data = api_client.get_devices()
|
||||
layout_data = api_client.get_layout()
|
||||
|
||||
# Build lookup: device_id -> (room_name, title)
|
||||
layout_map = {}
|
||||
if isinstance(layout_data, dict) and 'rooms' in layout_data:
|
||||
rooms_list = layout_data['rooms']
|
||||
if isinstance(rooms_list, list):
|
||||
for room in rooms_list:
|
||||
if isinstance(room, dict):
|
||||
room_name = room.get('name', 'Unknown')
|
||||
devices_in_room = room.get('devices', [])
|
||||
for device_info in devices_in_room:
|
||||
if isinstance(device_info, dict):
|
||||
device_id = device_info.get('device_id')
|
||||
title = device_info.get('title', '')
|
||||
if device_id:
|
||||
layout_map[device_id] = (room_name, title)
|
||||
|
||||
# Create Device objects
|
||||
devices = []
|
||||
for dev_data in devices_data:
|
||||
device_id = dev_data.get('device_id')
|
||||
if not device_id:
|
||||
logger.warning(f"Device without device_id: {dev_data}")
|
||||
continue
|
||||
|
||||
# Get layout info
|
||||
room_name, title = layout_map.get(device_id, (None, ''))
|
||||
|
||||
# Determine if read-only (sensors don't accept set commands)
|
||||
device_type = dev_data.get('type', '')
|
||||
read_only = device_type in ['contact', 'temp_humidity', 'motion', 'smoke']
|
||||
|
||||
device = Device(
|
||||
device_id=device_id,
|
||||
type=device_type,
|
||||
name=dev_data.get('name', device_id),
|
||||
friendly_name=title or dev_data.get('name', device_id),
|
||||
room=room_name,
|
||||
features=dev_data.get('features', {}),
|
||||
read_only=read_only
|
||||
)
|
||||
devices.append(device)
|
||||
|
||||
logger.info(f"Loaded {len(devices)} devices from API")
|
||||
return cls(devices)
|
||||
|
||||
def get_all(self) -> List[Device]:
|
||||
"""Get all devices."""
|
||||
return self._devices.copy()
|
||||
|
||||
def get_by_id(self, device_id: str) -> Optional[Device]:
|
||||
"""
|
||||
Get device by ID.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Returns:
|
||||
Device or None if not found
|
||||
"""
|
||||
return self._by_id.get(device_id)
|
||||
|
||||
def get_by_type(self, device_type: str) -> List[Device]:
|
||||
"""
|
||||
Get all devices of a specific type.
|
||||
|
||||
Args:
|
||||
device_type: Device type (e.g., "light", "thermostat")
|
||||
|
||||
Returns:
|
||||
List of matching devices
|
||||
"""
|
||||
return [d for d in self._devices if d.type == device_type]
|
||||
|
||||
def get_by_room(self, room: str) -> List[Device]:
|
||||
"""
|
||||
Get all devices in a specific room.
|
||||
|
||||
Args:
|
||||
room: Room name
|
||||
|
||||
Returns:
|
||||
List of devices in the room
|
||||
"""
|
||||
return [d for d in self._devices if d.room == room]
|
||||
111
apps/homekit/homekit_mapping.md
Normal file
111
apps/homekit/homekit_mapping.md
Normal file
@@ -0,0 +1,111 @@
|
||||
<!--
|
||||
Copilot-Aufgabe: HomeKit-Accessory-Mapping-Tabelle aus bestehender Implementierung ableiten
|
||||
|
||||
Ziel:
|
||||
Erstelle eine Mapping-Tabelle, die beschreibt, wie unsere abstrakten Gerätetypen
|
||||
(type/Features aus devices.yaml, apps/abstraction und apps/api) auf HomeKit-Accessories
|
||||
und Characteristics (HAP-Python) abgebildet werden sollen.
|
||||
|
||||
Die Tabelle soll:
|
||||
- sich an der bestehenden Implementierung und Konfiguration orientieren:
|
||||
- devices.yaml (Typen, Features)
|
||||
- apps/abstraction (Capabilities, Payload-Strukturen, Topics)
|
||||
- apps/api (DTOs, /devices-Response, /devices/{id}/set, /realtime-Events)
|
||||
- für jeden bekannten Gerätetyp (z.B. light, thermostat, outlet, contact, temp_humidity, cover, switch)
|
||||
festhalten:
|
||||
- Abstract Type (type)
|
||||
- Relevante Features (Features-Flags, z.B. power, brightness, color_hsb, target, current, contact, humidity)
|
||||
- HomeKit Service (z.B. LightBulb, Thermostat, Outlet, ContactSensor, TemperatureSensor, HumiditySensor, WindowCovering)
|
||||
- HomeKit Characteristics (z.B. On, Brightness, Hue, Saturation, CurrentTemperature, TargetTemperature, ContactSensorState, CurrentPosition, TargetPosition)
|
||||
- State-Mapping (abstraktes payload → HomeKit-Characteristics)
|
||||
- Set-Mapping (HomeKit-Characteristics → HTTP POST /devices/{id}/set Payload)
|
||||
- Status (OK / REVIEW / TODO)
|
||||
|
||||
Vorgehen für Copilot:
|
||||
1. Analysiere die bestehenden Typen und Features:
|
||||
- devices.yaml → "type" und "features" pro Gerät
|
||||
- apps/abstraction → Capabilities, State-/Set-Modelle (z.B. light@1.2.0, thermostat, contact, temp_humidity)
|
||||
- apps/api:
|
||||
- Response-Modell von GET /devices (device_id, type, name, features)
|
||||
- Struktur von GET /devices/{id}/state (payload-Felder je Typ)
|
||||
- Struktur der Events in /realtime (payload-Felder je Typ)
|
||||
- Body von POST /devices/{id}/set (type, payload)
|
||||
|
||||
2. Lege aufgrund dieser Analyse alle aktuell verwendeten abstrakten Typen an, z.B.:
|
||||
- light (mit power, brightness, color_hsb)
|
||||
- thermostat (mit current, target, mode)
|
||||
- outlet
|
||||
- contact
|
||||
- temp_humidity
|
||||
- ggf. cover, switch, etc., falls vorhanden
|
||||
|
||||
3. Erstelle eine Tabelle in Markdown mit folgenden Spalten:
|
||||
|
||||
| Abstract Type | Features (aus devices.yaml) | HomeKit Service | HomeKit Characteristics | State-Mapping (payload → HK) | Set-Mapping (HK → payload) | Status |
|
||||
|---------------|-----------------------------|-----------------|-------------------------|------------------------------|----------------------------|--------|
|
||||
|
||||
Beispiele für Inhalte:
|
||||
- Abstract Type: `light`
|
||||
- Features: `power, brightness, color_hsb`
|
||||
- HomeKit Service: `LightBulb`
|
||||
- HomeKit Characteristics: `On, Brightness, Hue, Saturation`
|
||||
- State-Mapping: `payload.power -> On; payload.brightness -> Brightness; payload.hue -> Hue; payload.sat -> Saturation`
|
||||
- Set-Mapping: `On -> {"type":"light","payload":{"power":"on"/"off"}} ...`
|
||||
- Status: `OK` wenn Mapping klar, `REVIEW` wenn du unsicher bist, `TODO` wenn Informationen fehlen
|
||||
|
||||
4. Nutze vorhandene Informationen aus dem Code:
|
||||
- Wenn ein Gerätetyp in apps/abstraction bereits ein klares State-/Set-Modell hat, übernimm das in State-/Set-Mapping.
|
||||
- Wenn bestimmte Features noch nicht im Code genutzt werden, markiere die entsprechenden Mapping-Zeilen mit Status=TODO.
|
||||
- Falls unklar ist, welcher HomeKit-Service für einen Typ am besten passt, mache einen Vorschlag und setze Status=REVIEW.
|
||||
|
||||
5. Markiere explizit, wo man später noch eingreifen muss:
|
||||
- In der Spalte "Status" mit `REVIEW` oder `TODO`.
|
||||
- Optional zusätzliche kurze Kommentare unter der Tabelle wie:
|
||||
- `<!-- TODO: RGBW-Unterstützung prüfen -->`
|
||||
- `<!-- REVIEW: Soll temp_humidity als zwei Accessories oder kombiniert abgebildet werden? -->`
|
||||
|
||||
Akzeptanz-Kriterien:
|
||||
- Es existiert am Ende dieses Dokuments eine Markdown-Tabelle mit mindestens allen abstrakten Gerätetypen, die aktuell in devices.yaml / apps/abstraction / apps/api verwendet werden.
|
||||
- Für jeden Typ sind HomeKit Service und Characteristics ausgefüllt oder mit Status=REVIEW/TODO markiert.
|
||||
- State-/Set-Mapping ist für alle bereits gut verstandenen Typen (z.B. einfache Lichter, Thermostate, Kontakte, Temp/Humidity-Sensoren) konkret beschrieben.
|
||||
- Stellen, an denen die aktuelle Implementierung keine klaren Informationen liefert, sind sichtbar mit Status=REVIEW oder TODO gekennzeichnet.
|
||||
- Copilot ändert an keiner anderen Stelle des Codes etwas, sondern erzeugt nur diese Tabelle/Dokumentation.
|
||||
-->
|
||||
|
||||
|
||||
# HomeKit-Accessory-Mapping-Tabelle
|
||||
|
||||
Dieses Dokument beschreibt das Mapping zwischen unseren abstrakten Gerätetypen (aus `devices.yaml`, `apps/abstraction` und `apps/api`) und HomeKit-Accessories/Characteristics (HAP-Python).
|
||||
|
||||
## Mapping-Tabelle
|
||||
|
||||
| Abstract Type | Features (aus devices.yaml) | HomeKit Service | HomeKit Characteristics | State-Mapping (payload → HK) | Set-Mapping (HK → payload) | Status |
|
||||
|---------------|-----------------------------|-----------------|-------------------------|------------------------------|----------------------------|--------|
|
||||
| `light` | `power, brightness, color_hsb` | `LightBulb` | `On, Brightness, Hue, Saturation` | `payload.power -> On (true/false)`<br>`payload.brightness -> Brightness (0-100)`<br>`payload.hue -> Hue (0-360)`<br>`payload.sat -> Saturation (0-100)` | `On -> {"type":"light","payload":{"power":"on"/"off"}}`<br>`Brightness -> {"type":"light","payload":{"brightness":0-100}}`<br>`Hue/Saturation -> {"type":"light","payload":{"hue":0-360,"sat":0-100}}` | OK |
|
||||
| `light` | `power, brightness` | `LightBulb` | `On, Brightness` | `payload.power -> On (true/false)`<br>`payload.brightness -> Brightness (0-100)` | `On -> {"type":"light","payload":{"power":"on"/"off"}}`<br>`Brightness -> {"type":"light","payload":{"brightness":0-100}}` | OK |
|
||||
| `light` | `power` | `LightBulb` | `On` | `payload.power -> On (true/false)` | `On -> {"type":"light","payload":{"power":"on"/"off"}}` | OK |
|
||||
| `thermostat` | `current, target` | `Thermostat` | `CurrentTemperature, TargetTemperature, CurrentHeatingCoolingState, TargetHeatingCoolingState` | `payload.current -> CurrentTemperature (°C)`<br>`payload.target -> TargetTemperature (°C)`<br>`CurrentHeatingCoolingState -> 1 (heat, fest)`<br>`TargetHeatingCoolingState -> 3 (auto, fest)` | `TargetTemperature -> {"type":"thermostat","payload":{"target":temp}}`<br>**Hinweis:** Mode ist immer "auto", keine Mode-Änderung über HomeKit | OK |
|
||||
| `relay` | `power` | `Outlet` | `On, OutletInUse` | `payload.power -> On (true/false)`<br>`payload.power -> OutletInUse (true/false)` | `On -> {"type":"relay","payload":{"power":"on"/"off"}}` | OK |
|
||||
| `contact` | `contact` | `ContactSensor` | `ContactSensorState` | `payload.contact -> ContactSensorState (0=detected, 1=not detected)` | N/A (read-only sensor) | OK |
|
||||
| `temp_humidity` | `temperature, humidity` | `TemperatureSensor` + `HumiditySensor` | `CurrentTemperature, CurrentRelativeHumidity` | `payload.temperature -> CurrentTemperature (°C)`<br>`payload.humidity -> CurrentRelativeHumidity (%)` | N/A (read-only sensors) | REVIEW |
|
||||
| `cover` | `position, tilt` | `WindowCovering` | `CurrentPosition, TargetPosition, CurrentHorizontalTiltAngle, TargetHorizontalTiltAngle` | `payload.position -> CurrentPosition (0-100)`<br>`payload.tilt -> CurrentHorizontalTiltAngle (-90 to 90)` | `TargetPosition -> {"type":"cover","payload":{"position":0-100}}`<br>`TargetHorizontalTiltAngle -> {"type":"cover","payload":{"tilt":-90 to 90}}` | TODO |
|
||||
| `switch` | `power` | `Switch` | `On` | `payload.power -> On (true/false)` | `On -> {"type":"switch","payload":{"power":"on"/"off"}}` | OK |
|
||||
|
||||
## Offene Punkte und Kommentare
|
||||
|
||||
<!-- OK: thermostat - Verwendet nur Mode "auto" (TargetHeatingCoolingState=3, CurrentHeatingCoolingState=1). Keine Mode-Änderung über HomeKit möglich. -->
|
||||
|
||||
<!-- REVIEW: temp_humidity - Soll als ein kombiniertes Accessory oder zwei separate Accessories (TemperatureSensor + HumiditySensor) abgebildet werden? HAP-Python erlaubt beides -->
|
||||
|
||||
<!-- TODO: cover - Position/Tilt-Mapping validieren. Sind die Wertebereiche korrekt? Gibt es zusätzliche Features wie PositionState (opening/closing/stopped)? -->
|
||||
|
||||
<!-- TODO: RGBW-Unterstützung - Prüfen, ob separate ColorTemperature-Characteristic für Warmweiß/Kaltweiß benötigt wird -->
|
||||
|
||||
<!-- TODO: Prüfen, ob weitere Gerätetypen in devices.yaml existieren (z.B. motion, door, lock, fan, etc.) und diese ergänzen -->
|
||||
|
||||
## Hinweise zur Implementierung
|
||||
|
||||
- **State-Updates**: Alle State-Änderungen von MQTT/API werden auf entsprechende HomeKit-Characteristics gemappt und via `set_value()` aktualisiert
|
||||
- **Set-Befehle**: HomeKit-Charakteristik-Änderungen werden in HTTP POST-Requests an `/devices/{id}/set` umgewandelt
|
||||
- **Read-only Sensors**: Sensoren (contact, temp_humidity) unterstützen nur State-Updates, keine Set-Befehle
|
||||
- **Bidirektionales Mapping**: Änderungen müssen in beide Richtungen synchronisiert werden (HomeKit ↔ Abstraction Layer)
|
||||
272
apps/homekit/main.py
Normal file
272
apps/homekit/main.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
HomeKit Bridge Main Module
|
||||
|
||||
Implementiert eine HAP-Python Bridge, die Geräte über die REST-API lädt
|
||||
und über HomeKit verfügbar macht.
|
||||
|
||||
Für detaillierte Implementierungsanweisungen, Tests und Deployment-Informationen
|
||||
siehe README.md in diesem Verzeichnis.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from pyhap.accessory_driver import AccessoryDriver
|
||||
from pyhap.accessory import Bridge
|
||||
|
||||
from .accessories.light import (
|
||||
OnOffLightAccessory,
|
||||
DimmableLightAccessory,
|
||||
ColorLightAccessory,
|
||||
)
|
||||
from .accessories.thermostat import ThermostatAccessory
|
||||
from .accessories.contact import ContactAccessory
|
||||
from .accessories.sensor import TempHumidityAccessory
|
||||
from .accessories.outlet import OutletAccessory
|
||||
from .api_client import ApiClient
|
||||
from .device_registry import DeviceRegistry
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Environment configuration
|
||||
HOMEKIT_NAME = os.getenv("HOMEKIT_NAME", "Home Automation Bridge")
|
||||
HOMEKIT_PIN = os.getenv("HOMEKIT_PIN", "031-45-154")
|
||||
HOMEKIT_PORT = int(os.getenv("HOMEKIT_PORT", "51826"))
|
||||
API_BASE = os.getenv("API_BASE", "http://api:8001")
|
||||
HOMEKIT_API_TOKEN = os.getenv("HOMEKIT_API_TOKEN")
|
||||
PERSIST_FILE = os.getenv("HOMEKIT_PERSIST_FILE", "homekit.state")
|
||||
|
||||
|
||||
def build_bridge(driver: AccessoryDriver, api_client: ApiClient) -> Bridge:
|
||||
"""
|
||||
Build the HomeKit Bridge with all device accessories.
|
||||
|
||||
Args:
|
||||
driver: HAP-Python AccessoryDriver
|
||||
api_client: API client for communication with backend
|
||||
|
||||
Returns:
|
||||
Bridge accessory with all device accessories attached
|
||||
"""
|
||||
logger.info("Loading devices from API...")
|
||||
registry = DeviceRegistry.load_from_api(api_client)
|
||||
devices = registry.get_all()
|
||||
logger.info(f"Loaded {len(devices)} devices from API")
|
||||
|
||||
# Create bridge
|
||||
bridge = Bridge(driver, HOMEKIT_NAME)
|
||||
accessory_map = {} # device_id -> Accessory instance
|
||||
|
||||
for device in devices:
|
||||
try:
|
||||
accessory = create_accessory_for_device(device, api_client, driver)
|
||||
if accessory:
|
||||
# Set room information in the accessory (HomeKit will use this for suggestions)
|
||||
if device.room:
|
||||
# Store room info for potential future use
|
||||
accessory._room_name = device.room
|
||||
|
||||
bridge.add_accessory(accessory)
|
||||
accessory_map[device.device_id] = accessory
|
||||
logger.info(f"Added accessory: {device.friendly_name} ({device.type}) in room: {device.room or 'Unknown'}")
|
||||
else:
|
||||
logger.warning(f"No accessory mapping for device: {device.name} ({device.type})")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create accessory for {device.name}: {e}", exc_info=True)
|
||||
|
||||
# Store accessory_map on bridge for realtime updates
|
||||
bridge._accessory_map = accessory_map
|
||||
|
||||
logger.info(f"Bridge built with {len(accessory_map)} accessories")
|
||||
return bridge
|
||||
|
||||
|
||||
def get_accessory_name(device) -> str:
|
||||
"""
|
||||
Build accessory name including room information.
|
||||
|
||||
Args:
|
||||
device: Device object from DeviceRegistry
|
||||
|
||||
Returns:
|
||||
Name string like "Device Name (Room)" or just "Device Name" if no room
|
||||
"""
|
||||
base_name = device.friendly_name or device.name
|
||||
if device.room:
|
||||
return f"{base_name} ({device.room})"
|
||||
return base_name
|
||||
|
||||
|
||||
def create_accessory_for_device(device, api_client: ApiClient, driver: AccessoryDriver):
|
||||
"""
|
||||
Create appropriate HomeKit accessory based on device type and features.
|
||||
|
||||
Maps device types to HomeKit accessories according to homekit_mapping.md.
|
||||
"""
|
||||
device_type = device.type
|
||||
features = device.features
|
||||
display_name = get_accessory_name(device)
|
||||
|
||||
# Light accessories
|
||||
if device_type == "light":
|
||||
if features.get("color_hsb"):
|
||||
return ColorLightAccessory(driver, device, api_client, display_name=display_name)
|
||||
elif features.get("brightness"):
|
||||
return DimmableLightAccessory(driver, device, api_client, display_name=display_name)
|
||||
else:
|
||||
return OnOffLightAccessory(driver, device, api_client, display_name=display_name)
|
||||
|
||||
# Thermostat
|
||||
elif device_type == "thermostat":
|
||||
return ThermostatAccessory(driver, device, api_client, display_name=display_name)
|
||||
|
||||
# Contact sensor
|
||||
elif device_type == "contact":
|
||||
return ContactAccessory(driver, device, api_client, display_name=display_name)
|
||||
|
||||
# Temperature/Humidity sensor
|
||||
elif device_type == "temp_humidity_sensor":
|
||||
return TempHumidityAccessory(driver, device, api_client, display_name=display_name)
|
||||
|
||||
# Relay/Outlet
|
||||
elif device_type == "relay":
|
||||
return OutletAccessory(driver, device, api_client, display_name=display_name)
|
||||
|
||||
# Cover/Blinds (optional)
|
||||
elif device_type == "cover":
|
||||
# TODO: Implement CoverAccessory based on homekit_mapping.md
|
||||
logger.warning(f"Cover accessory not yet implemented for {device.name}")
|
||||
return None
|
||||
|
||||
# TODO: Add more device types as needed (lock, motion, etc.)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def realtime_event_loop(api_client: ApiClient, bridge: Bridge, stop_event: threading.Event):
|
||||
"""
|
||||
Background thread that listens to realtime events and updates accessories.
|
||||
|
||||
Args:
|
||||
api_client: API client
|
||||
bridge: HomeKit bridge with accessories
|
||||
stop_event: Threading event to signal shutdown
|
||||
"""
|
||||
logger.info("Starting realtime event loop...")
|
||||
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
for event in api_client.stream_realtime():
|
||||
if stop_event.is_set():
|
||||
break
|
||||
|
||||
# Handle state update events
|
||||
if event.get("type") == "state":
|
||||
device_id = event.get("device_id")
|
||||
payload = event.get("payload", {})
|
||||
|
||||
# Find corresponding accessory
|
||||
accessory = bridge._accessory_map.get(device_id)
|
||||
if accessory and hasattr(accessory, 'update_state'):
|
||||
try:
|
||||
accessory.update_state(payload)
|
||||
logger.debug(f"Updated state for {device_id}: {payload}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating accessory {device_id}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
if not stop_event.is_set():
|
||||
logger.error(f"Realtime stream error: {e}. Reconnecting in 5s...")
|
||||
stop_event.wait(5) # Backoff before reconnect
|
||||
|
||||
logger.info("Realtime event loop stopped")
|
||||
|
||||
|
||||
def main():
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"Starting HomeKit Bridge: {HOMEKIT_NAME}")
|
||||
logger.info(f"API Base: {API_BASE}")
|
||||
logger.info(f"HomeKit Port: {HOMEKIT_PORT}")
|
||||
logger.info(f"PIN: {HOMEKIT_PIN}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Create API client
|
||||
api_client = ApiClient(
|
||||
base_url=API_BASE,
|
||||
token=HOMEKIT_API_TOKEN,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
# Test API connectivity
|
||||
try:
|
||||
devices = api_client.get_devices()
|
||||
logger.info(f"API connectivity OK - {len(devices)} devices available")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to API at {API_BASE}: {e}")
|
||||
logger.error("Please check API_BASE and network connectivity")
|
||||
sys.exit(1)
|
||||
|
||||
# Create AccessoryDriver
|
||||
driver = AccessoryDriver(
|
||||
port=HOMEKIT_PORT,
|
||||
persist_file=PERSIST_FILE
|
||||
)
|
||||
|
||||
# Build bridge with all accessories
|
||||
try:
|
||||
bridge = build_bridge(driver, api_client)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to build bridge: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Add bridge to driver
|
||||
driver.add_accessory(accessory=bridge)
|
||||
|
||||
# Setup realtime event thread
|
||||
stop_event = threading.Event()
|
||||
realtime_thread = threading.Thread(
|
||||
target=realtime_event_loop,
|
||||
args=(api_client, bridge, stop_event),
|
||||
daemon=True
|
||||
)
|
||||
|
||||
# Signal handlers for graceful shutdown
|
||||
def signal_handler(sig, frame):
|
||||
logger.info("Received shutdown signal, stopping...")
|
||||
stop_event.set()
|
||||
driver.stop()
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
# Start realtime thread
|
||||
realtime_thread.start()
|
||||
|
||||
# Start the bridge
|
||||
logger.info(f"HomeKit Bridge started on port {HOMEKIT_PORT}")
|
||||
logger.info(f"Pair with PIN: {HOMEKIT_PIN}")
|
||||
logger.info("Press Ctrl+C to stop")
|
||||
|
||||
try:
|
||||
driver.start()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("KeyboardInterrupt received")
|
||||
finally:
|
||||
logger.info("Stopping bridge...")
|
||||
stop_event.set()
|
||||
realtime_thread.join(timeout=5)
|
||||
logger.info("Bridge stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
13
apps/homekit/requirements.txt
Normal file
13
apps/homekit/requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
# HomeKit Bridge Dependencies
|
||||
# HAP-Python - HomeKit Accessory Protocol implementation
|
||||
HAP-python>=4.9.0
|
||||
|
||||
# HTTP client for API communication (REST API only - no MQTT)
|
||||
httpx>=0.24.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.0.0 # For .env file support
|
||||
pydantic>=2.0.0 # For data validation (shared with main app)
|
||||
|
||||
# Logging and monitoring (optional)
|
||||
# sentry-sdk>=1.0.0 # Optional: Error tracking
|
||||
76
apps/homekit/start_bridge.sh
Executable file
76
apps/homekit/start_bridge.sh
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
|
||||
# HomeKit Bridge Startup Script
|
||||
# This script sets up the virtual environment, installs dependencies, and starts the bridge
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# Determine script directory (apps/homekit)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Navigate to workspace root (two levels up from apps/homekit)
|
||||
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
cd "$WORKSPACE_ROOT"
|
||||
|
||||
echo "🏠 HomeKit Bridge Startup"
|
||||
echo "========================="
|
||||
echo " Working dir: $WORKSPACE_ROOT"
|
||||
echo ""
|
||||
|
||||
# Virtual environment path
|
||||
VENV_DIR="$SCRIPT_DIR/venv"
|
||||
|
||||
# Check if virtual environment exists
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "📦 Virtual environment not found. Creating..."
|
||||
# Try to use Python 3.12 or 3.13 (3.14 has compatibility issues with HAP-Python)
|
||||
if command -v python3.13 &> /dev/null; then
|
||||
PYTHON_CMD=python3.13
|
||||
elif command -v python3.12 &> /dev/null; then
|
||||
PYTHON_CMD=python3.12
|
||||
elif command -v python3.11 &> /dev/null; then
|
||||
PYTHON_CMD=python3.11
|
||||
else
|
||||
PYTHON_CMD=python3
|
||||
echo "⚠️ Warning: Using default python3. HAP-Python may not work with Python 3.14+"
|
||||
fi
|
||||
echo " Using: $PYTHON_CMD"
|
||||
$PYTHON_CMD -m venv "$VENV_DIR"
|
||||
echo "✅ Virtual environment created at $VENV_DIR"
|
||||
fi
|
||||
|
||||
# Activate virtual environment
|
||||
echo "🔧 Activating virtual environment..."
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
# Install/update dependencies
|
||||
echo "📥 Installing dependencies from requirements.txt..."
|
||||
pip install --upgrade pip -q
|
||||
pip install -r "$SCRIPT_DIR/requirements.txt" -q
|
||||
echo "✅ Dependencies installed"
|
||||
|
||||
# Set environment variables (with defaults)
|
||||
export HOMEKIT_NAME="${HOMEKIT_NAME:-Home Automation Bridge}"
|
||||
export HOMEKIT_PIN="${HOMEKIT_PIN:-031-45-154}"
|
||||
export HOMEKIT_PORT="${HOMEKIT_PORT:-51826}"
|
||||
export API_BASE="${API_BASE:-http://172.19.1.11:8001}"
|
||||
export HOMEKIT_API_TOKEN="${HOMEKIT_API_TOKEN:-}"
|
||||
export HOMEKIT_PERSIST_FILE="${HOMEKIT_PERSIST_FILE:-$SCRIPT_DIR/homekit.state}"
|
||||
|
||||
# Display configuration
|
||||
echo ""
|
||||
echo "⚙️ Configuration:"
|
||||
echo " Bridge Name: $HOMEKIT_NAME"
|
||||
echo " Bridge PIN: $HOMEKIT_PIN"
|
||||
echo " Bridge Port: $HOMEKIT_PORT"
|
||||
echo " API Base URL: $API_BASE"
|
||||
echo " Persist File: $HOMEKIT_PERSIST_FILE"
|
||||
echo ""
|
||||
|
||||
# Start the bridge
|
||||
echo "🚀 Starting HomeKit Bridge..."
|
||||
echo " (Press Ctrl+C to stop)"
|
||||
echo ""
|
||||
|
||||
# Run the bridge from workspace root with correct module path
|
||||
python -m apps.homekit.main
|
||||
53
apps/rules/Dockerfile
Normal file
53
apps/rules/Dockerfile
Normal file
@@ -0,0 +1,53 @@
|
||||
# Rules Engine Dockerfile
|
||||
# Event-driven automation rules processor with MQTT and Redis
|
||||
|
||||
FROM python:3.14-alpine
|
||||
|
||||
# Prevent Python from writing .pyc files and enable unbuffered output
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
RULES_CONFIG=config/rules.yaml \
|
||||
MQTT_BROKER=172.16.2.16 \
|
||||
MQTT_PORT=1883 \
|
||||
REDIS_HOST=localhost \
|
||||
REDIS_PORT=6379 \
|
||||
REDIS_DB=8 \
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 10001 -S app && \
|
||||
adduser -u 10001 -S app -G app
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
gcc \
|
||||
musl-dev \
|
||||
linux-headers
|
||||
|
||||
# Install Python dependencies
|
||||
COPY apps/rules/requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY apps/__init__.py /app/apps/
|
||||
COPY apps/rules/ /app/apps/rules/
|
||||
COPY packages/ /app/packages/
|
||||
COPY config/ /app/config/
|
||||
|
||||
# Change ownership to non-root user
|
||||
RUN chown -R app:app /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER app
|
||||
|
||||
# Expose no ports (MQTT/Redis client only)
|
||||
|
||||
# Health check (check if process is running)
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD pgrep -f "apps.rules.main" || exit 1
|
||||
|
||||
# Run the rules engine
|
||||
CMD ["python", "-m", "apps.rules.main"]
|
||||
15
apps/rules/impl/__init__.py
Normal file
15
apps/rules/impl/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Rule Implementations Package
|
||||
|
||||
This package contains all rule implementation modules.
|
||||
|
||||
Naming Convention:
|
||||
- Module name: snake_case matching the rule type name
|
||||
Example: window_setback.py for type 'window_setback@1.0'
|
||||
|
||||
- Class name: PascalCase + 'Rule' suffix
|
||||
Example: WindowSetbackRule
|
||||
|
||||
The rule engine uses load_rule() from rule_interface to dynamically
|
||||
import modules from this package based on the 'type' field in rules.yaml.
|
||||
"""
|
||||
256
apps/rules/impl/window_setback.py
Normal file
256
apps/rules/impl/window_setback.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
Example Rule Implementation: Window Setback
|
||||
|
||||
Demonstrates how to implement a Rule using the rule_interface.
|
||||
This rule lowers thermostat temperature when a window is opened.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from apps.rules.rule_interface import Rule, RuleDescriptor, RuleContext
|
||||
|
||||
|
||||
class WindowSetbackObjects(BaseModel):
|
||||
"""Object structure for window setback rule"""
|
||||
contacts: list[str] = Field(..., min_length=1, description="Contact sensors to monitor")
|
||||
thermostats: list[str] = Field(..., min_length=1, description="Thermostats to control")
|
||||
|
||||
|
||||
class WindowSetbackRule(Rule):
|
||||
"""
|
||||
Window setback automation rule.
|
||||
|
||||
When a window/door contact opens, set thermostats to eco temperature.
|
||||
When closed for a minimum duration, restore previous target temperature.
|
||||
|
||||
Configuration:
|
||||
objects:
|
||||
contacts: List of contact sensor device IDs to monitor (required, min 1)
|
||||
thermostats: List of thermostat device IDs to control (required, min 1)
|
||||
params:
|
||||
eco_target: Temperature to set when window opens (default: 16.0)
|
||||
open_min_secs: Minimum seconds window must be open before triggering (default: 20)
|
||||
close_min_secs: Minimum seconds window must be closed before restoring (default: 20)
|
||||
previous_target_ttl_secs: How long to remember previous temperature (default: 86400)
|
||||
|
||||
State storage (Redis keys):
|
||||
rule:{rule_id}:contact:{device_id}:state -> "open" | "closed"
|
||||
rule:{rule_id}:contact:{device_id}:ts -> ISO timestamp of last change
|
||||
rule:{rule_id}:thermo:{device_id}:current_target -> Current target temp (updated on every STATE)
|
||||
rule:{rule_id}:thermo:{device_id}:previous -> Previous target temp (saved on window open, deleted on restore)
|
||||
|
||||
Logic:
|
||||
1. Thermostat STATE events → update current_target in Redis
|
||||
2. Window opens → copy current_target to previous, then set to eco_target
|
||||
3. Window closes → restore from previous, then delete previous key
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._validated_objects: dict[str, WindowSetbackObjects] = {}
|
||||
|
||||
async def setup(self, desc: RuleDescriptor, ctx: RuleContext) -> None:
|
||||
"""Validate objects structure during setup"""
|
||||
try:
|
||||
validated = WindowSetbackObjects(**desc.objects)
|
||||
self._validated_objects[desc.id] = validated
|
||||
ctx.logger.info(
|
||||
f"Rule {desc.id} validated: {len(validated.contacts)} contacts, "
|
||||
f"{len(validated.thermostats)} thermostats"
|
||||
)
|
||||
except ValidationError as e:
|
||||
raise ValueError(
|
||||
f"Invalid objects configuration for rule {desc.id}: {e}"
|
||||
) from e
|
||||
|
||||
def get_subscriptions(self, desc: RuleDescriptor) -> list[str]:
|
||||
"""
|
||||
Return MQTT topics to subscribe to.
|
||||
|
||||
Subscribe to:
|
||||
- Contact sensor state changes (to detect window open/close)
|
||||
- Thermostat state changes (to track current target temperature)
|
||||
"""
|
||||
topics = []
|
||||
|
||||
# Subscribe to contact sensors
|
||||
contacts = desc.objects.get('contacts', [])
|
||||
for contact_id in contacts:
|
||||
topics.append(f"home/contact/{contact_id}/state")
|
||||
|
||||
# Subscribe to thermostats to track their current target temperature
|
||||
thermostats = desc.objects.get('thermostats', [])
|
||||
for thermo_id in thermostats:
|
||||
topics.append(f"home/thermostat/{thermo_id}/state")
|
||||
|
||||
return topics
|
||||
|
||||
async def on_event(
|
||||
self,
|
||||
evt: dict[str, Any],
|
||||
desc: RuleDescriptor,
|
||||
ctx: RuleContext
|
||||
) -> None:
|
||||
"""
|
||||
Process contact sensor or thermostat state changes.
|
||||
|
||||
Logic:
|
||||
1. If contact opened → remember current thermostat targets, set to eco
|
||||
2. If contact closed for min_secs → restore previous targets
|
||||
3. If thermostat target changed → update stored previous value
|
||||
"""
|
||||
device_id = evt['device_id']
|
||||
cap = evt['cap']
|
||||
payload = evt['payload']
|
||||
|
||||
# Only process events for devices in our objects
|
||||
target_contacts = desc.objects.get('contacts', [])
|
||||
target_thermostats = desc.objects.get('thermostats', [])
|
||||
|
||||
if cap == 'contact' and device_id in target_contacts:
|
||||
await self._handle_contact_event(evt, desc, ctx)
|
||||
|
||||
elif cap == 'thermostat' and device_id in target_thermostats:
|
||||
await self._handle_thermostat_event(evt, desc, ctx)
|
||||
|
||||
async def _handle_contact_event(
|
||||
self,
|
||||
evt: dict[str, Any],
|
||||
desc: RuleDescriptor,
|
||||
ctx: RuleContext
|
||||
) -> None:
|
||||
"""Handle contact sensor state change."""
|
||||
device_id = evt['device_id']
|
||||
contact_state = evt['payload'].get('contact') # "open" or "closed"
|
||||
event_ts = evt.get('ts', ctx.now().isoformat())
|
||||
|
||||
if not contact_state:
|
||||
ctx.logger.warning(f"Contact event missing 'contact' field: {evt}")
|
||||
return
|
||||
|
||||
# Store current state and timestamp
|
||||
state_key = f"rule:{desc.id}:contact:{device_id}:state"
|
||||
ts_key = f"rule:{desc.id}:contact:{device_id}:ts"
|
||||
|
||||
await ctx.redis.set(state_key, contact_state)
|
||||
await ctx.redis.set(ts_key, event_ts)
|
||||
|
||||
if contact_state == 'open':
|
||||
await self._on_window_opened(desc, ctx)
|
||||
elif contact_state == 'closed':
|
||||
await self._on_window_closed(desc, ctx)
|
||||
|
||||
async def _on_window_opened(self, desc: RuleDescriptor, ctx: RuleContext) -> None:
|
||||
"""
|
||||
Window opened - save current temperatures, then set thermostats to eco.
|
||||
|
||||
Important: We must save the current target BEFORE setting to eco,
|
||||
otherwise we'll save the eco temperature instead of the original.
|
||||
"""
|
||||
eco_target = desc.params.get('eco_target', 16.0)
|
||||
target_thermostats = desc.objects.get('thermostats', [])
|
||||
ttl_secs = desc.params.get('previous_target_ttl_secs', 86400)
|
||||
|
||||
ctx.logger.info(
|
||||
f"Rule {desc.id}: Window opened, setting {len(target_thermostats)} "
|
||||
f"thermostats to eco temperature {eco_target}°C"
|
||||
)
|
||||
|
||||
# FIRST: Save current target temperatures as "previous" (before we change them!)
|
||||
for thermo_id in target_thermostats:
|
||||
current_key = f"rule:{desc.id}:thermo:{thermo_id}:current_target"
|
||||
current_temp_str = await ctx.redis.get(current_key)
|
||||
|
||||
if current_temp_str:
|
||||
# Save current as previous (with TTL)
|
||||
prev_key = f"rule:{desc.id}:thermo:{thermo_id}:previous"
|
||||
await ctx.redis.set(prev_key, current_temp_str, ttl_secs=ttl_secs)
|
||||
ctx.logger.debug(
|
||||
f"Saved previous target for {thermo_id}: {current_temp_str}°C"
|
||||
)
|
||||
else:
|
||||
ctx.logger.warning(
|
||||
f"No current target found for {thermo_id}, cannot save previous"
|
||||
)
|
||||
|
||||
# THEN: Set all thermostats to eco temperature
|
||||
for thermo_id in target_thermostats:
|
||||
try:
|
||||
await ctx.mqtt.publish_set_thermostat(thermo_id, eco_target)
|
||||
ctx.logger.debug(f"Set {thermo_id} to {eco_target}°C")
|
||||
except Exception as e:
|
||||
ctx.logger.error(f"Failed to set {thermo_id}: {e}")
|
||||
|
||||
async def _on_window_closed(self, desc: RuleDescriptor, ctx: RuleContext) -> None:
|
||||
"""
|
||||
Window closed - restore previous temperatures.
|
||||
|
||||
Note: This is simplified. A production implementation would check
|
||||
close_min_secs and use a timer/scheduler.
|
||||
"""
|
||||
target_thermostats = desc.objects.get('thermostats', [])
|
||||
|
||||
ctx.logger.info(
|
||||
f"Rule {desc.id}: Window closed, restoring {len(target_thermostats)} "
|
||||
f"thermostats to previous temperatures"
|
||||
)
|
||||
|
||||
# Restore previous temperatures
|
||||
for thermo_id in target_thermostats:
|
||||
prev_key = f"rule:{desc.id}:thermo:{thermo_id}:previous"
|
||||
prev_temp_str = await ctx.redis.get(prev_key)
|
||||
|
||||
if prev_temp_str:
|
||||
try:
|
||||
prev_temp = float(prev_temp_str)
|
||||
await ctx.mqtt.publish_set_thermostat(thermo_id, prev_temp)
|
||||
ctx.logger.debug(f"Restored {thermo_id} to {prev_temp}°C")
|
||||
|
||||
# Delete the previous key after restoring
|
||||
await ctx.redis.delete(prev_key)
|
||||
except Exception as e:
|
||||
ctx.logger.error(f"Failed to restore {thermo_id}: {e}")
|
||||
else:
|
||||
ctx.logger.warning(
|
||||
f"No previous target found for {thermo_id}, cannot restore"
|
||||
)
|
||||
|
||||
async def _handle_thermostat_event(
|
||||
self,
|
||||
evt: dict[str, Any],
|
||||
desc: RuleDescriptor,
|
||||
ctx: RuleContext
|
||||
) -> None:
|
||||
"""
|
||||
Handle thermostat state change - track current target temperature.
|
||||
|
||||
This keeps a record of the thermostat's current target, so we can
|
||||
save it as "previous" when a window opens.
|
||||
|
||||
Important: We store in "current_target", NOT "previous". The "previous"
|
||||
key is only written when a window opens, to avoid race conditions.
|
||||
"""
|
||||
device_id = evt['device_id']
|
||||
payload = evt['payload']
|
||||
current_target = payload.get('target')
|
||||
|
||||
if current_target is None:
|
||||
return # No target in this state update
|
||||
|
||||
# Store current target (always update, even if it's the eco temperature)
|
||||
current_key = f"rule:{desc.id}:thermo:{device_id}:current_target"
|
||||
ttl_secs = desc.params.get('previous_target_ttl_secs', 86400)
|
||||
|
||||
await ctx.redis.set(current_key, str(current_target), ttl_secs=ttl_secs)
|
||||
|
||||
ctx.logger.debug(
|
||||
f"Rule {desc.id}: Updated current target for {device_id}: {current_target}°C"
|
||||
)
|
||||
|
||||
|
||||
# Rule registry - maps rule type to implementation class
|
||||
RULE_IMPLEMENTATIONS = {
|
||||
'window_setback@1.0': WindowSetbackRule,
|
||||
}
|
||||
@@ -1,83 +1,374 @@
|
||||
"""Rules main entry point."""
|
||||
"""
|
||||
Rules Engine
|
||||
|
||||
Loads rules configuration, subscribes to MQTT events, and dispatches events
|
||||
to registered rule implementations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from typing import NoReturn
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apps.rules.rules_config import load_rules_config
|
||||
from apps.rules.rule_interface import (
|
||||
RuleDescriptor,
|
||||
RuleContext,
|
||||
MQTTClient,
|
||||
RedisState,
|
||||
load_rule
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
level=logging.DEBUG,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global scheduler instance
|
||||
scheduler: BackgroundScheduler | None = None
|
||||
|
||||
|
||||
def rule_tick() -> None:
|
||||
"""Example job that runs every minute.
|
||||
|
||||
This is a placeholder for actual rule evaluation logic.
|
||||
class RuleEngine:
|
||||
"""
|
||||
logger.info("Rule tick")
|
||||
|
||||
|
||||
def shutdown_handler(signum: int, frame: object) -> NoReturn:
|
||||
"""Handle shutdown signals gracefully.
|
||||
|
||||
Args:
|
||||
signum: Signal number
|
||||
frame: Current stack frame
|
||||
Rule engine that loads rules, subscribes to MQTT events,
|
||||
and dispatches them to registered rule implementations.
|
||||
"""
|
||||
logger.info(f"Received signal {signum}, shutting down...")
|
||||
if scheduler:
|
||||
scheduler.shutdown(wait=True)
|
||||
logger.info("Scheduler stopped")
|
||||
sys.exit(0)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rules_config_path: str,
|
||||
mqtt_broker: str,
|
||||
mqtt_port: int,
|
||||
redis_url: str
|
||||
):
|
||||
"""
|
||||
Initialize rule engine.
|
||||
|
||||
Args:
|
||||
rules_config_path: Path to rules.yaml
|
||||
mqtt_broker: MQTT broker hostname/IP
|
||||
mqtt_port: MQTT broker port
|
||||
redis_url: Redis connection URL
|
||||
"""
|
||||
self.rules_config_path = rules_config_path
|
||||
self.mqtt_broker = mqtt_broker
|
||||
self.mqtt_port = mqtt_port
|
||||
self.redis_url = redis_url
|
||||
|
||||
# Will be initialized in setup()
|
||||
self.rule_descriptors: list[RuleDescriptor] = []
|
||||
self.rules: dict[str, Any] = {} # rule_id -> Rule instance
|
||||
self.mqtt_client: MQTTClient | None = None
|
||||
self.redis_state: RedisState | None = None
|
||||
self.context: RuleContext | None = None
|
||||
self._mqtt_topics: list[str] = [] # Topics to subscribe to
|
||||
|
||||
# For graceful shutdown
|
||||
self._shutdown_event = asyncio.Event()
|
||||
|
||||
async def setup(self) -> None:
|
||||
"""
|
||||
Load configuration and instantiate rules.
|
||||
|
||||
Raises:
|
||||
ImportError: If rule implementation not found
|
||||
ValueError: If configuration is invalid
|
||||
"""
|
||||
logger.info(f"Loading rules configuration from {self.rules_config_path}")
|
||||
|
||||
# Load rules configuration
|
||||
config = load_rules_config(self.rules_config_path)
|
||||
self.rule_descriptors = config.rules
|
||||
|
||||
logger.info(f"Loaded {len(self.rule_descriptors)} rule(s) from configuration")
|
||||
|
||||
# Instantiate each rule
|
||||
for desc in self.rule_descriptors:
|
||||
if not desc.enabled:
|
||||
logger.info(f" - {desc.id} (type: {desc.type}) [DISABLED]")
|
||||
continue
|
||||
|
||||
try:
|
||||
rule_instance = load_rule(desc)
|
||||
self.rules[desc.id] = rule_instance
|
||||
logger.info(f" - {desc.id} (type: {desc.type})")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load rule {desc.id} (type: {desc.type}): {e}")
|
||||
raise
|
||||
|
||||
enabled_count = len(self.rules)
|
||||
total_count = len(self.rule_descriptors)
|
||||
disabled_count = total_count - enabled_count
|
||||
logger.info(f"Successfully loaded {enabled_count} rule implementation(s) ({disabled_count} disabled)")
|
||||
|
||||
# Call setup on each rule for validation
|
||||
for rule_id, rule_instance in self.rules.items():
|
||||
desc = next((d for d in self.rule_descriptors if d.id == rule_id), None)
|
||||
if desc:
|
||||
try:
|
||||
ctx = RuleContext(
|
||||
logger=logger,
|
||||
mqtt_publisher=self.mqtt_client,
|
||||
redis_state=self.redis_state
|
||||
)
|
||||
await rule_instance.setup(desc, ctx)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to setup rule {rule_id}: {e}")
|
||||
raise
|
||||
|
||||
# Collect MQTT subscriptions from all enabled rules
|
||||
all_topics = set()
|
||||
for rule_id, rule_instance in self.rules.items():
|
||||
desc = next((d for d in self.rule_descriptors if d.id == rule_id), None)
|
||||
if desc:
|
||||
try:
|
||||
topics = rule_instance.get_subscriptions(desc)
|
||||
all_topics.update(topics)
|
||||
logger.debug(f"Rule {rule_id} subscribes to {len(topics)} topic(s)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get subscriptions for rule {rule_id}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"Total MQTT subscriptions needed: {len(all_topics)}")
|
||||
|
||||
# Create unique client ID to avoid conflicts
|
||||
import uuid
|
||||
import os
|
||||
|
||||
client_id_base = "rule_engine"
|
||||
client_suffix = os.environ.get("MQTT_CLIENT_ID_SUFFIX") or uuid.uuid4().hex[:6]
|
||||
unique_client_id = f"{client_id_base}-{client_suffix}"
|
||||
|
||||
# Initialize MQTT client
|
||||
self.mqtt_client = MQTTClient(
|
||||
broker=self.mqtt_broker,
|
||||
port=self.mqtt_port,
|
||||
client_id=unique_client_id
|
||||
)
|
||||
self.mqtt_client.set_logger(logger)
|
||||
|
||||
# Store topics for connection
|
||||
self._mqtt_topics = list(all_topics)
|
||||
|
||||
# Initialize Redis state
|
||||
self.redis_state = RedisState(self.redis_url)
|
||||
|
||||
# Create MQTT publisher wrapper for RuleContext
|
||||
from apps.rules.rule_interface import MQTTPublisher
|
||||
mqtt_publisher = MQTTPublisher(mqtt_client=self.mqtt_client)
|
||||
|
||||
# Create rule context
|
||||
self.context = RuleContext(
|
||||
logger=logger,
|
||||
mqtt_publisher=mqtt_publisher,
|
||||
redis_state=self.redis_state,
|
||||
now_fn=datetime.now
|
||||
)
|
||||
|
||||
def _filter_rules_for_event(self, event: dict[str, Any]) -> list[tuple[str, RuleDescriptor]]:
|
||||
"""
|
||||
Filter rules that should receive this event.
|
||||
|
||||
Rules match if the event's device_id is in the rule's objects.
|
||||
|
||||
Args:
|
||||
event: Normalized MQTT event
|
||||
|
||||
Returns:
|
||||
List of (rule_id, descriptor) tuples that should process this event
|
||||
"""
|
||||
matching_rules = []
|
||||
device_id = event.get('device_id')
|
||||
cap = event.get('cap')
|
||||
|
||||
if not device_id or not cap:
|
||||
return matching_rules
|
||||
|
||||
logger.debug(f"Filtering for cap={cap}, device_id={device_id}")
|
||||
|
||||
# Only check enabled rules (rules in self.rules dict)
|
||||
for rule_id, rule_instance in self.rules.items():
|
||||
desc = next((d for d in self.rule_descriptors if d.id == rule_id), None)
|
||||
if not desc:
|
||||
continue
|
||||
|
||||
objects = desc.objects
|
||||
|
||||
# Check if this device is in the rule's objects
|
||||
matched = False
|
||||
|
||||
if cap == 'contact' and objects.get('contacts'):
|
||||
logger.debug(f"Rule {rule_id}: checking contacts {objects.get('contacts')}")
|
||||
if device_id in objects.get('contacts', []):
|
||||
matched = True
|
||||
|
||||
elif cap == 'thermostat' and objects.get('thermostats'):
|
||||
logger.debug(f"Rule {rule_id}: checking thermostats {objects.get('thermostats')}")
|
||||
if device_id in objects.get('thermostats', []):
|
||||
matched = True
|
||||
|
||||
elif cap == 'light' and objects.get('lights'):
|
||||
logger.debug(f"Rule {rule_id}: checking lights {objects.get('lights')}")
|
||||
if device_id in objects.get('lights', []):
|
||||
matched = True
|
||||
|
||||
elif cap == 'relay' and objects.get('relays'):
|
||||
logger.debug(f"Rule {rule_id}: checking relays {objects.get('relays')}")
|
||||
if device_id in objects.get('relays', []):
|
||||
matched = True
|
||||
|
||||
if matched:
|
||||
matching_rules.append((rule_id, desc))
|
||||
|
||||
return matching_rules
|
||||
|
||||
async def _dispatch_event(self, event: dict[str, Any]) -> None:
|
||||
"""
|
||||
Dispatch event to matching rules.
|
||||
|
||||
Calls rule.on_event() for each matching rule sequentially
|
||||
to preserve order and avoid race conditions.
|
||||
|
||||
Args:
|
||||
event: Normalized MQTT event
|
||||
"""
|
||||
# Debug logging
|
||||
logger.debug(f"Received event: {event}")
|
||||
|
||||
matching_rules = self._filter_rules_for_event(event)
|
||||
|
||||
if not matching_rules:
|
||||
# No rules interested in this event
|
||||
logger.debug(f"No matching rules for {event.get('cap')}/{event.get('device_id')}")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Event {event['cap']}/{event['device_id']}: "
|
||||
f"{len(matching_rules)} matching rule(s)"
|
||||
)
|
||||
|
||||
# Process rules sequentially to preserve order
|
||||
for rule_id, desc in matching_rules:
|
||||
rule = self.rules.get(rule_id)
|
||||
if not rule:
|
||||
logger.warning(f"Rule instance not found for {rule_id}")
|
||||
continue
|
||||
|
||||
try:
|
||||
await rule.on_event(event, desc, self.context)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error in rule {rule_id} processing event "
|
||||
f"{event['cap']}/{event['device_id']}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
# Continue with other rules
|
||||
|
||||
async def run(self) -> None:
|
||||
"""
|
||||
Main event loop - subscribe to MQTT and process events.
|
||||
|
||||
Runs until shutdown signal received.
|
||||
"""
|
||||
logger.info("Starting event processing loop")
|
||||
|
||||
try:
|
||||
async for event in self.mqtt_client.connect(topics=self._mqtt_topics):
|
||||
# Check for shutdown
|
||||
if self._shutdown_event.is_set():
|
||||
logger.info("Shutdown signal received, stopping event loop")
|
||||
break
|
||||
|
||||
# Dispatch event to matching rules
|
||||
await self._dispatch_event(event)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Event loop cancelled")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal error in event loop: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Graceful shutdown - close connections."""
|
||||
logger.info("Shutting down rule engine...")
|
||||
self._shutdown_event.set()
|
||||
|
||||
if self.redis_state:
|
||||
await self.redis_state.close()
|
||||
logger.info("Redis connection closed")
|
||||
|
||||
logger.info("Shutdown complete")
|
||||
|
||||
|
||||
async def main_async() -> None:
|
||||
"""Async main function."""
|
||||
# Read configuration from environment
|
||||
rules_config = os.getenv('RULES_CONFIG', 'config/rules.yaml')
|
||||
mqtt_broker = os.getenv('MQTT_BROKER', '172.16.2.16')
|
||||
mqtt_port = int(os.getenv('MQTT_PORT', '1883'))
|
||||
redis_host = os.getenv('REDIS_HOST', '172.23.1.116')
|
||||
redis_port = int(os.getenv('REDIS_PORT', '6379'))
|
||||
redis_db = int(os.getenv('REDIS_DB', '8'))
|
||||
redis_url = f'redis://{redis_host}:{redis_port}/{redis_db}'
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("Rules Engine Starting")
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"Config: {rules_config}")
|
||||
logger.info(f"MQTT: {mqtt_broker}:{mqtt_port}")
|
||||
logger.info(f"Redis: {redis_url}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Initialize engine
|
||||
engine = RuleEngine(
|
||||
rules_config_path=rules_config,
|
||||
mqtt_broker=mqtt_broker,
|
||||
mqtt_port=mqtt_port,
|
||||
redis_url=redis_url
|
||||
)
|
||||
|
||||
# Load rules
|
||||
try:
|
||||
await engine.setup()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to setup engine: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Setup signal handlers for graceful shutdown
|
||||
loop = asyncio.get_running_loop()
|
||||
main_task = None
|
||||
|
||||
def signal_handler():
|
||||
logger.info("Received shutdown signal")
|
||||
engine._shutdown_event.set()
|
||||
if main_task and not main_task.done():
|
||||
main_task.cancel()
|
||||
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, signal_handler)
|
||||
|
||||
# Run engine
|
||||
try:
|
||||
main_task = asyncio.create_task(engine.run())
|
||||
await main_task
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Main task cancelled")
|
||||
finally:
|
||||
await engine.shutdown()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the rules application."""
|
||||
global scheduler
|
||||
|
||||
logger.info("Rules engine starting...")
|
||||
|
||||
# Register signal handlers
|
||||
signal.signal(signal.SIGINT, shutdown_handler)
|
||||
signal.signal(signal.SIGTERM, shutdown_handler)
|
||||
|
||||
# Initialize scheduler
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
# Add example job - runs every minute
|
||||
scheduler.add_job(
|
||||
rule_tick,
|
||||
'interval',
|
||||
minutes=1,
|
||||
id='rule_tick',
|
||||
name='Rule Tick Job'
|
||||
)
|
||||
|
||||
# Start scheduler
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started with rule_tick job (every 1 minute)")
|
||||
|
||||
# Run initial tick immediately
|
||||
rule_tick()
|
||||
|
||||
# Keep the application running
|
||||
"""Entry point for rule engine."""
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
asyncio.run(main_async())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("KeyboardInterrupt received, shutting down...")
|
||||
scheduler.shutdown(wait=True)
|
||||
logger.info("Scheduler stopped")
|
||||
logger.info("Keyboard interrupt received")
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal error: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
5
apps/rules/requirements.txt
Normal file
5
apps/rules/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
# Rules Engine Dependencies
|
||||
pydantic>=2.0
|
||||
redis>=5.0.1
|
||||
aiomqtt>=2.0.1
|
||||
pyyaml>=6.0.1
|
||||
759
apps/rules/rule_interface.py
Normal file
759
apps/rules/rule_interface.py
Normal file
@@ -0,0 +1,759 @@
|
||||
"""
|
||||
Rule Interface and Context Objects
|
||||
|
||||
Provides the core abstractions for implementing automation rules:
|
||||
- RuleDescriptor: Configuration data for a rule instance
|
||||
- RedisState: State persistence interface
|
||||
- RuleContext: Runtime context provided to rules
|
||||
- Rule: Abstract base class for all rule implementations
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Any, Awaitable, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RuleDescriptor(BaseModel):
|
||||
"""
|
||||
Configuration descriptor for a rule instance.
|
||||
|
||||
This is the validated representation of a rule from rules.yaml.
|
||||
The engine loads these and passes them to rule implementations.
|
||||
|
||||
The 'objects' field is intentionally flexible (dict) to allow different
|
||||
rule types to define their own object structures.
|
||||
"""
|
||||
|
||||
id: str = Field(..., description="Unique identifier for this rule instance")
|
||||
name: Optional[str] = Field(None, description="Optional human-readable name")
|
||||
type: str = Field(..., description="Rule type with version (e.g., 'window_setback@1.0')")
|
||||
enabled: bool = Field(default=True, description="Whether this rule is enabled")
|
||||
objects: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Objects this rule monitors or controls (structure varies by rule type)"
|
||||
)
|
||||
params: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Rule-specific parameters"
|
||||
)
|
||||
|
||||
|
||||
class RedisState:
|
||||
"""
|
||||
Async Redis-backed state persistence for rules with automatic reconnection.
|
||||
|
||||
Provides a simple key-value and hash storage interface for rules to persist
|
||||
state across restarts. All operations are asynchronous and include retry logic
|
||||
for robustness against temporary Redis outages.
|
||||
|
||||
Key Convention:
|
||||
- Callers should use keys like: f"rules:{rule_id}:contact:{device_id}"
|
||||
- This class does NOT enforce key prefixes - caller controls the full key
|
||||
"""
|
||||
|
||||
def __init__(self, url: str, max_retries: int = 3, retry_delay: float = 0.5):
|
||||
"""
|
||||
Initialize RedisState with connection URL.
|
||||
|
||||
Args:
|
||||
url: Redis connection URL (e.g., 'redis://172.23.1.116:6379/8')
|
||||
max_retries: Maximum number of retry attempts for operations (default: 3)
|
||||
retry_delay: Initial delay between retries in seconds, uses exponential backoff (default: 0.5)
|
||||
|
||||
Note:
|
||||
Connection is lazy - actual connection happens on first operation.
|
||||
Uses connection pooling with automatic reconnection on failure.
|
||||
"""
|
||||
self._url = url
|
||||
self._max_retries = max_retries
|
||||
self._retry_delay = retry_delay
|
||||
self._redis: Optional[Any] = None # redis.asyncio.Redis instance
|
||||
|
||||
async def _get_client(self):
|
||||
"""
|
||||
Get or create Redis client with connection pool.
|
||||
|
||||
Lazy initialization ensures we don't connect until first use.
|
||||
Uses decode_responses=True for automatic UTF-8 decoding.
|
||||
"""
|
||||
if self._redis is None:
|
||||
import redis.asyncio as aioredis
|
||||
self._redis = await aioredis.from_url(
|
||||
self._url,
|
||||
decode_responses=True, # Automatic UTF-8 decode
|
||||
encoding='utf-8',
|
||||
max_connections=10, # Connection pool size
|
||||
socket_connect_timeout=5,
|
||||
socket_keepalive=True,
|
||||
health_check_interval=30 # Auto-check connection health
|
||||
)
|
||||
return self._redis
|
||||
|
||||
async def _execute_with_retry(self, operation, *args, **kwargs):
|
||||
"""
|
||||
Execute Redis operation with exponential backoff retry.
|
||||
|
||||
Handles temporary connection failures gracefully by retrying
|
||||
with exponential backoff. On permanent failure, raises the
|
||||
original exception.
|
||||
|
||||
Args:
|
||||
operation: Async callable (Redis method)
|
||||
*args, **kwargs: Arguments to pass to operation
|
||||
|
||||
Returns:
|
||||
Result of the operation
|
||||
|
||||
Raises:
|
||||
Exception: If all retries are exhausted
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
last_exception = None
|
||||
for attempt in range(self._max_retries):
|
||||
try:
|
||||
client = await self._get_client()
|
||||
return await operation(client, *args, **kwargs)
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
if attempt < self._max_retries - 1:
|
||||
# Exponential backoff: 0.5s, 1s, 2s, ...
|
||||
delay = self._retry_delay * (2 ** attempt)
|
||||
await asyncio.sleep(delay)
|
||||
# Reset client to force reconnection
|
||||
if self._redis:
|
||||
try:
|
||||
await self._redis.close()
|
||||
except:
|
||||
pass
|
||||
self._redis = None
|
||||
|
||||
# All retries exhausted
|
||||
raise last_exception
|
||||
|
||||
# JSON helpers for complex data structures
|
||||
def _dumps(self, obj: Any) -> str:
|
||||
"""Serialize Python object to JSON string."""
|
||||
import json
|
||||
return json.dumps(obj, ensure_ascii=False)
|
||||
|
||||
def _loads(self, s: str) -> Any:
|
||||
"""Deserialize JSON string to Python object."""
|
||||
import json
|
||||
return json.loads(s)
|
||||
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
Get a string value by key.
|
||||
|
||||
Args:
|
||||
key: Redis key (e.g., "rules:my_rule:contact:sensor_1")
|
||||
|
||||
Returns:
|
||||
String value or None if key doesn't exist
|
||||
|
||||
Example:
|
||||
>>> state = RedisState("redis://localhost:6379/0")
|
||||
>>> await state.set("rules:r1:temp", "22.5")
|
||||
>>> temp = await state.get("rules:r1:temp")
|
||||
>>> print(temp) # "22.5"
|
||||
"""
|
||||
async def _get(client, k):
|
||||
return await client.get(k)
|
||||
|
||||
return await self._execute_with_retry(_get, key)
|
||||
|
||||
async def set(self, key: str, value: str, ttl_secs: Optional[int] = None) -> None:
|
||||
"""
|
||||
Set a string value with optional TTL.
|
||||
|
||||
Args:
|
||||
key: Redis key
|
||||
value: String value to store
|
||||
ttl_secs: Optional time-to-live in seconds. If None, key persists indefinitely.
|
||||
|
||||
Example:
|
||||
>>> state = RedisState("redis://localhost:6379/0")
|
||||
>>> # Store with 1 hour TTL
|
||||
>>> await state.set("rules:r1:previous_temp", "20.0", ttl_secs=3600)
|
||||
"""
|
||||
async def _set(client, k, v, ttl):
|
||||
if ttl is not None:
|
||||
await client.setex(k, ttl, v)
|
||||
else:
|
||||
await client.set(k, v)
|
||||
|
||||
await self._execute_with_retry(_set, key, value, ttl_secs)
|
||||
|
||||
async def hget(self, key: str, field: str) -> Optional[str]:
|
||||
"""
|
||||
Get a hash field value.
|
||||
|
||||
Args:
|
||||
key: Redis hash key
|
||||
field: Field name within the hash
|
||||
|
||||
Returns:
|
||||
String value or None if field doesn't exist
|
||||
|
||||
Example:
|
||||
>>> state = RedisState("redis://localhost:6379/0")
|
||||
>>> await state.hset("rules:r1:device_states", "sensor_1", "open")
|
||||
>>> value = await state.hget("rules:r1:device_states", "sensor_1")
|
||||
>>> print(value) # "open"
|
||||
"""
|
||||
async def _hget(client, k, f):
|
||||
return await client.hget(k, f)
|
||||
|
||||
return await self._execute_with_retry(_hget, key, field)
|
||||
|
||||
async def hset(self, key: str, field: str, value: str) -> None:
|
||||
"""
|
||||
Set a hash field value.
|
||||
|
||||
Args:
|
||||
key: Redis hash key
|
||||
field: Field name within the hash
|
||||
value: String value to store
|
||||
|
||||
Example:
|
||||
>>> state = RedisState("redis://localhost:6379/0")
|
||||
>>> await state.hset("rules:r1:sensors", "bedroom", "open")
|
||||
>>> await state.hset("rules:r1:sensors", "kitchen", "closed")
|
||||
"""
|
||||
async def _hset(client, k, f, v):
|
||||
await client.hset(k, f, v)
|
||||
|
||||
await self._execute_with_retry(_hset, key, field, value)
|
||||
|
||||
async def expire(self, key: str, ttl_secs: int) -> None:
|
||||
"""
|
||||
Set or update TTL on an existing key.
|
||||
|
||||
Args:
|
||||
key: Redis key
|
||||
ttl_secs: Time-to-live in seconds
|
||||
|
||||
Example:
|
||||
>>> state = RedisState("redis://localhost:6379/0")
|
||||
>>> await state.set("rules:r1:temp", "22.5")
|
||||
>>> await state.expire("rules:r1:temp", 3600) # Expire in 1 hour
|
||||
"""
|
||||
async def _expire(client, k, ttl):
|
||||
await client.expire(k, ttl)
|
||||
|
||||
await self._execute_with_retry(_expire, key, ttl_secs)
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
"""
|
||||
Delete a key from Redis.
|
||||
|
||||
Args:
|
||||
key: Redis key to delete
|
||||
|
||||
Example:
|
||||
>>> state = RedisState("redis://localhost:6379/0")
|
||||
>>> await state.set("rules:r1:temp", "22.5")
|
||||
>>> await state.delete("rules:r1:temp")
|
||||
"""
|
||||
async def _delete(client, k):
|
||||
await client.delete(k)
|
||||
|
||||
await self._execute_with_retry(_delete, key)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""
|
||||
Close Redis connection and cleanup resources.
|
||||
|
||||
Should be called when shutting down the application.
|
||||
"""
|
||||
if self._redis:
|
||||
await self._redis.close()
|
||||
self._redis = None
|
||||
|
||||
|
||||
class MQTTClient:
|
||||
"""
|
||||
Async MQTT client for rule engine with event normalization and publishing.
|
||||
|
||||
Subscribes to device state topics, normalizes events to a consistent format,
|
||||
and provides high-level publishing methods for device commands.
|
||||
|
||||
Event Normalization:
|
||||
All incoming MQTT messages are parsed into a normalized event structure:
|
||||
{
|
||||
"topic": "home/contact/sensor_1/state",
|
||||
"type": "state",
|
||||
"cap": "contact", # Capability type (contact, thermostat, light, etc.)
|
||||
"device_id": "sensor_1",
|
||||
"payload": {"contact": "open"},
|
||||
"ts": "2025-11-11T10:30:45.123456"
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
broker: str,
|
||||
port: int = 1883,
|
||||
client_id: str = "rule_engine",
|
||||
reconnect_interval: int = 5,
|
||||
max_reconnect_delay: int = 300
|
||||
):
|
||||
"""
|
||||
Initialize MQTT client.
|
||||
|
||||
Args:
|
||||
broker: MQTT broker hostname or IP
|
||||
port: MQTT broker port (default: 1883)
|
||||
client_id: Unique client ID for this connection
|
||||
reconnect_interval: Initial reconnect delay in seconds (default: 5)
|
||||
max_reconnect_delay: Maximum reconnect delay in seconds (default: 300)
|
||||
"""
|
||||
self._broker = broker
|
||||
self._port = port
|
||||
self._client_id = client_id
|
||||
self._reconnect_interval = reconnect_interval
|
||||
self._max_reconnect_delay = max_reconnect_delay
|
||||
self._client = None
|
||||
self._logger = None # Set externally
|
||||
|
||||
def set_logger(self, logger):
|
||||
"""Set logger instance for connection status messages."""
|
||||
self._logger = logger
|
||||
|
||||
def _log(self, level: str, msg: str):
|
||||
"""Internal logging helper."""
|
||||
if self._logger:
|
||||
getattr(self._logger, level)(msg)
|
||||
else:
|
||||
print(f"[{level.upper()}] {msg}")
|
||||
|
||||
async def connect(self, topics: list[str] = None):
|
||||
"""
|
||||
Connect to MQTT broker with automatic reconnection.
|
||||
|
||||
This method manages the connection and automatically reconnects
|
||||
with exponential backoff if the connection is lost.
|
||||
|
||||
Args:
|
||||
topics: List of MQTT topics to subscribe to. If None, subscribes to nothing.
|
||||
"""
|
||||
import aiomqtt
|
||||
from aiomqtt import Client
|
||||
|
||||
if topics is None:
|
||||
topics = []
|
||||
|
||||
reconnect_delay = self._reconnect_interval
|
||||
|
||||
while True:
|
||||
try:
|
||||
self._log("info", f"Connecting to MQTT broker {self._broker}:{self._port} (client_id={self._client_id})")
|
||||
|
||||
async with Client(
|
||||
hostname=self._broker,
|
||||
port=self._port,
|
||||
identifier=self._client_id,
|
||||
) as client:
|
||||
self._client = client
|
||||
self._log("info", f"Connected to MQTT broker {self._broker}:{self._port}")
|
||||
|
||||
# Subscribe to provided topics
|
||||
if topics:
|
||||
for topic in topics:
|
||||
await client.subscribe(topic)
|
||||
self._log("info", f"Subscribed to {len(topics)} topic(s): {', '.join(topics[:5])}{'...' if len(topics) > 5 else ''}")
|
||||
|
||||
# Reset reconnect delay on successful connection
|
||||
reconnect_delay = self._reconnect_interval
|
||||
|
||||
# Process messages - this is a generator that yields messages
|
||||
async for message in client.messages:
|
||||
yield self._normalize_event(message)
|
||||
|
||||
except aiomqtt.MqttError as e:
|
||||
self._log("error", f"MQTT connection error: {e}")
|
||||
self._log("info", f"Reconnecting in {reconnect_delay} seconds...")
|
||||
|
||||
import asyncio
|
||||
await asyncio.sleep(reconnect_delay)
|
||||
|
||||
# Exponential backoff
|
||||
reconnect_delay = min(reconnect_delay * 2, self._max_reconnect_delay)
|
||||
|
||||
def _normalize_event(self, message) -> dict[str, Any]:
|
||||
"""
|
||||
Normalize MQTT message to standard event format.
|
||||
|
||||
Parses topic to extract capability type and device_id,
|
||||
adds timestamp, and structures payload.
|
||||
|
||||
Args:
|
||||
message: aiomqtt.Message instance
|
||||
|
||||
Returns:
|
||||
Normalized event dictionary
|
||||
|
||||
Example:
|
||||
Topic: home/contact/sensor_bedroom/state
|
||||
Payload: {"contact": "open"}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"topic": "home/contact/sensor_bedroom/state",
|
||||
"type": "state",
|
||||
"cap": "contact",
|
||||
"device_id": "sensor_bedroom",
|
||||
"payload": {"contact": "open"},
|
||||
"ts": "2025-11-11T10:30:45.123456"
|
||||
}
|
||||
"""
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
topic = str(message.topic)
|
||||
topic_parts = topic.split('/')
|
||||
|
||||
# Parse topic: home/{capability}/{device_id}/state
|
||||
if len(topic_parts) >= 4 and topic_parts[0] == 'home' and topic_parts[3] == 'state':
|
||||
cap = topic_parts[1] # contact, thermostat, light, etc.
|
||||
device_id = topic_parts[2]
|
||||
else:
|
||||
# Fallback for unexpected topic format
|
||||
cap = "unknown"
|
||||
device_id = topic_parts[-2] if len(topic_parts) >= 2 else "unknown"
|
||||
|
||||
# Parse payload
|
||||
try:
|
||||
payload = json.loads(message.payload.decode('utf-8'))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
payload = {"raw": message.payload.decode('utf-8', errors='replace')}
|
||||
|
||||
# Generate timestamp
|
||||
ts = datetime.now().isoformat()
|
||||
|
||||
return {
|
||||
"topic": topic,
|
||||
"type": "state",
|
||||
"cap": cap,
|
||||
"device_id": device_id,
|
||||
"payload": payload,
|
||||
"ts": ts
|
||||
}
|
||||
|
||||
async def publish_set_thermostat(self, device_id: str, target: float) -> None:
|
||||
"""
|
||||
Publish thermostat target temperature command.
|
||||
|
||||
Publishes to: home/thermostat/{device_id}/set
|
||||
QoS: 1 (at least once delivery)
|
||||
|
||||
Args:
|
||||
device_id: Thermostat device identifier
|
||||
target: Target temperature in degrees Celsius
|
||||
|
||||
Example:
|
||||
>>> mqtt = MQTTClient("172.16.2.16", 1883)
|
||||
>>> await mqtt.publish_set_thermostat("thermostat_wohnzimmer", 22.5)
|
||||
|
||||
Published to: home/thermostat/thermostat_wohnzimmer/set
|
||||
Payload: {"type":"thermostat","payload":{"target":22.5}}
|
||||
"""
|
||||
import json
|
||||
|
||||
if self._client is None:
|
||||
raise RuntimeError("MQTT client not connected. Call connect() first.")
|
||||
|
||||
topic = f"home/thermostat/{device_id}/set"
|
||||
payload = {
|
||||
"type": "thermostat",
|
||||
"payload": {
|
||||
"target": target
|
||||
}
|
||||
}
|
||||
|
||||
payload_str = json.dumps(payload)
|
||||
|
||||
await self._client.publish(
|
||||
topic,
|
||||
payload=payload_str.encode('utf-8'),
|
||||
qos=1 # At least once delivery
|
||||
)
|
||||
|
||||
self._log("debug", f"Published SET to {topic}: {payload_str}")
|
||||
|
||||
|
||||
# Legacy alias for backward compatibility
|
||||
class MQTTPublisher:
|
||||
"""
|
||||
Legacy MQTT publishing interface - DEPRECATED.
|
||||
|
||||
Use MQTTClient instead for new code.
|
||||
This class is kept for backward compatibility with existing documentation.
|
||||
"""
|
||||
|
||||
def __init__(self, mqtt_client):
|
||||
"""
|
||||
Initialize MQTT publisher.
|
||||
|
||||
Args:
|
||||
mqtt_client: MQTTClient instance
|
||||
"""
|
||||
self._mqtt = mqtt_client
|
||||
|
||||
async def publish_set_thermostat(self, device_id: str, target: float) -> None:
|
||||
"""
|
||||
Publish a thermostat target temperature command.
|
||||
|
||||
Args:
|
||||
device_id: Thermostat device identifier
|
||||
target: Target temperature in degrees Celsius
|
||||
"""
|
||||
await self._mqtt.publish_set_thermostat(device_id, target)
|
||||
|
||||
|
||||
class RuleContext:
|
||||
"""
|
||||
Runtime context provided to rules during event processing.
|
||||
|
||||
Contains all external dependencies and utilities a rule needs:
|
||||
- Logger for diagnostics
|
||||
- MQTT client for publishing commands
|
||||
- Redis client for state persistence
|
||||
- Current timestamp function
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
logger,
|
||||
mqtt_publisher: MQTTPublisher,
|
||||
redis_state: RedisState,
|
||||
now_fn=None
|
||||
):
|
||||
"""
|
||||
Initialize rule context.
|
||||
|
||||
Args:
|
||||
logger: Logger instance (e.g., logging.Logger)
|
||||
mqtt_publisher: MQTTPublisher instance for device commands
|
||||
redis_state: RedisState instance for persistence
|
||||
now_fn: Optional callable returning current datetime (defaults to datetime.now)
|
||||
"""
|
||||
self.logger = logger
|
||||
self.mqtt = mqtt_publisher
|
||||
self.redis = redis_state
|
||||
self._now_fn = now_fn or datetime.now
|
||||
|
||||
def now(self) -> datetime:
|
||||
"""
|
||||
Get current timestamp.
|
||||
|
||||
Returns:
|
||||
Current datetime (timezone-aware if now_fn provides it)
|
||||
"""
|
||||
return self._now_fn()
|
||||
|
||||
|
||||
class Rule(ABC):
|
||||
"""
|
||||
Abstract base class for all automation rule implementations.
|
||||
|
||||
Rules implement event-driven automation logic. The engine calls on_event()
|
||||
for each relevant device state change, passing the event data, rule configuration,
|
||||
and runtime context.
|
||||
|
||||
Implementations must be idempotent - processing the same event multiple times
|
||||
should produce the same result.
|
||||
|
||||
Example implementation:
|
||||
|
||||
class WindowSetbackRule(Rule):
|
||||
def get_subscriptions(self, desc: RuleDescriptor) -> list[str]:
|
||||
# Subscribe to contact sensor state topics
|
||||
topics = []
|
||||
for contact_id in desc.objects.contacts or []:
|
||||
topics.append(f"home/contact/{contact_id}/state")
|
||||
return topics
|
||||
|
||||
async def on_event(self, evt: dict, desc: RuleDescriptor, ctx: RuleContext) -> None:
|
||||
device_id = evt['device_id']
|
||||
cap = evt['cap']
|
||||
|
||||
if cap == 'contact':
|
||||
contact_state = evt['payload'].get('contact')
|
||||
if contact_state == 'open':
|
||||
# Window opened - set thermostats to eco
|
||||
for thermo_id in desc.objects.thermostats or []:
|
||||
eco_temp = desc.params.get('eco_target', 16.0)
|
||||
await ctx.mqtt.publish_set_thermostat(thermo_id, eco_temp)
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_subscriptions(self, desc: RuleDescriptor) -> list[str]:
|
||||
"""
|
||||
Return list of MQTT topics this rule needs to subscribe to.
|
||||
|
||||
Called once during rule engine setup. The rule examines its configuration
|
||||
(desc.objects) and returns the specific state topics it needs to monitor.
|
||||
|
||||
Args:
|
||||
desc: Rule configuration from rules.yaml
|
||||
|
||||
Returns:
|
||||
List of MQTT topic patterns/strings to subscribe to
|
||||
|
||||
Example:
|
||||
For a window setback rule monitoring 2 contacts:
|
||||
['home/contact/sensor_bedroom/state', 'home/contact/sensor_kitchen/state']
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def on_event(
|
||||
self,
|
||||
evt: dict[str, Any],
|
||||
desc: RuleDescriptor,
|
||||
ctx: RuleContext
|
||||
) -> None:
|
||||
"""
|
||||
Process a device state change event.
|
||||
|
||||
This method is called by the rule engine whenever a device state changes
|
||||
that is relevant to this rule. The implementation should examine the event
|
||||
and take appropriate actions (e.g., publish MQTT commands, update state).
|
||||
|
||||
MUST be idempotent: Processing the same event multiple times should be safe.
|
||||
|
||||
Args:
|
||||
evt: Event dictionary with the following structure:
|
||||
{
|
||||
"topic": "home/contact/device_id/state", # MQTT topic
|
||||
"type": "state", # Message type
|
||||
"cap": "contact", # Capability type
|
||||
"device_id": "kontakt_wohnzimmer", # Device identifier
|
||||
"payload": {"contact": "open"}, # Capability-specific payload
|
||||
"ts": "2025-11-11T10:30:45.123456" # ISO timestamp
|
||||
}
|
||||
|
||||
desc: Rule configuration from rules.yaml
|
||||
|
||||
ctx: Runtime context with logger, MQTT, Redis, and timestamp utilities
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
Exception: Implementation may raise exceptions for errors.
|
||||
The engine will log them but continue processing.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Dynamic Rule Loading
|
||||
# ============================================================================
|
||||
|
||||
import importlib
|
||||
import re
|
||||
from typing import Type
|
||||
|
||||
# Cache for loaded rule classes (per process)
|
||||
_RULE_CLASS_CACHE: dict[str, Type[Rule]] = {}
|
||||
|
||||
|
||||
def load_rule(desc: RuleDescriptor) -> Rule:
|
||||
"""
|
||||
Dynamically load and instantiate a rule based on its type descriptor.
|
||||
|
||||
Convention:
|
||||
- Rule type format: 'name@version' (e.g., 'window_setback@1.0')
|
||||
- Module path: apps.rules.impl.{name}
|
||||
- Class name: PascalCase version of name + 'Rule'
|
||||
Example: 'window_setback' → 'WindowSetbackRule'
|
||||
|
||||
Args:
|
||||
desc: Rule descriptor from rules.yaml
|
||||
|
||||
Returns:
|
||||
Instantiated Rule object
|
||||
|
||||
Raises:
|
||||
ValueError: If type format is invalid
|
||||
ImportError: If rule module cannot be found
|
||||
AttributeError: If rule class cannot be found in module
|
||||
|
||||
Examples:
|
||||
>>> desc = RuleDescriptor(
|
||||
... id="test_rule",
|
||||
... type="window_setback@1.0",
|
||||
... targets={},
|
||||
... params={}
|
||||
... )
|
||||
>>> rule = load_rule(desc)
|
||||
>>> isinstance(rule, Rule)
|
||||
True
|
||||
"""
|
||||
rule_type = desc.type
|
||||
|
||||
# Check cache first
|
||||
if rule_type in _RULE_CLASS_CACHE:
|
||||
rule_class = _RULE_CLASS_CACHE[rule_type]
|
||||
return rule_class()
|
||||
|
||||
# Parse type: 'name@version'
|
||||
if '@' not in rule_type:
|
||||
raise ValueError(
|
||||
f"Invalid rule type '{rule_type}': must be in format 'name@version' "
|
||||
f"(e.g., 'window_setback@1.0')"
|
||||
)
|
||||
|
||||
name, version = rule_type.split('@', 1)
|
||||
|
||||
# Validate name (alphanumeric and underscores only)
|
||||
if not re.match(r'^[a-z][a-z0-9_]*$', name):
|
||||
raise ValueError(
|
||||
f"Invalid rule name '{name}': must start with lowercase letter "
|
||||
f"and contain only lowercase letters, numbers, and underscores"
|
||||
)
|
||||
|
||||
# Convert snake_case to PascalCase for class name
|
||||
# Example: 'window_setback' → 'WindowSetbackRule'
|
||||
class_name = ''.join(word.capitalize() for word in name.split('_')) + 'Rule'
|
||||
|
||||
# Construct module path
|
||||
module_path = f'apps.rules.impl.{name}'
|
||||
|
||||
# Try to import the module
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
f"Cannot load rule type '{rule_type}': module '{module_path}' not found.\n"
|
||||
f"Hint: Create file 'apps/rules/impl/{name}.py' with class '{class_name}'.\n"
|
||||
f"Original error: {e}"
|
||||
) from e
|
||||
|
||||
# Try to get the class from the module
|
||||
try:
|
||||
rule_class = getattr(module, class_name)
|
||||
except AttributeError as e:
|
||||
raise AttributeError(
|
||||
f"Cannot load rule type '{rule_type}': class '{class_name}' not found in module '{module_path}'.\n"
|
||||
f"Hint: Define 'class {class_name}(Rule):' in 'apps/rules/impl/{name}.py'.\n"
|
||||
f"Available classes in module: {[name for name in dir(module) if not name.startswith('_')]}"
|
||||
) from e
|
||||
|
||||
# Validate that it's a Rule subclass
|
||||
if not issubclass(rule_class, Rule):
|
||||
raise TypeError(
|
||||
f"Class '{class_name}' in '{module_path}' is not a subclass of Rule. "
|
||||
f"Ensure it inherits from apps.rules.rule_interface.Rule"
|
||||
)
|
||||
|
||||
# Cache the class
|
||||
_RULE_CLASS_CACHE[rule_type] = rule_class
|
||||
|
||||
# Instantiate and return
|
||||
return rule_class()
|
||||
122
apps/rules/rules_config.py
Normal file
122
apps/rules/rules_config.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Rules Configuration Schema and Loader
|
||||
|
||||
Provides Pydantic models for validating rules.yaml configuration.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class Rule(BaseModel):
|
||||
"""Single rule configuration"""
|
||||
id: str = Field(..., description="Unique rule identifier")
|
||||
name: Optional[str] = Field(None, description="Optional human-readable name")
|
||||
type: str = Field(..., description="Rule type (e.g., 'window_setback@1.0')")
|
||||
enabled: bool = Field(default=True, description="Whether this rule is enabled")
|
||||
objects: dict[str, Any] = Field(default_factory=dict, description="Objects this rule monitors or controls")
|
||||
params: dict[str, Any] = Field(default_factory=dict, description="Rule-specific parameters")
|
||||
|
||||
@field_validator('id')
|
||||
@classmethod
|
||||
def validate_id(cls, v: str) -> str:
|
||||
if not v or not v.strip():
|
||||
raise ValueError("Rule ID cannot be empty")
|
||||
return v.strip()
|
||||
|
||||
@field_validator('type')
|
||||
@classmethod
|
||||
def validate_type(cls, v: str) -> str:
|
||||
if not v or not v.strip():
|
||||
raise ValueError("Rule type cannot be empty")
|
||||
if '@' not in v:
|
||||
raise ValueError(f"Rule type must include version (e.g., 'window_setback@1.0'), got: {v}")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class RulesConfig(BaseModel):
|
||||
"""Root configuration object"""
|
||||
rules: list[Rule] = Field(..., description="List of all rules")
|
||||
|
||||
@field_validator('rules')
|
||||
@classmethod
|
||||
def validate_unique_ids(cls, rules: list[Rule]) -> list[Rule]:
|
||||
"""Ensure all rule IDs are unique"""
|
||||
ids = [rule.id for rule in rules]
|
||||
duplicates = [id for id in ids if ids.count(id) > 1]
|
||||
if duplicates:
|
||||
raise ValueError(f"Duplicate rule IDs found: {set(duplicates)}")
|
||||
return rules
|
||||
|
||||
|
||||
def load_rules_config(config_path: str | Path = "config/rules.yaml") -> RulesConfig:
|
||||
"""
|
||||
Load and validate rules configuration from YAML file.
|
||||
|
||||
Args:
|
||||
config_path: Path to rules.yaml file
|
||||
|
||||
Returns:
|
||||
Validated RulesConfig object
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config file doesn't exist
|
||||
ValueError: If YAML is invalid or validation fails
|
||||
"""
|
||||
config_path = Path(config_path)
|
||||
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(f"Rules configuration not found: {config_path}")
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
try:
|
||||
data = yaml.safe_load(f)
|
||||
except yaml.YAMLError as e:
|
||||
raise ValueError(f"Invalid YAML in {config_path}: {e}") from e
|
||||
|
||||
if not data:
|
||||
raise ValueError(f"Empty configuration file: {config_path}")
|
||||
|
||||
if 'rules' not in data:
|
||||
raise ValueError(
|
||||
f"Missing 'rules:' key in {config_path}. "
|
||||
"Configuration must start with 'rules:' followed by a list of rule definitions."
|
||||
)
|
||||
|
||||
try:
|
||||
return RulesConfig(**data)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Configuration validation failed: {e}") from e
|
||||
|
||||
|
||||
def get_rule_by_id(config: RulesConfig, rule_id: str) -> Rule | None:
|
||||
"""Get a specific rule by ID"""
|
||||
for rule in config.rules:
|
||||
if rule.id == rule_id:
|
||||
return rule
|
||||
return None
|
||||
|
||||
|
||||
def get_rules_by_type(config: RulesConfig, rule_type: str) -> list[Rule]:
|
||||
"""Get all rules of a specific type"""
|
||||
return [rule for rule in config.rules if rule.type == rule_type]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test configuration loading
|
||||
try:
|
||||
config = load_rules_config()
|
||||
print(f"✅ Loaded {len(config.rules)} rules:")
|
||||
for rule in config.rules:
|
||||
name = f" ({rule.name})" if rule.name else ""
|
||||
enabled = "✓" if rule.enabled else "✗"
|
||||
print(f" [{enabled}] {rule.id}{name}: {rule.type}")
|
||||
if rule.objects:
|
||||
obj_summary = ", ".join(f"{k}: {len(v) if isinstance(v, list) else v}"
|
||||
for k, v in rule.objects.items())
|
||||
print(f" Objects: {obj_summary}")
|
||||
except Exception as e:
|
||||
print(f"❌ Configuration error: {e}")
|
||||
44
apps/simulator/Dockerfile
Normal file
44
apps/simulator/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
# Simulator Service Dockerfile
|
||||
# FastAPI Web UI + MQTT Device Simulator
|
||||
|
||||
FROM python:3.14-alpine
|
||||
|
||||
# Prevent Python from writing .pyc files and enable unbuffered output
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
MQTT_BROKER=172.16.2.16 \
|
||||
MQTT_PORT=1883 \
|
||||
SIM_PORT=8010
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 10001 -S app && \
|
||||
adduser -u 10001 -S app -G app
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
gcc \
|
||||
musl-dev \
|
||||
linux-headers
|
||||
|
||||
# Install Python dependencies
|
||||
COPY apps/simulator/requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY apps/__init__.py /app/apps/__init__.py
|
||||
COPY apps/simulator/ /app/apps/simulator/
|
||||
|
||||
# Change ownership to app user
|
||||
RUN chown -R app:app /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER app
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8010
|
||||
|
||||
# Run the simulator
|
||||
CMD ["python", "-m", "uvicorn", "apps.simulator.main:app", "--host", "0.0.0.0", "--port", "8010"]
|
||||
351
apps/simulator/README.md
Normal file
351
apps/simulator/README.md
Normal file
@@ -0,0 +1,351 @@
|
||||
# Device Simulator Web Application
|
||||
|
||||
Web-basierte Anwendung zur Simulation von Home-Automation-Geräten mit Echtzeit-Monitoring.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Web-Interface**: Dashboard mit Echtzeit-Anzeige aller Events
|
||||
- ✅ **Server-Sent Events (SSE)**: Live-Updates ohne Polling
|
||||
- ✅ **Funktioniert ohne Browser**: Simulator läuft im Hintergrund
|
||||
- ✅ **Multi-Device Support**: Lights + Thermostats
|
||||
- ✅ **MQTT Integration**: Vollständige Kommunikation über MQTT
|
||||
- ✅ **Statistiken**: Zähler für Events, Commands und States
|
||||
- ✅ **Event-History**: Letzte 50 Events mit Details
|
||||
|
||||
## Geräte
|
||||
|
||||
### Lights (3 Stück)
|
||||
- `test_lampe_1` - Dimmbar
|
||||
- `test_lampe_2` - Einfach
|
||||
- `test_lampe_3` - Dimmbar
|
||||
|
||||
### Thermostats (1 Stück)
|
||||
- `test_thermo_1` - Auto/Heat/Off Modi, Temperatur-Drift
|
||||
|
||||
## Installation
|
||||
|
||||
Der Simulator ist bereits Teil des Projekts. Keine zusätzlichen Dependencies erforderlich.
|
||||
|
||||
## Start
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
# Standard-Start (Port 8010)
|
||||
poetry run uvicorn apps.simulator.main:app --host 0.0.0.0 --port 8010
|
||||
|
||||
# Mit Auto-Reload für Entwicklung
|
||||
poetry run uvicorn apps.simulator.main:app --host 0.0.0.0 --port 8010 --reload
|
||||
|
||||
# Im Hintergrund
|
||||
poetry run uvicorn apps.simulator.main:app --host 0.0.0.0 --port 8010 > /tmp/simulator.log 2>&1 &
|
||||
```
|
||||
|
||||
### Docker Container
|
||||
|
||||
#### Build Image
|
||||
|
||||
```bash
|
||||
docker build -t simulator:dev -f apps/simulator/Dockerfile .
|
||||
```
|
||||
|
||||
#### Run Container
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8010:8010 \
|
||||
-e MQTT_BROKER=172.23.1.102 \
|
||||
-e MQTT_PORT=1883 \
|
||||
-e SIM_PORT=8010 \
|
||||
simulator:dev
|
||||
```
|
||||
|
||||
**Note for finch/nerdctl users:**
|
||||
- finch binds ports to `127.0.0.1` by default
|
||||
- The web interface will be accessible at `http://127.0.0.1:8010`
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MQTT_BROKER` | `172.16.2.16` | MQTT broker hostname/IP |
|
||||
| `MQTT_PORT` | `1883` | MQTT broker port |
|
||||
| `SIM_PORT` | `8010` | Port for web interface |
|
||||
|
||||
## Web-Interface
|
||||
|
||||
Öffne im Browser:
|
||||
```
|
||||
http://localhost:8010
|
||||
```
|
||||
|
||||
### Features im Dashboard
|
||||
|
||||
1. **Status-Anzeige**
|
||||
- MQTT-Verbindungsstatus
|
||||
- Anzahl aktiver Geräte
|
||||
- Simulator-Status
|
||||
|
||||
2. **Geräte-Übersicht**
|
||||
- Echtzeit-Anzeige aller Light-States
|
||||
- Echtzeit-Anzeige aller Thermostat-States
|
||||
|
||||
3. **Event-Stream**
|
||||
- Alle MQTT-Commands
|
||||
- Alle State-Updates
|
||||
- Temperatur-Drift-Events
|
||||
- Fehler und Warnungen
|
||||
|
||||
4. **Statistiken**
|
||||
- Total Events
|
||||
- Commands Received
|
||||
- States Published
|
||||
|
||||
5. **Controls**
|
||||
- Clear Events
|
||||
- Toggle Auto-Scroll
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### `GET /`
|
||||
Web-Dashboard (HTML)
|
||||
|
||||
### `GET /health`
|
||||
Health-Check
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"simulator_running": true,
|
||||
"mqtt_connected": true
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /status`
|
||||
Vollständiger Status
|
||||
```json
|
||||
{
|
||||
"connected": true,
|
||||
"running": true,
|
||||
"light_states": {...},
|
||||
"thermostat_states": {...},
|
||||
"broker": "172.16.2.16:1883"
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /events`
|
||||
Letzte Events (JSON)
|
||||
```json
|
||||
{
|
||||
"events": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /realtime`
|
||||
Server-Sent Events Stream
|
||||
|
||||
## Event-Typen
|
||||
|
||||
### `simulator_connected`
|
||||
Simulator hat MQTT-Verbindung hergestellt
|
||||
```json
|
||||
{
|
||||
"type": "simulator_connected",
|
||||
"broker": "172.16.2.16:1883",
|
||||
"client_id": "device_simulator-abc123"
|
||||
}
|
||||
```
|
||||
|
||||
### `command_received`
|
||||
SET-Command von MQTT empfangen
|
||||
```json
|
||||
{
|
||||
"type": "command_received",
|
||||
"device_id": "test_lampe_1",
|
||||
"topic": "vendor/test_lampe_1/set",
|
||||
"payload": {"power": "on"}
|
||||
}
|
||||
```
|
||||
|
||||
### `light_updated`
|
||||
Light-State wurde aktualisiert
|
||||
```json
|
||||
{
|
||||
"type": "light_updated",
|
||||
"device_id": "test_lampe_1",
|
||||
"changes": {
|
||||
"power": {"old": "off", "new": "on"}
|
||||
},
|
||||
"new_state": {"power": "on", "brightness": 50}
|
||||
}
|
||||
```
|
||||
|
||||
### `thermostat_updated`
|
||||
Thermostat-State wurde aktualisiert
|
||||
```json
|
||||
{
|
||||
"type": "thermostat_updated",
|
||||
"device_id": "test_thermo_1",
|
||||
"changes": {
|
||||
"mode": {"old": "off", "new": "heat"}
|
||||
},
|
||||
"new_state": {...}
|
||||
}
|
||||
```
|
||||
|
||||
### `temperature_drift`
|
||||
Temperatur-Drift simuliert (alle 5 Sekunden)
|
||||
```json
|
||||
{
|
||||
"type": "temperature_drift",
|
||||
"device_id": "test_thermo_1",
|
||||
"old_temp": 20.5,
|
||||
"new_temp": 20.7,
|
||||
"target": 21.0,
|
||||
"mode": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
### `state_published`
|
||||
State wurde nach MQTT publiziert
|
||||
```json
|
||||
{
|
||||
"type": "state_published",
|
||||
"device_id": "test_lampe_1",
|
||||
"device_type": "light",
|
||||
"topic": "vendor/test_lampe_1/state",
|
||||
"payload": {"power": "on", "brightness": 50}
|
||||
}
|
||||
```
|
||||
|
||||
## MQTT Topics
|
||||
|
||||
### Subscribe
|
||||
- `vendor/test_lampe_1/set`
|
||||
- `vendor/test_lampe_2/set`
|
||||
- `vendor/test_lampe_3/set`
|
||||
- `vendor/test_thermo_1/set`
|
||||
|
||||
### Publish (retained, QoS 1)
|
||||
- `vendor/test_lampe_1/state`
|
||||
- `vendor/test_lampe_2/state`
|
||||
- `vendor/test_lampe_3/state`
|
||||
- `vendor/test_thermo_1/state`
|
||||
|
||||
## Integration mit anderen Services
|
||||
|
||||
Der Simulator funktioniert nahtlos mit:
|
||||
|
||||
1. **Abstraction Layer** (`apps.abstraction.main`)
|
||||
- Empfängt Commands über MQTT
|
||||
- Sendet States zurück
|
||||
|
||||
2. **API** (`apps.api.main`)
|
||||
- Commands werden via API gesendet
|
||||
- Simulator reagiert automatisch
|
||||
|
||||
3. **UI** (`apps.ui.main`)
|
||||
- UI zeigt Simulator-States in Echtzeit
|
||||
- Bedienung über UI beeinflusst Simulator
|
||||
|
||||
## Deployment
|
||||
|
||||
### Systemd Service
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Device Simulator
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=homeautomation
|
||||
WorkingDirectory=/path/to/home-automation
|
||||
ExecStart=/path/to/.venv/bin/uvicorn apps.simulator.main:app --host 0.0.0.0 --port 8003
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### Docker
|
||||
```dockerfile
|
||||
FROM python:3.14
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN pip install poetry && poetry install
|
||||
EXPOSE 8003
|
||||
CMD ["poetry", "run", "uvicorn", "apps.simulator.main:app", "--host", "0.0.0.0", "--port", "8003"]
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Simulator startet nicht
|
||||
```bash
|
||||
# Check logs
|
||||
tail -f /tmp/simulator.log
|
||||
|
||||
# Verify MQTT broker
|
||||
mosquitto_sub -h 172.16.2.16 -t '#' -v
|
||||
```
|
||||
|
||||
### Keine Events im Dashboard
|
||||
1. Browser-Console öffnen (F12)
|
||||
2. Prüfe SSE-Verbindung
|
||||
3. Reload Seite (F5)
|
||||
|
||||
### MQTT-Verbindung fehlgeschlagen
|
||||
```bash
|
||||
# Test broker connection
|
||||
mosquitto_pub -h 172.16.2.16 -t test -m hello
|
||||
|
||||
# Check broker status
|
||||
systemctl status mosquitto
|
||||
```
|
||||
|
||||
## Unterschied zum alten Simulator
|
||||
|
||||
### Alt (`tools/device_simulator.py`)
|
||||
- ✅ Reine CLI-Anwendung
|
||||
- ✅ Logging nur in stdout
|
||||
- ❌ Keine Web-UI
|
||||
- ❌ Keine Live-Monitoring
|
||||
|
||||
### Neu (`apps/simulator/main.py`)
|
||||
- ✅ FastAPI Web-Application
|
||||
- ✅ Logging + Web-Dashboard
|
||||
- ✅ SSE für Echtzeit-Updates
|
||||
- ✅ REST API für Status
|
||||
- ✅ Funktioniert auch ohne Browser
|
||||
- ✅ Statistiken und Event-History
|
||||
|
||||
## Entwicklung
|
||||
|
||||
### Code-Struktur
|
||||
```
|
||||
apps/simulator/
|
||||
├── __init__.py
|
||||
├── main.py # FastAPI app + Simulator logic
|
||||
└── templates/
|
||||
└── index.html # Web dashboard
|
||||
```
|
||||
|
||||
### Logging
|
||||
```python
|
||||
logger.info() # Wird in stdout UND als Event gestreamt
|
||||
add_event({}) # Wird nur als Event gestreamt
|
||||
```
|
||||
|
||||
### Neue Event-Typen hinzufügen
|
||||
1. Event in `main.py` erstellen: `add_event({...})`
|
||||
2. Optional: CSS-Klasse in `index.html` für Farbe
|
||||
3. Event wird automatisch im Dashboard angezeigt
|
||||
|
||||
## Performance
|
||||
|
||||
- **Memory**: ~50 MB
|
||||
- **CPU**: <1% idle, ~5% bei Commands
|
||||
- **SSE Connections**: Unbegrenzt
|
||||
- **Event Queue**: Max 100 Events (rolling)
|
||||
- **Per-Client Queue**: Unbegrenzt
|
||||
|
||||
## License
|
||||
|
||||
Teil des Home-Automation Projekts.
|
||||
1
apps/simulator/__init__.py
Normal file
1
apps/simulator/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Simulator app package."""
|
||||
489
apps/simulator/main.py
Normal file
489
apps/simulator/main.py
Normal file
@@ -0,0 +1,489 @@
|
||||
"""Device Simulator Web Application.
|
||||
|
||||
FastAPI application that runs the device simulator in the background
|
||||
and provides a web interface with real-time event streaming (SSE).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, AsyncGenerator
|
||||
from queue import Queue
|
||||
from collections import deque
|
||||
|
||||
from aiomqtt import Client, MqttError
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration
|
||||
BROKER_HOST = os.getenv("MQTT_BROKER", "172.16.2.16")
|
||||
BROKER_PORT = int(os.getenv("MQTT_PORT", "1883"))
|
||||
DRIFT_INTERVAL = 5 # seconds for thermostat temperature drift
|
||||
|
||||
# Device configurations
|
||||
LIGHT_DEVICES = ["test_lampe_1", "test_lampe_2", "test_lampe_3"]
|
||||
THERMOSTAT_DEVICES = ["test_thermo_1"]
|
||||
|
||||
# Global event queue for SSE
|
||||
event_queue = deque(maxlen=100) # Keep last 100 events
|
||||
sse_queues = [] # List of queues for active SSE connections
|
||||
|
||||
|
||||
def add_event(event: dict):
|
||||
"""Add event to global queue and notify all SSE clients."""
|
||||
event_with_ts = {
|
||||
**event,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z"
|
||||
}
|
||||
event_queue.append(event_with_ts)
|
||||
|
||||
# Notify all SSE clients
|
||||
for q in sse_queues:
|
||||
try:
|
||||
q.put_nowait(event_with_ts)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
class DeviceSimulator:
|
||||
"""Unified simulator for lights and thermostats."""
|
||||
|
||||
def __init__(self):
|
||||
# Light states
|
||||
self.light_states: Dict[str, Dict[str, Any]] = {
|
||||
"test_lampe_1": {"power": "off", "brightness": 50},
|
||||
"test_lampe_2": {"power": "off", "brightness": 50},
|
||||
"test_lampe_3": {"power": "off", "brightness": 50}
|
||||
}
|
||||
|
||||
# Thermostat states
|
||||
self.thermostat_states: Dict[str, Dict[str, Any]] = {
|
||||
"test_thermo_1": {
|
||||
"mode": "auto",
|
||||
"target": 21.0,
|
||||
"current": 20.5,
|
||||
"battery": 90,
|
||||
"window_open": False
|
||||
}
|
||||
}
|
||||
|
||||
self.client = None
|
||||
self.running = True
|
||||
self.drift_task = None
|
||||
self.connected = False
|
||||
|
||||
async def publish_state(self, device_id: str, device_type: str):
|
||||
"""Publish device state to MQTT (retained, QoS 1)."""
|
||||
if not self.client:
|
||||
return
|
||||
|
||||
if device_type == "light":
|
||||
state = self.light_states.get(device_id)
|
||||
elif device_type == "thermostat":
|
||||
state = self.thermostat_states.get(device_id)
|
||||
else:
|
||||
logger.warning(f"Unknown device type: {device_type}")
|
||||
return
|
||||
|
||||
if not state:
|
||||
logger.warning(f"Unknown device: {device_id}")
|
||||
return
|
||||
|
||||
state_topic = f"vendor/{device_id}/state"
|
||||
payload = json.dumps(state)
|
||||
|
||||
await self.client.publish(
|
||||
state_topic,
|
||||
payload=payload,
|
||||
qos=1,
|
||||
retain=True
|
||||
)
|
||||
|
||||
add_event({
|
||||
"type": "state_published",
|
||||
"device_id": device_id,
|
||||
"device_type": device_type,
|
||||
"topic": state_topic,
|
||||
"payload": state
|
||||
})
|
||||
|
||||
logger.info(f"[{device_id}] Published state: {payload}")
|
||||
|
||||
async def handle_light_set(self, device_id: str, payload: dict):
|
||||
"""Handle SET command for light device."""
|
||||
if device_id not in self.light_states:
|
||||
logger.warning(f"Unknown light device: {device_id}")
|
||||
return
|
||||
|
||||
state = self.light_states[device_id]
|
||||
changes = {}
|
||||
|
||||
if "power" in payload:
|
||||
old_power = state["power"]
|
||||
state["power"] = payload["power"]
|
||||
if old_power != state["power"]:
|
||||
changes["power"] = {"old": old_power, "new": state["power"]}
|
||||
logger.info(f"[{device_id}] Power: {old_power} -> {state['power']}")
|
||||
|
||||
if "brightness" in payload:
|
||||
old_brightness = state["brightness"]
|
||||
state["brightness"] = int(payload["brightness"])
|
||||
if old_brightness != state["brightness"]:
|
||||
changes["brightness"] = {"old": old_brightness, "new": state["brightness"]}
|
||||
logger.info(f"[{device_id}] Brightness: {old_brightness} -> {state['brightness']}")
|
||||
|
||||
if changes:
|
||||
add_event({
|
||||
"type": "light_updated",
|
||||
"device_id": device_id,
|
||||
"changes": changes,
|
||||
"new_state": dict(state)
|
||||
})
|
||||
await self.publish_state(device_id, "light")
|
||||
|
||||
async def handle_thermostat_set(self, device_id: str, payload: dict):
|
||||
"""Handle SET command for thermostat device."""
|
||||
if device_id not in self.thermostat_states:
|
||||
logger.warning(f"Unknown thermostat device: {device_id}")
|
||||
return
|
||||
|
||||
state = self.thermostat_states[device_id]
|
||||
changes = {}
|
||||
|
||||
if "mode" in payload:
|
||||
new_mode = payload["mode"]
|
||||
if new_mode in ["off", "heat", "auto"]:
|
||||
old_mode = state["mode"]
|
||||
state["mode"] = new_mode
|
||||
if old_mode != new_mode:
|
||||
changes["mode"] = {"old": old_mode, "new": new_mode}
|
||||
logger.info(f"[{device_id}] Mode: {old_mode} -> {new_mode}")
|
||||
else:
|
||||
logger.warning(f"[{device_id}] Invalid mode: {new_mode}")
|
||||
|
||||
if "target" in payload:
|
||||
try:
|
||||
new_target = float(payload["target"])
|
||||
if 5.0 <= new_target <= 30.0:
|
||||
old_target = state["target"]
|
||||
state["target"] = new_target
|
||||
if old_target != new_target:
|
||||
changes["target"] = {"old": old_target, "new": new_target}
|
||||
logger.info(f"[{device_id}] Target: {old_target}°C -> {new_target}°C")
|
||||
else:
|
||||
logger.warning(f"[{device_id}] Target out of range: {new_target}")
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(f"[{device_id}] Invalid target value: {payload['target']}")
|
||||
|
||||
if changes:
|
||||
add_event({
|
||||
"type": "thermostat_updated",
|
||||
"device_id": device_id,
|
||||
"changes": changes,
|
||||
"new_state": dict(state)
|
||||
})
|
||||
await self.publish_state(device_id, "thermostat")
|
||||
|
||||
def apply_temperature_drift(self, device_id: str):
|
||||
"""
|
||||
Simulate temperature drift for thermostat.
|
||||
Max change: ±0.2°C per interval.
|
||||
"""
|
||||
if device_id not in self.thermostat_states:
|
||||
return
|
||||
|
||||
state = self.thermostat_states[device_id]
|
||||
old_current = state["current"]
|
||||
|
||||
if state["mode"] == "off":
|
||||
# Drift towards ambient (18°C)
|
||||
ambient = 18.0
|
||||
diff = ambient - state["current"]
|
||||
else:
|
||||
# Drift towards target
|
||||
diff = state["target"] - state["current"]
|
||||
|
||||
# Apply max ±0.2°C drift
|
||||
if abs(diff) < 0.1:
|
||||
state["current"] = round(state["current"] + diff, 1)
|
||||
elif diff > 0:
|
||||
state["current"] = round(state["current"] + 0.2, 1)
|
||||
else:
|
||||
state["current"] = round(state["current"] - 0.2, 1)
|
||||
|
||||
if old_current != state["current"]:
|
||||
add_event({
|
||||
"type": "temperature_drift",
|
||||
"device_id": device_id,
|
||||
"old_temp": old_current,
|
||||
"new_temp": state["current"],
|
||||
"target": state["target"],
|
||||
"mode": state["mode"]
|
||||
})
|
||||
|
||||
logger.info(f"[{device_id}] Temperature drift: current={state['current']}°C (target={state['target']}°C, mode={state['mode']})")
|
||||
|
||||
async def thermostat_drift_loop(self):
|
||||
"""Background loop for thermostat temperature drift."""
|
||||
while self.running:
|
||||
await asyncio.sleep(DRIFT_INTERVAL)
|
||||
|
||||
for device_id in THERMOSTAT_DEVICES:
|
||||
self.apply_temperature_drift(device_id)
|
||||
await self.publish_state(device_id, "thermostat")
|
||||
|
||||
async def handle_message(self, message):
|
||||
"""Handle incoming MQTT message."""
|
||||
try:
|
||||
# Extract device_id from topic (vendor/{device_id}/set)
|
||||
topic_parts = message.topic.value.split('/')
|
||||
if len(topic_parts) != 3 or topic_parts[0] != "vendor" or topic_parts[2] != "set":
|
||||
logger.warning(f"Unexpected topic format: {message.topic}")
|
||||
return
|
||||
|
||||
device_id = topic_parts[1]
|
||||
payload = json.loads(message.payload.decode())
|
||||
|
||||
add_event({
|
||||
"type": "command_received",
|
||||
"device_id": device_id,
|
||||
"topic": message.topic.value,
|
||||
"payload": payload
|
||||
})
|
||||
|
||||
logger.info(f"[{device_id}] Received SET: {payload}")
|
||||
|
||||
# Determine device type and handle accordingly
|
||||
if device_id in self.light_states:
|
||||
await self.handle_light_set(device_id, payload)
|
||||
elif device_id in self.thermostat_states:
|
||||
await self.handle_thermostat_set(device_id, payload)
|
||||
else:
|
||||
logger.warning(f"Unknown device: {device_id}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Invalid JSON: {e}")
|
||||
add_event({"type": "error", "message": f"Invalid JSON: {e}"})
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling message: {e}")
|
||||
add_event({"type": "error", "message": f"Error: {e}"})
|
||||
|
||||
async def run(self):
|
||||
"""Main simulator loop."""
|
||||
# Generate unique client ID to avoid collisions
|
||||
base_client_id = "device_simulator"
|
||||
client_suffix = os.environ.get("MQTT_CLIENT_ID_SUFFIX") or uuid.uuid4().hex[:6]
|
||||
unique_client_id = f"{base_client_id}-{client_suffix}"
|
||||
|
||||
try:
|
||||
async with Client(
|
||||
hostname=BROKER_HOST,
|
||||
port=BROKER_PORT,
|
||||
identifier=unique_client_id
|
||||
) as client:
|
||||
self.client = client
|
||||
self.connected = True
|
||||
|
||||
add_event({
|
||||
"type": "simulator_connected",
|
||||
"broker": f"{BROKER_HOST}:{BROKER_PORT}",
|
||||
"client_id": unique_client_id
|
||||
})
|
||||
|
||||
logger.info(f"✅ Connected to MQTT broker {BROKER_HOST}:{BROKER_PORT}")
|
||||
|
||||
# Publish initial states
|
||||
for device_id in LIGHT_DEVICES:
|
||||
await self.publish_state(device_id, "light")
|
||||
logger.info(f"💡 Light simulator started: {device_id}")
|
||||
|
||||
for device_id in THERMOSTAT_DEVICES:
|
||||
await self.publish_state(device_id, "thermostat")
|
||||
logger.info(f"🌡️ Thermostat simulator started: {device_id}")
|
||||
|
||||
add_event({
|
||||
"type": "devices_initialized",
|
||||
"lights": LIGHT_DEVICES,
|
||||
"thermostats": THERMOSTAT_DEVICES
|
||||
})
|
||||
|
||||
# Subscribe to all SET topics
|
||||
all_devices = LIGHT_DEVICES + THERMOSTAT_DEVICES
|
||||
for device_id in all_devices:
|
||||
set_topic = f"vendor/{device_id}/set"
|
||||
await client.subscribe(set_topic, qos=1)
|
||||
logger.info(f"👂 Subscribed to {set_topic}")
|
||||
|
||||
add_event({
|
||||
"type": "subscriptions_complete",
|
||||
"topics": [f"vendor/{d}/set" for d in all_devices]
|
||||
})
|
||||
|
||||
# Start thermostat drift loop
|
||||
self.drift_task = asyncio.create_task(self.thermostat_drift_loop())
|
||||
|
||||
# Listen for messages
|
||||
async for message in client.messages:
|
||||
await self.handle_message(message)
|
||||
|
||||
# Cancel drift loop on disconnect
|
||||
if self.drift_task:
|
||||
self.drift_task.cancel()
|
||||
|
||||
except MqttError as e:
|
||||
logger.error(f"❌ MQTT Error: {e}")
|
||||
add_event({"type": "error", "message": f"MQTT Error: {e}"})
|
||||
self.connected = False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error: {e}")
|
||||
add_event({"type": "error", "message": f"Error: {e}"})
|
||||
self.connected = False
|
||||
finally:
|
||||
self.running = False
|
||||
self.connected = False
|
||||
if self.drift_task:
|
||||
self.drift_task.cancel()
|
||||
add_event({"type": "simulator_stopped"})
|
||||
logger.info("👋 Simulator stopped")
|
||||
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="Device Simulator",
|
||||
description="Web interface for device simulator with real-time event streaming",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# Setup templates
|
||||
templates_dir = Path(__file__).parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(templates_dir))
|
||||
|
||||
# Global simulator instance
|
||||
simulator = DeviceSimulator()
|
||||
simulator_task = None
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Start simulator on application startup."""
|
||||
global simulator_task
|
||||
simulator_task = asyncio.create_task(simulator.run())
|
||||
add_event({"type": "app_started", "message": "Simulator web app started"})
|
||||
logger.info("🚀 Simulator web app started")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""Stop simulator on application shutdown."""
|
||||
global simulator_task
|
||||
simulator.running = False
|
||||
if simulator_task:
|
||||
simulator_task.cancel()
|
||||
try:
|
||||
await simulator_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
add_event({"type": "app_stopped", "message": "Simulator web app stopped"})
|
||||
logger.info("🛑 Simulator web app stopped")
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
"""Render simulator dashboard."""
|
||||
return templates.TemplateResponse("index.html", {
|
||||
"request": request,
|
||||
"broker": f"{BROKER_HOST}:{BROKER_PORT}",
|
||||
"light_devices": LIGHT_DEVICES,
|
||||
"thermostat_devices": THERMOSTAT_DEVICES,
|
||||
"connected": simulator.connected
|
||||
})
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"simulator_running": simulator.running,
|
||||
"mqtt_connected": simulator.connected
|
||||
}
|
||||
|
||||
|
||||
@app.get("/status")
|
||||
async def status():
|
||||
"""Get current simulator status."""
|
||||
return {
|
||||
"connected": simulator.connected,
|
||||
"running": simulator.running,
|
||||
"light_states": simulator.light_states,
|
||||
"thermostat_states": simulator.thermostat_states,
|
||||
"broker": f"{BROKER_HOST}:{BROKER_PORT}"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/events")
|
||||
async def get_events():
|
||||
"""Get recent events."""
|
||||
return {
|
||||
"events": list(event_queue)
|
||||
}
|
||||
|
||||
|
||||
@app.get("/realtime")
|
||||
async def realtime(request: Request):
|
||||
"""Server-Sent Events stream for real-time updates."""
|
||||
async def event_generator() -> AsyncGenerator[str, None]:
|
||||
# Create a queue for this SSE connection
|
||||
q = asyncio.Queue()
|
||||
sse_queues.append(q)
|
||||
|
||||
try:
|
||||
# Send recent events first
|
||||
for event in event_queue:
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
|
||||
# Stream new events
|
||||
while True:
|
||||
# Check if client disconnected
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
try:
|
||||
# Wait for new event with timeout
|
||||
event = await asyncio.wait_for(q.get(), timeout=30.0)
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
# Send heartbeat
|
||||
yield f"event: ping\ndata: {json.dumps({'type': 'ping'})}\n\n"
|
||||
|
||||
finally:
|
||||
# Remove queue on disconnect
|
||||
if q in sse_queues:
|
||||
sse_queues.remove(q)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8003)
|
||||
4
apps/simulator/requirements.txt
Normal file
4
apps/simulator/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
aiomqtt==2.0.1
|
||||
jinja2==3.1.2
|
||||
537
apps/simulator/templates/index.html
Normal file
537
apps/simulator/templates/index.html
Normal file
@@ -0,0 +1,537 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Device Simulator - Monitor</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
color: #ecf0f1;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-indicator.connected {
|
||||
background: #2ecc71;
|
||||
box-shadow: 0 0 10px #2ecc71;
|
||||
}
|
||||
|
||||
.status-indicator.disconnected {
|
||||
background: #e74c3c;
|
||||
box-shadow: 0 0 10px #e74c3c;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 2px solid rgba(255, 255, 255, 0.3);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.device-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.device-item {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.875rem;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.device-item.light {
|
||||
border-left-color: #f39c12;
|
||||
}
|
||||
|
||||
.device-item.thermostat {
|
||||
border-left-color: #3498db;
|
||||
}
|
||||
|
||||
.events-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.event-item.command_received {
|
||||
border-left-color: #9b59b6;
|
||||
}
|
||||
|
||||
.event-item.state_published {
|
||||
border-left-color: #27ae60;
|
||||
}
|
||||
|
||||
.event-item.light_updated {
|
||||
border-left-color: #f39c12;
|
||||
}
|
||||
|
||||
.event-item.thermostat_updated {
|
||||
border-left-color: #3498db;
|
||||
}
|
||||
|
||||
.event-item.temperature_drift {
|
||||
border-left-color: #1abc9c;
|
||||
}
|
||||
|
||||
.event-item.error {
|
||||
border-left-color: #e74c3c;
|
||||
}
|
||||
|
||||
.event-item.simulator_connected,
|
||||
.event-item.devices_initialized,
|
||||
.event-item.subscriptions_complete {
|
||||
border-left-color: #2ecc71;
|
||||
}
|
||||
|
||||
.event-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.event-type {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.event-data {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.875rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: #2980b9;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🔌 Device Simulator Monitor</h1>
|
||||
<p>Real-time monitoring of simulated home automation devices</p>
|
||||
|
||||
<div class="status-bar">
|
||||
<div class="status-item">
|
||||
<div class="status-indicator {% if connected %}connected{% else %}disconnected{% endif %}" id="mqtt-status"></div>
|
||||
<span>MQTT: <span id="mqtt-text">{{ broker }}</span></span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-indicator connected" id="app-status"></div>
|
||||
<span>Simulator: <span id="app-text">Running</span></span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>💡 {{ light_devices|length }} Lights</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>🌡️ {{ thermostat_devices|length }} Thermostats</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<div class="panel">
|
||||
<h2>💡 Light Devices</h2>
|
||||
<div class="device-list">
|
||||
{% for device_id in light_devices %}
|
||||
<div class="device-item light">
|
||||
<strong>{{ device_id }}</strong>
|
||||
<div id="light-{{ device_id }}" style="margin-top: 0.25rem; opacity: 0.8;">
|
||||
Power: off, Brightness: 50
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>🌡️ Thermostat Devices</h2>
|
||||
<div class="device-list">
|
||||
{% for device_id in thermostat_devices %}
|
||||
<div class="device-item thermostat">
|
||||
<strong>{{ device_id }}</strong>
|
||||
<div id="thermo-{{ device_id }}" style="margin-top: 0.25rem; opacity: 0.8;">
|
||||
Mode: auto, Target: 21.0°C, Current: 20.5°C
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel events-panel">
|
||||
<h2>📡 Real-time Events</h2>
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="total-events">0</div>
|
||||
<div class="stat-label">Total Events</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="commands-received">0</div>
|
||||
<div class="stat-label">Commands Received</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="states-published">0</div>
|
||||
<div class="stat-label">States Published</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="primary" onclick="clearEvents()">Clear Events</button>
|
||||
<button class="primary" onclick="toggleAutoScroll()">
|
||||
<span id="autoscroll-text">Auto-scroll: ON</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="event-list" id="event-list">
|
||||
<div class="empty-state">
|
||||
<p>Waiting for events...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let eventSource = null;
|
||||
let autoScroll = true;
|
||||
let stats = {
|
||||
total: 0,
|
||||
commands: 0,
|
||||
states: 0
|
||||
};
|
||||
|
||||
// Connect to SSE
|
||||
function connectSSE() {
|
||||
eventSource = new EventSource('/realtime');
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('SSE connected');
|
||||
updateStatus('app', true, 'Running');
|
||||
};
|
||||
|
||||
eventSource.addEventListener('message', (e) => {
|
||||
const event = JSON.parse(e.data);
|
||||
handleEvent(event);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('ping', (e) => {
|
||||
console.log('Heartbeat received');
|
||||
});
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE error:', error);
|
||||
updateStatus('app', false, 'Disconnected');
|
||||
eventSource.close();
|
||||
|
||||
// Reconnect after 5 seconds
|
||||
setTimeout(connectSSE, 5000);
|
||||
};
|
||||
}
|
||||
|
||||
function handleEvent(event) {
|
||||
// Update stats
|
||||
stats.total++;
|
||||
if (event.type === 'command_received') stats.commands++;
|
||||
if (event.type === 'state_published') stats.states++;
|
||||
|
||||
document.getElementById('total-events').textContent = stats.total;
|
||||
document.getElementById('commands-received').textContent = stats.commands;
|
||||
document.getElementById('states-published').textContent = stats.states;
|
||||
|
||||
// Update device states
|
||||
if (event.type === 'light_updated' && event.new_state) {
|
||||
updateLightState(event.device_id, event.new_state);
|
||||
} else if (event.type === 'thermostat_updated' && event.new_state) {
|
||||
updateThermostatState(event.device_id, event.new_state);
|
||||
} else if (event.type === 'state_published') {
|
||||
if (event.device_type === 'light') {
|
||||
updateLightState(event.device_id, event.payload);
|
||||
} else if (event.device_type === 'thermostat') {
|
||||
updateThermostatState(event.device_id, event.payload);
|
||||
}
|
||||
} else if (event.type === 'simulator_connected') {
|
||||
updateStatus('mqtt', true, event.broker);
|
||||
}
|
||||
|
||||
// Add to event list
|
||||
addEventToList(event);
|
||||
}
|
||||
|
||||
function updateLightState(deviceId, state) {
|
||||
const el = document.getElementById(`light-${deviceId}`);
|
||||
if (el) {
|
||||
el.textContent = `Power: ${state.power}, Brightness: ${state.brightness}`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateThermostatState(deviceId, state) {
|
||||
const el = document.getElementById(`thermo-${deviceId}`);
|
||||
if (el) {
|
||||
el.textContent = `Mode: ${state.mode}, Target: ${state.target}°C, Current: ${state.current}°C`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(type, connected, text) {
|
||||
const indicator = document.getElementById(`${type}-status`);
|
||||
const textEl = document.getElementById(`${type}-text`);
|
||||
|
||||
if (indicator) {
|
||||
indicator.className = `status-indicator ${connected ? 'connected' : 'disconnected'}`;
|
||||
}
|
||||
if (textEl) {
|
||||
textEl.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
function addEventToList(event) {
|
||||
const eventList = document.getElementById('event-list');
|
||||
|
||||
// Remove empty state
|
||||
const emptyState = eventList.querySelector('.empty-state');
|
||||
if (emptyState) {
|
||||
emptyState.remove();
|
||||
}
|
||||
|
||||
const eventItem = document.createElement('div');
|
||||
eventItem.className = `event-item ${event.type}`;
|
||||
|
||||
const time = new Date(event.timestamp).toLocaleTimeString('de-DE');
|
||||
const eventData = { ...event };
|
||||
delete eventData.timestamp;
|
||||
|
||||
eventItem.innerHTML = `
|
||||
<div class="event-header">
|
||||
<span class="event-type">${event.type}</span>
|
||||
<span class="event-time">${time}</span>
|
||||
</div>
|
||||
<div class="event-data">${JSON.stringify(eventData, null, 2)}</div>
|
||||
`;
|
||||
|
||||
eventList.insertBefore(eventItem, eventList.firstChild);
|
||||
|
||||
// Keep only last 50 events
|
||||
while (eventList.children.length > 50) {
|
||||
eventList.removeChild(eventList.lastChild);
|
||||
}
|
||||
|
||||
// Auto-scroll to top
|
||||
if (autoScroll) {
|
||||
eventList.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function clearEvents() {
|
||||
const eventList = document.getElementById('event-list');
|
||||
eventList.innerHTML = '<div class="empty-state"><p>Events cleared</p></div>';
|
||||
stats = { total: 0, commands: 0, states: 0 };
|
||||
document.getElementById('total-events').textContent = '0';
|
||||
document.getElementById('commands-received').textContent = '0';
|
||||
document.getElementById('states-published').textContent = '0';
|
||||
}
|
||||
|
||||
function toggleAutoScroll() {
|
||||
autoScroll = !autoScroll;
|
||||
document.getElementById('autoscroll-text').textContent = `Auto-scroll: ${autoScroll ? 'ON' : 'OFF'}`;
|
||||
}
|
||||
|
||||
// Initialize
|
||||
connectSSE();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
49
apps/ui/Dockerfile
Normal file
49
apps/ui/Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
||||
# UI Service Dockerfile
|
||||
# FastAPI + Jinja2 + HTMX Dashboard
|
||||
|
||||
FROM python:3.14-alpine
|
||||
|
||||
# Prevent Python from writing .pyc files and enable unbuffered output
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
UI_PORT=8002 \
|
||||
API_BASE=http://api:8001 \
|
||||
BASE_PATH=""
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 10001 -S app && \
|
||||
adduser -u 10001 -S app -G app
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
curl \
|
||||
gcc \
|
||||
musl-dev \
|
||||
linux-headers
|
||||
|
||||
# Install Python dependencies
|
||||
COPY apps/ui/requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY apps/__init__.py /app/apps/__init__.py
|
||||
COPY apps/ui/ /app/apps/ui/
|
||||
|
||||
# Change ownership to app user
|
||||
RUN chown -R app:app /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER app
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:${UI_PORT}/health || exit 1
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8002
|
||||
|
||||
# Run application
|
||||
CMD ["python", "-m", "uvicorn", "apps.ui.main:app", "--host", "0.0.0.0", "--port", "8002"]
|
||||
@@ -27,6 +27,38 @@ poetry run uvicorn apps.ui.main:app --reload --port 8002
|
||||
poetry run python -m apps.ui.main
|
||||
```
|
||||
|
||||
### Docker Container
|
||||
|
||||
#### Build Image
|
||||
|
||||
```bash
|
||||
docker build -t ui:dev -f apps/ui/Dockerfile .
|
||||
```
|
||||
|
||||
#### Run Container
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8002:8002 \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
-e UI_PORT=8002 \
|
||||
-e API_BASE=http://host.docker.internal:8001 \
|
||||
-e BASE_PATH=/ \
|
||||
ui:dev
|
||||
```
|
||||
|
||||
**Note for finch/nerdctl users:**
|
||||
- finch binds ports to `127.0.0.1` by default (not `0.0.0.0`)
|
||||
- Use `--add-host=host.docker.internal:host-gateway` to allow container-to-host communication
|
||||
- Set `API_BASE=http://host.docker.internal:8001` to reach the API container
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `UI_PORT` | `8002` | Port for UI server |
|
||||
| `API_BASE` | `http://localhost:8001` | Base URL for API service |
|
||||
| `BASE_PATH` | `/` | Base path for routing |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
|
||||
@@ -8,12 +8,12 @@ import httpx
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fetch_devices(api_base: str = "http://localhost:8001") -> list[dict]:
|
||||
def fetch_devices(api_base: str) -> list[dict]:
|
||||
"""
|
||||
Fetch devices from the API Gateway.
|
||||
|
||||
Args:
|
||||
api_base: Base URL of the API Gateway (default: http://localhost:8001)
|
||||
api_base: Base URL of the API Gateway (e.g., "http://localhost:8001" or "http://api:8001")
|
||||
|
||||
Returns:
|
||||
List of device dictionaries. Each device contains at least:
|
||||
@@ -56,12 +56,12 @@ def fetch_devices(api_base: str = "http://localhost:8001") -> list[dict]:
|
||||
return []
|
||||
|
||||
|
||||
def fetch_layout(api_base: str = "http://localhost:8001") -> dict:
|
||||
def fetch_layout(api_base: str) -> dict:
|
||||
"""
|
||||
Fetch UI layout from the API Gateway.
|
||||
|
||||
Args:
|
||||
api_base: Base URL of the API Gateway (default: http://localhost:8001)
|
||||
api_base: Base URL of the API Gateway (e.g., "http://localhost:8001" or "http://api:8001")
|
||||
|
||||
Returns:
|
||||
Layout dictionary with structure:
|
||||
|
||||
123
apps/ui/main.py
123
apps/ui/main.py
@@ -1,10 +1,11 @@
|
||||
"""UI main entry point."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
@@ -12,11 +13,30 @@ from apps.ui.api_client import fetch_devices, fetch_layout
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize FastAPI app
|
||||
# Read configuration from environment variables
|
||||
API_BASE = os.getenv("API_BASE", "http://localhost:8001")
|
||||
BASE_PATH = os.getenv("BASE_PATH", "") # e.g., "/ui" for reverse proxy
|
||||
|
||||
print(f"UI using API_BASE: {API_BASE}")
|
||||
print(f"UI using BASE_PATH: {BASE_PATH}")
|
||||
|
||||
def api_url(path: str) -> str:
|
||||
"""Helper function to construct API URLs.
|
||||
|
||||
Args:
|
||||
path: API path (e.g., "/devices")
|
||||
|
||||
Returns:
|
||||
Full API URL
|
||||
"""
|
||||
return f"{API_BASE}{path}"
|
||||
|
||||
# Initialize FastAPI app with optional root_path for reverse proxy
|
||||
app = FastAPI(
|
||||
title="Home Automation UI",
|
||||
description="User interface for home automation system",
|
||||
version="0.1.0"
|
||||
version="0.1.0",
|
||||
root_path=BASE_PATH
|
||||
)
|
||||
|
||||
# Setup Jinja2 templates
|
||||
@@ -29,6 +49,21 @@ static_dir.mkdir(exist_ok=True)
|
||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> JSONResponse:
|
||||
"""Health check endpoint for Kubernetes/Docker.
|
||||
|
||||
Returns:
|
||||
JSONResponse: Health status
|
||||
"""
|
||||
return JSONResponse({
|
||||
"status": "ok",
|
||||
"service": "ui",
|
||||
"api_base": API_BASE,
|
||||
"base_path": BASE_PATH
|
||||
})
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request) -> HTMLResponse:
|
||||
"""Redirect to dashboard.
|
||||
@@ -39,7 +74,75 @@ async def index(request: Request) -> HTMLResponse:
|
||||
Returns:
|
||||
HTMLResponse: Rendered dashboard
|
||||
"""
|
||||
return await dashboard(request)
|
||||
return await rooms(request)
|
||||
|
||||
|
||||
@app.get("/rooms", response_class=HTMLResponse)
|
||||
async def rooms(request: Request) -> HTMLResponse:
|
||||
"""Render the rooms overview page.
|
||||
|
||||
Args:
|
||||
request: The FastAPI request object
|
||||
|
||||
Returns:
|
||||
HTMLResponse: Rendered rooms template
|
||||
"""
|
||||
return templates.TemplateResponse("rooms.html", {
|
||||
"request": request,
|
||||
"api_base": API_BASE
|
||||
})
|
||||
|
||||
|
||||
@app.get("/room/{room_name}", response_class=HTMLResponse)
|
||||
async def room_detail(request: Request, room_name: str) -> HTMLResponse:
|
||||
"""Render the room detail page with devices.
|
||||
|
||||
Args:
|
||||
request: The FastAPI request object
|
||||
room_name: Name of the room to display
|
||||
|
||||
Returns:
|
||||
HTMLResponse: Rendered room template
|
||||
"""
|
||||
return templates.TemplateResponse("room.html", {
|
||||
"request": request,
|
||||
"api_base": API_BASE,
|
||||
"room_name": room_name
|
||||
})
|
||||
|
||||
|
||||
@app.get("/device/{device_id}", response_class=HTMLResponse)
|
||||
async def device_detail(request: Request, device_id: str) -> HTMLResponse:
|
||||
"""Render the device detail page with controls.
|
||||
|
||||
Args:
|
||||
request: The FastAPI request object
|
||||
device_id: ID of the device to display
|
||||
|
||||
Returns:
|
||||
HTMLResponse: Rendered device template
|
||||
"""
|
||||
return templates.TemplateResponse("device.html", {
|
||||
"request": request,
|
||||
"api_base": API_BASE,
|
||||
"device_id": device_id
|
||||
})
|
||||
|
||||
|
||||
@app.get("/garage", response_class=HTMLResponse)
|
||||
async def garage(request: Request) -> HTMLResponse:
|
||||
"""Render the garage page with car outlet devices.
|
||||
|
||||
Args:
|
||||
request: The FastAPI request object
|
||||
|
||||
Returns:
|
||||
HTMLResponse: Rendered garage template
|
||||
"""
|
||||
return templates.TemplateResponse("garage.html", {
|
||||
"request": request,
|
||||
"api_base": API_BASE
|
||||
})
|
||||
|
||||
|
||||
@app.get("/dashboard", response_class=HTMLResponse)
|
||||
@@ -53,11 +156,11 @@ async def dashboard(request: Request) -> HTMLResponse:
|
||||
HTMLResponse: Rendered dashboard template
|
||||
"""
|
||||
try:
|
||||
# Load layout from API
|
||||
layout_data = fetch_layout()
|
||||
# Load layout from API (use configured API_BASE)
|
||||
layout_data = fetch_layout(API_BASE)
|
||||
|
||||
# Fetch devices from API (now includes features)
|
||||
api_devices = fetch_devices()
|
||||
api_devices = fetch_devices(API_BASE)
|
||||
|
||||
# Create device lookup by device_id
|
||||
device_map = {d["device_id"]: d for d in api_devices}
|
||||
@@ -98,7 +201,8 @@ async def dashboard(request: Request) -> HTMLResponse:
|
||||
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
"rooms": rooms
|
||||
"rooms": rooms,
|
||||
"api_base": API_BASE # Pass API_BASE to template
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -106,7 +210,8 @@ async def dashboard(request: Request) -> HTMLResponse:
|
||||
# Fallback to empty dashboard
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
"rooms": []
|
||||
"rooms": [],
|
||||
"api_base": API_BASE # Pass API_BASE even on error
|
||||
})
|
||||
|
||||
|
||||
|
||||
4
apps/ui/requirements.txt
Normal file
4
apps/ui/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
jinja2==3.1.2
|
||||
httpx==0.25.1
|
||||
301
apps/ui/static/README.md
Normal file
301
apps/ui/static/README.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# Home Automation API Client
|
||||
|
||||
Wiederverwendbare JavaScript-API-Client-Bibliothek für das Home Automation UI.
|
||||
|
||||
## Installation
|
||||
|
||||
Füge die folgenden Script-Tags in deine HTML-Seiten ein:
|
||||
|
||||
```html
|
||||
<script src="/static/types.js"></script>
|
||||
<script src="/static/api-client.js"></script>
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Der API-Client nutzt `window.API_BASE`, das vom Backend gesetzt wird:
|
||||
|
||||
```javascript
|
||||
window.API_BASE = '{{ api_base }}'; // Jinja2 template
|
||||
```
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Globale Instanz
|
||||
|
||||
Der API-Client erstellt automatisch eine globale Instanz `window.apiClient`:
|
||||
|
||||
```javascript
|
||||
// Layout abrufen
|
||||
const layout = await window.apiClient.getLayout();
|
||||
|
||||
// Geräte abrufen
|
||||
const devices = await window.apiClient.getDevices();
|
||||
|
||||
// Gerätestatus abrufen
|
||||
const state = await window.apiClient.getDeviceState('kitchen_light');
|
||||
|
||||
// Gerätesteuerung
|
||||
await window.apiClient.setDeviceState('kitchen_light', 'light', {
|
||||
power: true,
|
||||
brightness: 80
|
||||
});
|
||||
```
|
||||
|
||||
### Verfügbare Methoden
|
||||
|
||||
#### `getLayout(): Promise<Layout>`
|
||||
Lädt die Layout-Daten (Räume und ihre Geräte).
|
||||
|
||||
```javascript
|
||||
const layout = await window.apiClient.getLayout();
|
||||
// { rooms: [{name: "Küche", devices: ["kitchen_light", ...]}, ...] }
|
||||
```
|
||||
|
||||
#### `getDevices(): Promise<Device[]>`
|
||||
Lädt alle Geräte mit ihren Features.
|
||||
|
||||
```javascript
|
||||
const devices = await window.apiClient.getDevices();
|
||||
// [{device_id: "...", name: "...", type: "light", features: {...}}, ...]
|
||||
```
|
||||
|
||||
#### `getDeviceState(deviceId): Promise<DeviceState>`
|
||||
Lädt den aktuellen Status eines Geräts.
|
||||
|
||||
```javascript
|
||||
const state = await window.apiClient.getDeviceState('kitchen_light');
|
||||
// {power: true, brightness: 80, ...}
|
||||
```
|
||||
|
||||
#### `getAllStates(): Promise<Object>`
|
||||
Lädt alle Gerätestatus auf einmal.
|
||||
|
||||
```javascript
|
||||
const states = await window.apiClient.getAllStates();
|
||||
// {"kitchen_light": {power: true, ...}, "thermostat_1": {...}, ...}
|
||||
```
|
||||
|
||||
#### `setDeviceState(deviceId, type, payload): Promise<void>`
|
||||
Sendet einen Befehl an ein Gerät.
|
||||
|
||||
```javascript
|
||||
// Licht einschalten
|
||||
await window.apiClient.setDeviceState('kitchen_light', 'light', {
|
||||
power: true,
|
||||
brightness: 80
|
||||
});
|
||||
|
||||
// Thermostat einstellen
|
||||
await window.apiClient.setDeviceState('thermostat_1', 'thermostat', {
|
||||
target_temp: 22.5
|
||||
});
|
||||
|
||||
// Rollladen steuern
|
||||
await window.apiClient.setDeviceState('cover_1', 'cover', {
|
||||
position: 50
|
||||
});
|
||||
```
|
||||
|
||||
#### `getDeviceRoom(deviceId): Promise<{room: string}>`
|
||||
Ermittelt den Raum eines Geräts.
|
||||
|
||||
```javascript
|
||||
const { room } = await window.apiClient.getDeviceRoom('kitchen_light');
|
||||
// {room: "Küche"}
|
||||
```
|
||||
|
||||
#### `getScenes(): Promise<Scene[]>`
|
||||
Lädt alle verfügbaren Szenen.
|
||||
|
||||
```javascript
|
||||
const scenes = await window.apiClient.getScenes();
|
||||
```
|
||||
|
||||
#### `activateScene(sceneId): Promise<void>`
|
||||
Aktiviert eine Szene.
|
||||
|
||||
```javascript
|
||||
await window.apiClient.activateScene('evening');
|
||||
```
|
||||
|
||||
### Realtime-Updates (SSE)
|
||||
|
||||
#### `connectRealtime(onEvent, onError): EventSource`
|
||||
Verbindet sich mit dem SSE-Stream für Live-Updates.
|
||||
|
||||
```javascript
|
||||
window.apiClient.connectRealtime(
|
||||
(event) => {
|
||||
console.log('Update:', event.device_id, event.state);
|
||||
// event = {device_id: "...", type: "state", state: {...}}
|
||||
},
|
||||
(error) => {
|
||||
console.error('Connection error:', error);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
#### `onDeviceUpdate(deviceId, callback): Function`
|
||||
Registriert einen Listener für spezifische Geräte-Updates.
|
||||
|
||||
```javascript
|
||||
// Für ein bestimmtes Gerät
|
||||
const unsubscribe = window.apiClient.onDeviceUpdate('kitchen_light', (event) => {
|
||||
console.log('Kitchen light changed:', event.state);
|
||||
updateUI(event.state);
|
||||
});
|
||||
|
||||
// Für alle Geräte
|
||||
const unsubscribeAll = window.apiClient.onDeviceUpdate(null, (event) => {
|
||||
console.log('Any device changed:', event.device_id, event.state);
|
||||
});
|
||||
|
||||
// Später: Listener entfernen
|
||||
unsubscribe();
|
||||
```
|
||||
|
||||
#### `disconnectRealtime(): void`
|
||||
Trennt die SSE-Verbindung und entfernt alle Listener.
|
||||
|
||||
```javascript
|
||||
window.apiClient.disconnectRealtime();
|
||||
```
|
||||
|
||||
### Helper-Methoden
|
||||
|
||||
#### `findDevice(devices, deviceId): Device|null`
|
||||
Findet ein Gerät in einem Array.
|
||||
|
||||
```javascript
|
||||
const devices = await window.apiClient.getDevices();
|
||||
const device = window.apiClient.findDevice(devices, 'kitchen_light');
|
||||
```
|
||||
|
||||
#### `findRoom(layout, roomName): Room|null`
|
||||
Findet einen Raum im Layout.
|
||||
|
||||
```javascript
|
||||
const layout = await window.apiClient.getLayout();
|
||||
const room = window.apiClient.findRoom(layout, 'Küche');
|
||||
```
|
||||
|
||||
#### `getDevicesForRoom(layout, devices, roomName): Device[]`
|
||||
Gibt alle Geräte eines Raums zurück.
|
||||
|
||||
```javascript
|
||||
const layout = await window.apiClient.getLayout();
|
||||
const devices = await window.apiClient.getDevices();
|
||||
const kitchenDevices = window.apiClient.getDevicesForRoom(layout, devices, 'Küche');
|
||||
```
|
||||
|
||||
#### `api(path): string`
|
||||
Konstruiert eine vollständige API-URL.
|
||||
|
||||
```javascript
|
||||
const url = window.apiClient.api('/devices');
|
||||
// "http://172.19.1.11:8001/devices"
|
||||
```
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
Die globale `api()` Funktion ist weiterhin verfügbar:
|
||||
|
||||
```javascript
|
||||
function api(url) {
|
||||
return window.apiClient.api(url);
|
||||
}
|
||||
```
|
||||
|
||||
## Typen (JSDoc)
|
||||
|
||||
Die Datei `types.js` enthält JSDoc-Definitionen für alle API-Typen:
|
||||
|
||||
- `Room` - Raum mit Geräten
|
||||
- `Layout` - Layout-Struktur
|
||||
- `Device` - Gerätedaten
|
||||
- `DeviceFeatures` - Geräte-Features
|
||||
- `DeviceState` - Gerätestatus (Light, Thermostat, Contact, etc.)
|
||||
- `RealtimeEvent` - SSE-Event-Format
|
||||
- `Scene` - Szenen-Definition
|
||||
- `*Payload` - Command-Payloads für verschiedene Gerätetypen
|
||||
|
||||
Diese ermöglichen IDE-Autocomplete und Type-Checking in modernen Editoren (VS Code, WebStorm).
|
||||
|
||||
## Beispiel: Vollständige Seite
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>My Page</title>
|
||||
<script src="/static/types.js"></script>
|
||||
<script src="/static/api-client.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="status"></div>
|
||||
<button id="toggle">Toggle Light</button>
|
||||
|
||||
<script>
|
||||
window.API_BASE = 'http://172.19.1.11:8001';
|
||||
const deviceId = 'kitchen_light';
|
||||
|
||||
async function init() {
|
||||
// Load initial state
|
||||
const state = await window.apiClient.getDeviceState(deviceId);
|
||||
updateUI(state);
|
||||
|
||||
// Listen for updates
|
||||
window.apiClient.onDeviceUpdate(deviceId, (event) => {
|
||||
updateUI(event.state);
|
||||
});
|
||||
|
||||
// Connect to realtime
|
||||
window.apiClient.connectRealtime((event) => {
|
||||
console.log('Event:', event);
|
||||
});
|
||||
|
||||
// Handle button clicks
|
||||
document.getElementById('toggle').onclick = async () => {
|
||||
const currentState = await window.apiClient.getDeviceState(deviceId);
|
||||
await window.apiClient.setDeviceState(deviceId, 'light', {
|
||||
power: !currentState.power
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function updateUI(state) {
|
||||
document.getElementById('status').textContent =
|
||||
state.power ? 'ON' : 'OFF';
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Alle API-Methoden werfen Exceptions bei Fehlern:
|
||||
|
||||
```javascript
|
||||
try {
|
||||
const state = await window.apiClient.getDeviceState('invalid_id');
|
||||
} catch (error) {
|
||||
console.error('API error:', error);
|
||||
showErrorMessage(error.message);
|
||||
}
|
||||
```
|
||||
|
||||
## Auto-Reconnect
|
||||
|
||||
Der SSE-Client versucht automatisch, nach 5 Sekunden wieder zu verbinden, wenn die Verbindung abbricht.
|
||||
|
||||
## Verwendete Technologien
|
||||
|
||||
- **Fetch API** - Für HTTP-Requests
|
||||
- **EventSource** - Für Server-Sent Events
|
||||
- **JSDoc** - Für Type Definitions
|
||||
- **ES6+** - Modern JavaScript (Class, async/await, etc.)
|
||||
288
apps/ui/static/api-client.js
Normal file
288
apps/ui/static/api-client.js
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Home Automation API Client
|
||||
*
|
||||
* Provides a unified interface to interact with the backend API.
|
||||
* All functions use the global window.API_BASE configuration.
|
||||
*/
|
||||
|
||||
class HomeAutomationClient {
|
||||
/**
|
||||
* Get layout info for a specific device
|
||||
* @param {string} deviceId - Device ID
|
||||
* @returns {Promise<object>} Layout info
|
||||
*/
|
||||
async getDeviceLayout(deviceId) {
|
||||
return await this.fetch(this.api(`/devices/${deviceId}/layout`));
|
||||
}
|
||||
constructor() {
|
||||
this.eventSource = null;
|
||||
this.eventListeners = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to construct full API URLs
|
||||
* Reads window.API_BASE at runtime to support dynamic configuration
|
||||
* @param {string} path - API path (e.g., '/devices')
|
||||
* @returns {string} Full URL
|
||||
*/
|
||||
api(path) {
|
||||
const baseUrl = window.API_BASE || '';
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic fetch wrapper with error handling
|
||||
* @param {string} url - URL to fetch
|
||||
* @param {object} options - Fetch options
|
||||
* @returns {Promise<any>} Response data
|
||||
*/
|
||||
async fetch(url, options = {}) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API request failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get layout data (rooms and their devices)
|
||||
* @returns {Promise<{rooms: Array<{name: string, devices: string[]}>}>}
|
||||
*/
|
||||
async getLayout() {
|
||||
return await this.fetch(this.api('/layout'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all devices with their features
|
||||
* @returns {Promise<Array<{device_id: string, name: string, type: string, features: object}>>}
|
||||
*/
|
||||
async getDevices() {
|
||||
return await this.fetch(this.api('/devices'));
|
||||
}
|
||||
|
||||
async getDevice(deviceId) {
|
||||
return await this.fetch(this.api(`/devices/${deviceId}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state of a specific device
|
||||
* @param {string} deviceId - Device ID
|
||||
* @returns {Promise<object>} Device state
|
||||
*/
|
||||
async getDeviceState(deviceId) {
|
||||
return await this.fetch(this.api(`/devices/${deviceId}/state`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all device states at once
|
||||
* @returns {Promise<object>} Map of device_id to state
|
||||
*/
|
||||
async getAllStates() {
|
||||
return await this.fetch(this.api('/devices/states'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command to a device
|
||||
* @param {string} deviceId - Device ID
|
||||
* @param {string} type - Device type (light, thermostat, etc.)
|
||||
* @param {object} payload - Command payload
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async setDeviceState(deviceId, type, payload) {
|
||||
await fetch(this.api(`/devices/${deviceId}/set`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ type, payload })
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get room information for a device
|
||||
* @param {string} deviceId - Device ID
|
||||
* @returns {Promise<{room: string}>}
|
||||
*/
|
||||
async getDeviceRoom(deviceId) {
|
||||
return await this.fetch(this.api(`/devices/${deviceId}/room`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available scenes
|
||||
* @returns {Promise<Array<{scene_id: string, name: string, devices: object}>>}
|
||||
*/
|
||||
async getScenes() {
|
||||
return await this.fetch(this.api('/scenes'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a scene
|
||||
* @param {string} sceneId - Scene ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async activateScene(sceneId) {
|
||||
await fetch(this.api(`/scenes/${sceneId}/activate`), {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to realtime event stream (SSE)
|
||||
* @param {Function} onEvent - Callback function(event)
|
||||
* @param {Function} onError - Error callback (optional)
|
||||
* @returns {EventSource} EventSource instance
|
||||
*/
|
||||
connectRealtime(onEvent, onError = null) {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
}
|
||||
|
||||
const realtimeUrl = this.api('/realtime');
|
||||
console.log('Connecting to SSE endpoint:', realtimeUrl);
|
||||
this.eventSource = new EventSource(realtimeUrl);
|
||||
|
||||
this.eventSource.onmessage = (event) => {
|
||||
console.log('Raw SSE event received:', event.data);
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('Parsed SSE data:', data);
|
||||
|
||||
// Normalize event format: convert API format to unified format
|
||||
const normalizedEvent = {
|
||||
device_id: data.device_id,
|
||||
type: data.type,
|
||||
state: data.payload || data.state // Support both formats
|
||||
};
|
||||
|
||||
console.log('Normalized SSE event:', normalizedEvent);
|
||||
onEvent(normalizedEvent);
|
||||
|
||||
// Notify all registered listeners
|
||||
this.eventListeners.forEach(listener => {
|
||||
if (!listener.deviceId || listener.deviceId === normalizedEvent.device_id) {
|
||||
listener.callback(normalizedEvent);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to parse SSE event:', error, 'Raw data:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onopen = (event) => {
|
||||
console.log('SSE connection opened:', event);
|
||||
};
|
||||
|
||||
this.eventSource.onerror = (error) => {
|
||||
console.error('SSE connection error:', error);
|
||||
console.log('EventSource readyState:', this.eventSource.readyState);
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
|
||||
// Auto-reconnect after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.connectRealtime(onEvent, onError);
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
return this.eventSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a listener for specific device updates
|
||||
* @param {string|null} deviceId - Device ID or null for all devices
|
||||
* @param {Function} callback - Callback function(event)
|
||||
* @returns {Function} Unsubscribe function
|
||||
*/
|
||||
onDeviceUpdate(deviceId, callback) {
|
||||
const listener = { deviceId, callback };
|
||||
this.eventListeners.push(listener);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const index = this.eventListeners.indexOf(listener);
|
||||
if (index > -1) {
|
||||
this.eventListeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from realtime stream
|
||||
*/
|
||||
disconnectRealtime() {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
this.eventListeners = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get device by ID from devices array
|
||||
* @param {Array} devices - Devices array
|
||||
* @param {string} deviceId - Device ID to find
|
||||
* @returns {object|null} Device object or null
|
||||
*/
|
||||
findDevice(devices, deviceId) {
|
||||
return devices.find(d => d.device_id === deviceId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get room by name from layout
|
||||
* @param {object} layout - Layout object
|
||||
* @param {string} roomName - Room name to find
|
||||
* @returns {object|null} Room object or null
|
||||
*/
|
||||
findRoom(layout, roomName) {
|
||||
return layout.rooms.find(r => r.name === roomName) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get devices for a specific room
|
||||
* @param {object} layout - Layout object
|
||||
* @param {Array} devices - Devices array
|
||||
* @param {string} roomName - Room name
|
||||
* @returns {Array} Array of device objects
|
||||
*/
|
||||
getDevicesForRoom(layout, devices, roomName) {
|
||||
const room = this.findRoom(layout, roomName);
|
||||
if (!room) return [];
|
||||
|
||||
const deviceMap = {};
|
||||
devices.forEach(d => deviceMap[d.device_id] = d);
|
||||
|
||||
// Extract device IDs from room.devices (they are objects with device_id property)
|
||||
const deviceIds = room.devices.map(d => d.device_id || d);
|
||||
|
||||
return deviceIds
|
||||
.map(id => deviceMap[id])
|
||||
.filter(d => d != null);
|
||||
}
|
||||
}
|
||||
|
||||
// Create global instance
|
||||
window.apiClient = new HomeAutomationClient();
|
||||
|
||||
/**
|
||||
* Convenience function for backward compatibility
|
||||
*/
|
||||
function api(url) {
|
||||
return window.apiClient.api(url);
|
||||
}
|
||||
13
apps/ui/static/favicon.svg
Normal file
13
apps/ui/static/favicon.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<!-- Roof -->
|
||||
<path d="M50 10 L90 45 L85 45 L85 50 L15 50 L15 45 L10 45 Z" fill="#667eea" stroke="#4c51bf" stroke-width="2" stroke-linejoin="round"/>
|
||||
<!-- House body -->
|
||||
<rect x="15" y="45" width="70" height="45" fill="#764ba2" stroke="#4c51bf" stroke-width="2"/>
|
||||
<!-- Door -->
|
||||
<rect x="35" y="60" width="15" height="30" fill="#4c51bf" rx="2"/>
|
||||
<!-- Window -->
|
||||
<rect x="60" y="60" width="20" height="15" fill="#fbbf24" stroke="#f59e0b" stroke-width="1"/>
|
||||
<!-- Window panes -->
|
||||
<line x1="70" y1="60" x2="70" y2="75" stroke="#f59e0b" stroke-width="1"/>
|
||||
<line x1="60" y1="67.5" x2="80" y2="67.5" stroke="#f59e0b" stroke-width="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 721 B |
166
apps/ui/static/types.js
Normal file
166
apps/ui/static/types.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Type definitions for Home Automation API
|
||||
*
|
||||
* These are JSDoc type definitions that provide IDE autocomplete
|
||||
* and type checking in JavaScript files.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Room
|
||||
* @property {string} name - Room name (e.g., "Küche", "Wohnzimmer")
|
||||
* @property {Array<{device_id: string, title: string, icon: string, rank: number}>} devices - Array of device objects in this room
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Layout
|
||||
* @property {Room[]} rooms - Array of rooms with their devices
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DeviceFeatures
|
||||
* @property {boolean} [dimmable] - Light: supports brightness control
|
||||
* @property {boolean} [color_hsb] - Light: supports HSB color control
|
||||
* @property {boolean} [color_temp] - Light: supports color temperature
|
||||
* @property {number} [min_temp] - Thermostat: minimum temperature
|
||||
* @property {number} [max_temp] - Thermostat: maximum temperature
|
||||
* @property {number} [step] - Thermostat: temperature step size
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Device
|
||||
* @property {string} device_id - Unique device identifier
|
||||
* @property {string} name - Human-readable device name
|
||||
* @property {string} type - Device type: light, thermostat, contact, temp_humidity_sensor, relay, outlet, cover
|
||||
* @property {DeviceFeatures} features - Device-specific features
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} LightState
|
||||
* @property {boolean} power - On/off state
|
||||
* @property {number} [brightness] - Brightness 0-100 (if dimmable)
|
||||
* @property {Object} [color_hsb] - HSB color (if color_hsb)
|
||||
* @property {number} color_hsb.hue - Hue 0-360
|
||||
* @property {number} color_hsb.saturation - Saturation 0-100
|
||||
* @property {number} color_hsb.brightness - Brightness 0-100
|
||||
* @property {number} [color_temp] - Color temperature in mireds (if color_temp)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ThermostatState
|
||||
* @property {number} current_temp - Current temperature in °C
|
||||
* @property {number} target_temp - Target temperature in °C
|
||||
* @property {string} mode - Operating mode: heat, cool, auto, off
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ContactState
|
||||
* @property {boolean} open - true if open, false if closed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TempHumidityState
|
||||
* @property {number} temperature - Temperature in °C
|
||||
* @property {number} humidity - Relative humidity 0-100%
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RelayState
|
||||
* @property {boolean} power - On/off state
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} OutletState
|
||||
* @property {boolean} power - On/off state
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CoverState
|
||||
* @property {number} position - Position 0-100 (0=closed, 100=open)
|
||||
* @property {string} state - State: open, closed, opening, closing, stopped
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {LightState|ThermostatState|ContactState|TempHumidityState|RelayState|OutletState|CoverState} DeviceState
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RealtimeEvent
|
||||
* @property {string} device_id - Device that changed
|
||||
* @property {string} type - Device type
|
||||
* @property {DeviceState} state - New device state
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Scene
|
||||
* @property {string} scene_id - Unique scene identifier
|
||||
* @property {string} name - Human-readable scene name
|
||||
* @property {Object<string, Object>} devices - Map of device_id to desired state
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} LightPayload
|
||||
* @property {boolean} [power] - Turn on/off
|
||||
* @property {number} [brightness] - Set brightness 0-100
|
||||
* @property {Object} [color_hsb] - Set HSB color
|
||||
* @property {number} color_hsb.hue - Hue 0-360
|
||||
* @property {number} color_hsb.saturation - Saturation 0-100
|
||||
* @property {number} color_hsb.brightness - Brightness 0-100
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ThermostatPayload
|
||||
* @property {number} [target_temp] - Set target temperature
|
||||
* @property {string} [mode] - Set mode: heat, cool, auto, off
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RelayPayload
|
||||
* @property {boolean} power - Turn on/off
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} OutletPayload
|
||||
* @property {boolean} power - Turn on/off
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CoverPayload
|
||||
* @property {number} [position] - Set position 0-100
|
||||
* @property {string} [action] - Action: open, close, stop
|
||||
*/
|
||||
|
||||
/**
|
||||
* Example usage:
|
||||
*
|
||||
* // Get layout data
|
||||
* const layout = await window.apiClient.getLayout();
|
||||
* // layout is typed as Layout
|
||||
*
|
||||
* // Get devices
|
||||
* const devices = await window.apiClient.getDevices();
|
||||
* // devices is typed as Device[]
|
||||
*
|
||||
* // Get device state
|
||||
* const state = await window.apiClient.getDeviceState('kitchen_light');
|
||||
* // state is typed as DeviceState
|
||||
*
|
||||
* // Set device state
|
||||
* await window.apiClient.setDeviceState('kitchen_light', 'light', {
|
||||
* power: true,
|
||||
* brightness: 80
|
||||
* });
|
||||
*
|
||||
* // Connect to realtime events
|
||||
* window.apiClient.connectRealtime((event) => {
|
||||
* console.log('Device update:', event.device_id, event.state);
|
||||
* });
|
||||
*
|
||||
* // Listen to specific device
|
||||
* const unsubscribe = window.apiClient.onDeviceUpdate('kitchen_light', (event) => {
|
||||
* console.log('Kitchen light changed:', event.state);
|
||||
* });
|
||||
*
|
||||
* // Later: cleanup
|
||||
* unsubscribe();
|
||||
* window.apiClient.disconnectRealtime();
|
||||
*/
|
||||
File diff suppressed because it is too large
Load Diff
1011
apps/ui/templates/device.html
Normal file
1011
apps/ui/templates/device.html
Normal file
File diff suppressed because it is too large
Load Diff
615
apps/ui/templates/garage.html
Normal file
615
apps/ui/templates/garage.html
Normal file
@@ -0,0 +1,615 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Garage - Home Automation</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.devices-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.devices-container {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.device-section {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.device-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.device-type {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.state-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.state-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.state-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.control-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.control-button.on {
|
||||
background: #34c759;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.control-button.off {
|
||||
background: #e0e0e0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.control-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.control-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.phase-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.phase-section h4 {
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.phase-values {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.phase-value {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.phase-value .value {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.phase-value .unit {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.phase-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(255, 59, 48, 0.9);
|
||||
color: white;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div id="error-container"></div>
|
||||
<div id="loading" class="loading">Lade Geräte...</div>
|
||||
<div id="devices-container" class="devices-container" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// API configuration from backend
|
||||
window.API_BASE = '{{ api_base }}';
|
||||
</script>
|
||||
|
||||
<!-- Load API client AFTER API_BASE is set -->
|
||||
<script src="/static/types.js"></script>
|
||||
<script src="/static/api-client.js"></script>
|
||||
|
||||
<script>
|
||||
// Device IDs for garage devices
|
||||
const GARAGE_DEVICES = [
|
||||
'power_relay_caroutlet',
|
||||
'powermeter_caroutlet'
|
||||
];
|
||||
|
||||
// Device states
|
||||
const deviceStates = {};
|
||||
let devicesData = {};
|
||||
|
||||
async function loadGarageDevices() {
|
||||
const loading = document.getElementById('loading');
|
||||
const container = document.getElementById('devices-container');
|
||||
const errorContainer = document.getElementById('error-container');
|
||||
|
||||
try {
|
||||
// Load all devices using API client
|
||||
const allDevices = await window.apiClient.getDevices();
|
||||
console.log('All devices loaded:', allDevices.length);
|
||||
|
||||
// Filter garage devices
|
||||
const garageDevices = allDevices.filter(device =>
|
||||
GARAGE_DEVICES.includes(device.device_id)
|
||||
);
|
||||
|
||||
console.log('Garage devices found:', garageDevices);
|
||||
|
||||
if (garageDevices.length === 0) {
|
||||
throw new Error('Keine Garage-Geräte gefunden');
|
||||
}
|
||||
|
||||
// Create device lookup
|
||||
garageDevices.forEach(device => {
|
||||
devicesData[device.device_id] = device;
|
||||
});
|
||||
|
||||
// Load device states
|
||||
for (const device of garageDevices) {
|
||||
try {
|
||||
deviceStates[device.device_id] = await window.apiClient.getDeviceState(device.device_id);
|
||||
console.log(`State for ${device.device_id}:`, deviceStates[device.device_id]);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to load state for ${device.device_id}:`, err);
|
||||
deviceStates[device.device_id] = null;
|
||||
}
|
||||
}
|
||||
|
||||
loading.style.display = 'none';
|
||||
container.style.display = 'grid';
|
||||
|
||||
// Render devices
|
||||
garageDevices.forEach(device => {
|
||||
const deviceSection = createDeviceSection(device);
|
||||
container.appendChild(deviceSection);
|
||||
});
|
||||
|
||||
// Start SSE for live updates
|
||||
connectRealtime();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading garage devices:', error);
|
||||
loading.style.display = 'none';
|
||||
errorContainer.innerHTML = `
|
||||
<div class="error">
|
||||
⚠️ Fehler beim Laden: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function createDeviceSection(device) {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'device-section';
|
||||
section.dataset.deviceId = device.device_id;
|
||||
|
||||
// Device header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'device-header';
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'device-icon';
|
||||
icon.textContent = getDeviceIcon(device.type);
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'device-info';
|
||||
|
||||
const name = document.createElement('h2');
|
||||
name.className = 'device-name';
|
||||
name.textContent = device.name;
|
||||
|
||||
const type = document.createElement('p');
|
||||
type.className = 'device-type';
|
||||
type.textContent = getTypeLabel(device.type);
|
||||
|
||||
info.appendChild(name);
|
||||
info.appendChild(type);
|
||||
|
||||
header.appendChild(icon);
|
||||
header.appendChild(info);
|
||||
|
||||
section.appendChild(header);
|
||||
|
||||
// Device content
|
||||
const content = document.createElement('div');
|
||||
content.className = 'device-content';
|
||||
|
||||
renderDeviceContent(content, device);
|
||||
|
||||
section.appendChild(content);
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderDeviceContent(container, device) {
|
||||
// Clear existing content
|
||||
container.innerHTML = '';
|
||||
|
||||
switch (device.type) {
|
||||
case 'relay':
|
||||
case 'outlet':
|
||||
renderOutletControls(container, device);
|
||||
break;
|
||||
case 'three_phase_powermeter':
|
||||
renderThreePhasePowerDisplay(container, device);
|
||||
break;
|
||||
default:
|
||||
container.innerHTML = '<div class="card">Keine Steuerung verfügbar</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderOutletControls(container, device) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.innerHTML = '<div class="card-title">Steuerung</div>';
|
||||
|
||||
const controlGroup = document.createElement('div');
|
||||
controlGroup.className = 'control-group';
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = 'control-label';
|
||||
label.textContent = 'Status';
|
||||
|
||||
const buttonGroup = document.createElement('div');
|
||||
buttonGroup.className = 'button-group';
|
||||
|
||||
const state = deviceStates[device.device_id];
|
||||
const currentPower = state?.power === 'on';
|
||||
|
||||
const onButton = document.createElement('button');
|
||||
onButton.className = `control-button ${currentPower ? 'on' : 'off'}`;
|
||||
onButton.textContent = 'Ein';
|
||||
onButton.onclick = () => toggleOutlet(device.device_id, 'on');
|
||||
|
||||
const offButton = document.createElement('button');
|
||||
offButton.className = `control-button ${!currentPower ? 'on' : 'off'}`;
|
||||
offButton.textContent = 'Aus';
|
||||
offButton.onclick = () => toggleOutlet(device.device_id, 'off');
|
||||
|
||||
buttonGroup.appendChild(onButton);
|
||||
buttonGroup.appendChild(offButton);
|
||||
|
||||
controlGroup.appendChild(label);
|
||||
controlGroup.appendChild(buttonGroup);
|
||||
|
||||
card.appendChild(controlGroup);
|
||||
container.appendChild(card);
|
||||
}
|
||||
|
||||
function renderThreePhasePowerDisplay(container, device) {
|
||||
const state = deviceStates[device.device_id] || {};
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.innerHTML = '<div class="card-title">Leistungsmessung</div>';
|
||||
|
||||
// Übersicht
|
||||
const overviewGrid = document.createElement('div');
|
||||
overviewGrid.className = 'state-grid';
|
||||
overviewGrid.innerHTML = `
|
||||
<div class="state-item">
|
||||
<div class="state-value" id="total-power-${device.device_id}">${state.total_power?.toFixed(0) || '--'} W</div>
|
||||
<div class="state-label">Gesamtleistung</div>
|
||||
</div>
|
||||
<div class="state-item">
|
||||
<div class="state-value" id="energy-${device.device_id}">${state.energy?.toFixed(2) || '--'} kWh</div>
|
||||
<div class="state-label">Energie</div>
|
||||
</div>
|
||||
`;
|
||||
card.appendChild(overviewGrid);
|
||||
|
||||
// Phasen Details
|
||||
const phaseCard = document.createElement('div');
|
||||
phaseCard.className = 'card';
|
||||
phaseCard.innerHTML = '<div class="card-title">Phasen</div>';
|
||||
phaseCard.style.marginTop = '20px';
|
||||
|
||||
const phaseGrid = document.createElement('div');
|
||||
phaseGrid.className = 'phase-grid';
|
||||
phaseGrid.innerHTML = `
|
||||
<div class="phase-section">
|
||||
<h4>Phase 1</h4>
|
||||
<div class="phase-values">
|
||||
<div class="phase-value">
|
||||
<span class="value" id="phase1-power-${device.device_id}">${state.phase1_power?.toFixed(0) || '--'}</span>
|
||||
<span class="unit">W</span>
|
||||
</div>
|
||||
<div class="phase-value">
|
||||
<span class="value" id="phase1-voltage-${device.device_id}">${state.phase1_voltage?.toFixed(1) || '--'}</span>
|
||||
<span class="unit">V</span>
|
||||
</div>
|
||||
<div class="phase-value">
|
||||
<span class="value" id="phase1-current-${device.device_id}">${state.phase1_current?.toFixed(2) || '--'}</span>
|
||||
<span class="unit">A</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phase-section">
|
||||
<h4>Phase 2</h4>
|
||||
<div class="phase-values">
|
||||
<div class="phase-value">
|
||||
<span class="value" id="phase2-power-${device.device_id}">${state.phase2_power?.toFixed(0) || '--'}</span>
|
||||
<span class="unit">W</span>
|
||||
</div>
|
||||
<div class="phase-value">
|
||||
<span class="value" id="phase2-voltage-${device.device_id}">${state.phase2_voltage?.toFixed(1) || '--'}</span>
|
||||
<span class="unit">V</span>
|
||||
</div>
|
||||
<div class="phase-value">
|
||||
<span class="value" id="phase2-current-${device.device_id}">${state.phase2_current?.toFixed(2) || '--'}</span>
|
||||
<span class="unit">A</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phase-section">
|
||||
<h4>Phase 3</h4>
|
||||
<div class="phase-values">
|
||||
<div class="phase-value">
|
||||
<span class="value" id="phase3-power-${device.device_id}">${state.phase3_power?.toFixed(0) || '--'}</span>
|
||||
<span class="unit">W</span>
|
||||
</div>
|
||||
<div class="phase-value">
|
||||
<span class="value" id="phase3-voltage-${device.device_id}">${state.phase3_voltage?.toFixed(1) || '--'}</span>
|
||||
<span class="unit">V</span>
|
||||
</div>
|
||||
<div class="phase-value">
|
||||
<span class="value" id="phase3-current-${device.device_id}">${state.phase3_current?.toFixed(2) || '--'}</span>
|
||||
<span class="unit">A</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
phaseCard.appendChild(phaseGrid);
|
||||
|
||||
container.appendChild(card);
|
||||
container.appendChild(phaseCard);
|
||||
}
|
||||
|
||||
async function toggleOutlet(deviceId, newState) {
|
||||
try {
|
||||
await window.apiClient.setDeviceState(deviceId, { power: newState });
|
||||
console.log(`Set ${deviceId} to ${newState}`);
|
||||
} catch (error) {
|
||||
console.error('Error toggling outlet:', error);
|
||||
alert('Fehler beim Schalten des Geräts: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function connectRealtime() {
|
||||
try {
|
||||
window.apiClient.connectRealtime((event) => {
|
||||
console.log('SSE event received:', event);
|
||||
if (event.device_id && event.state && GARAGE_DEVICES.includes(event.device_id)) {
|
||||
console.log('Updating garage device state for:', event.device_id);
|
||||
deviceStates[event.device_id] = { ...deviceStates[event.device_id], ...event.state };
|
||||
updateDeviceUI(event.device_id);
|
||||
}
|
||||
}, (error) => {
|
||||
console.error('SSE connection error:', error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to realtime events:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateDeviceUI(deviceId) {
|
||||
const device = devicesData[deviceId];
|
||||
if (!device) return;
|
||||
|
||||
const state = deviceStates[deviceId];
|
||||
console.log(`Updating UI for ${deviceId}:`, state);
|
||||
|
||||
switch (device.type) {
|
||||
case 'relay':
|
||||
case 'outlet':
|
||||
updateOutletUI(deviceId, state);
|
||||
break;
|
||||
case 'three_phase_powermeter':
|
||||
updateThreePhasePowerUI(deviceId, state);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function updateOutletUI(deviceId, state) {
|
||||
const section = document.querySelector(`[data-device-id="${deviceId}"]`);
|
||||
if (!section) return;
|
||||
|
||||
const onButton = section.querySelector('.control-button:nth-child(1)');
|
||||
const offButton = section.querySelector('.control-button:nth-child(2)');
|
||||
|
||||
if (onButton && offButton && state.power) {
|
||||
const isOn = state.power === 'on';
|
||||
onButton.className = `control-button ${isOn ? 'on' : 'off'}`;
|
||||
offButton.className = `control-button ${!isOn ? 'on' : 'off'}`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateThreePhasePowerUI(deviceId, state) {
|
||||
// Update overview
|
||||
const totalPower = document.getElementById(`total-power-${deviceId}`);
|
||||
const energy = document.getElementById(`energy-${deviceId}`);
|
||||
|
||||
if (totalPower && state.total_power != null) {
|
||||
totalPower.textContent = state.total_power.toFixed(0) + ' W';
|
||||
}
|
||||
if (energy && state.energy != null) {
|
||||
energy.textContent = state.energy.toFixed(2) + ' kWh';
|
||||
}
|
||||
|
||||
// Update phases
|
||||
const phases = ['phase1', 'phase2', 'phase3'];
|
||||
phases.forEach(phase => {
|
||||
const power = document.getElementById(`${phase}-power-${deviceId}`);
|
||||
const voltage = document.getElementById(`${phase}-voltage-${deviceId}`);
|
||||
const current = document.getElementById(`${phase}-current-${deviceId}`);
|
||||
|
||||
if (power && state[`${phase}_power`] != null) {
|
||||
power.textContent = state[`${phase}_power`].toFixed(0);
|
||||
}
|
||||
if (voltage && state[`${phase}_voltage`] != null) {
|
||||
voltage.textContent = state[`${phase}_voltage`].toFixed(1);
|
||||
}
|
||||
if (current && state[`${phase}_current`] != null) {
|
||||
current.textContent = state[`${phase}_current`].toFixed(2);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getDeviceIcon(type) {
|
||||
const icons = {
|
||||
'relay': '⚡',
|
||||
'outlet': '⚡',
|
||||
'three_phase_powermeter': '📊'
|
||||
};
|
||||
return icons[type] || '📱';
|
||||
}
|
||||
|
||||
function getTypeLabel(type) {
|
||||
const labels = {
|
||||
'relay': 'Relais',
|
||||
'outlet': 'Steckdose',
|
||||
'three_phase_powermeter': 'Dreiphasen-Stromzähler'
|
||||
};
|
||||
return labels[type] || 'Unbekannt';
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
window.apiClient.disconnectRealtime();
|
||||
});
|
||||
|
||||
// Load garage devices on page load
|
||||
document.addEventListener('DOMContentLoaded', loadGarageDevices);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
476
apps/ui/templates/room.html
Normal file
476
apps/ui/templates/room.html
Normal file
@@ -0,0 +1,476 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ room_name }} - Home Automation</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #667eea;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
margin-bottom: 12px;
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.room-info {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.devices-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.devices-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.devices-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.device-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
min-height: 110px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.device-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.device-card:active {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.device-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.device-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.device-state {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.state-primary {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.state-secondary {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.state-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.state-badge.on {
|
||||
background: #34c759;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.state-badge.off {
|
||||
background: #e0e0e0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.state-badge.open {
|
||||
background: #ff9500;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.state-badge.closed {
|
||||
background: #34c759;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(255, 59, 48, 0.9);
|
||||
color: white;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.no-devices {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<a href="/rooms" class="back-button">← Zurück zu Räumen</a>
|
||||
<h1 id="room-name">Raum wird geladen...</h1>
|
||||
<div class="room-info" id="room-info"></div>
|
||||
</div>
|
||||
|
||||
<div id="error-container"></div>
|
||||
<div id="loading" class="loading">Lade Geräte...</div>
|
||||
<div id="devices-grid" class="devices-grid" style="display: none;"></div>
|
||||
<div id="no-devices" class="no-devices" style="display: none;">
|
||||
Keine Geräte in diesem Raum gefunden.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// API configuration from backend
|
||||
window.API_BASE = '{{ api_base }}';
|
||||
</script>
|
||||
|
||||
<!-- Load API client AFTER API_BASE is set -->
|
||||
<script src="/static/types.js"></script>
|
||||
<script src="/static/api-client.js"></script>
|
||||
|
||||
<script>
|
||||
// Get room name from URL
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const roomName = decodeURIComponent(pathParts[pathParts.length - 1]);
|
||||
|
||||
// Device type to icon mapping
|
||||
const deviceIcons = {
|
||||
'light': '💡',
|
||||
'thermostat': '🌡️',
|
||||
'contact': '🚪',
|
||||
'temp_humidity_sensor': '🌡️',
|
||||
'three_phase_powermeter': '📊',
|
||||
'relay': '💡',
|
||||
'outlet': '💡',
|
||||
'cover': '🪟'
|
||||
};
|
||||
|
||||
// Device states
|
||||
const deviceStates = {};
|
||||
|
||||
async function loadRoom() {
|
||||
const loading = document.getElementById('loading');
|
||||
const grid = document.getElementById('devices-grid');
|
||||
const noDevices = document.getElementById('no-devices');
|
||||
const errorContainer = document.getElementById('error-container');
|
||||
const roomNameEl = document.getElementById('room-name');
|
||||
const roomInfoEl = document.getElementById('room-info');
|
||||
|
||||
try {
|
||||
// Load layout and devices using API client
|
||||
// NEW: Use device layout endpoint for each device in this room
|
||||
const layoutData = await window.apiClient.getLayout();
|
||||
const devicesData = await window.apiClient.getDevices();
|
||||
// Example: For each device in room.devices, you could fetch layout info via
|
||||
// await window.apiClient.fetch(window.apiClient.api(`/devices/${device_id}/layout`));
|
||||
|
||||
console.log('Room name from URL:', roomName);
|
||||
console.log('Available rooms:', layoutData.rooms.map(r => r.name));
|
||||
console.log('Total devices:', devicesData.length);
|
||||
|
||||
// Find the room using API client helper
|
||||
const room = window.apiClient.findRoom(layoutData, roomName);
|
||||
if (!room) {
|
||||
console.error('Room not found:', roomName);
|
||||
throw new Error(`Raum "${roomName}" nicht gefunden`);
|
||||
}
|
||||
|
||||
console.log('Found room:', room);
|
||||
console.log('Room devices:', room.devices);
|
||||
|
||||
// Update header
|
||||
roomNameEl.textContent = room.name;
|
||||
roomInfoEl.textContent = `${room.devices.length} Gerät${room.devices.length !== 1 ? 'e' : ''}`;
|
||||
|
||||
// Create device lookup
|
||||
const deviceMap = {};
|
||||
devicesData.forEach(device => {
|
||||
deviceMap[device.device_id] = device;
|
||||
});
|
||||
|
||||
// Extract device IDs from room devices (they are objects now)
|
||||
const deviceIds = room.devices.map(d => d.device_id);
|
||||
console.log('Device IDs from room:', deviceIds);
|
||||
|
||||
// Filter devices for this room
|
||||
const roomDevices = deviceIds
|
||||
.map(deviceId => deviceMap[deviceId])
|
||||
.filter(device => device != null);
|
||||
|
||||
console.log('Filtered room devices:', roomDevices);
|
||||
|
||||
loading.style.display = 'none';
|
||||
|
||||
if (roomDevices.length === 0) {
|
||||
noDevices.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Lade nur die States für die Geräte im aktuellen Raum
|
||||
for (const device of roomDevices) {
|
||||
try {
|
||||
deviceStates[device.device_id] = await window.apiClient.getDeviceState(device.device_id);
|
||||
} catch (err) {
|
||||
deviceStates[device.device_id] = null;
|
||||
}
|
||||
}
|
||||
console.log('Device states:', deviceStates);
|
||||
|
||||
// Render devices
|
||||
grid.style.display = 'grid';
|
||||
roomDevices.forEach(device => {
|
||||
const card = createDeviceCard(device);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
// Start SSE for live updates
|
||||
connectRealtime();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading room:', error);
|
||||
loading.style.display = 'none';
|
||||
errorContainer.innerHTML = `
|
||||
<div class="error">
|
||||
⚠️ Fehler beim Laden: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function createDeviceCard(device) {
|
||||
const card = document.createElement('a');
|
||||
card.className = 'device-card';
|
||||
card.href = `/device/${device.device_id}`;
|
||||
card.dataset.deviceId = device.device_id;
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'device-header';
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'device-icon';
|
||||
icon.textContent = deviceIcons[device.type] || '📱';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'device-title';
|
||||
title.textContent = device.name;
|
||||
|
||||
header.appendChild(icon);
|
||||
header.appendChild(title);
|
||||
|
||||
const stateDiv = document.createElement('div');
|
||||
stateDiv.className = 'device-state';
|
||||
updateDeviceCardState(stateDiv, device);
|
||||
|
||||
card.appendChild(header);
|
||||
card.appendChild(stateDiv);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function updateDeviceCardState(stateDiv, device) {
|
||||
const state = deviceStates[device.device_id];
|
||||
if (!state) {
|
||||
stateDiv.innerHTML = '<div class="state-secondary">Status unbekannt</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
switch (device.type) {
|
||||
case 'light':
|
||||
if (state.power) {
|
||||
const powerState = state.power === 'on';
|
||||
html = `<span class="state-badge ${powerState ? 'on' : 'off'}">${powerState ? 'An' : 'Aus'}</span>`;
|
||||
if (powerState && state.brightness != null) {
|
||||
html += `<div class="state-secondary">${state.brightness}% Helligkeit</div>`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'thermostat':
|
||||
if (state.target != null) {
|
||||
html = `<div class="state-primary">${state.target.toFixed(1)}°C</div>`;
|
||||
if (state.current != null) {
|
||||
html += `<div class="state-secondary">Ist: ${state.current}°C</div>`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'contact':
|
||||
if (state.contact != null) {
|
||||
const isOpen = state.contact === 'open';
|
||||
html = `<span class="state-badge ${isOpen ? 'open' : 'closed'}">${isOpen ? 'Offen' : 'Geschlossen'}</span>`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'temp_humidity_sensor':
|
||||
if (state.temperature != null) {
|
||||
html = `<div class="state-primary">${state.temperature.toFixed(1)}°C</div>`;
|
||||
if (state.humidity != null) {
|
||||
html += `<div class="state-secondary">${state.humidity}% Luftfeuchte</div>`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'three_phase_powermeter':
|
||||
if (state.total_power != null) {
|
||||
html = `<div class="state-primary">${state.total_power.toFixed(0)} W</div>`;
|
||||
if (state.energy != null) {
|
||||
html += `<div class="state-secondary">${state.energy.toFixed(2)} kWh</div>`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'relay':
|
||||
case 'outlet':
|
||||
if (state.power) {
|
||||
const powerState = state.power === 'on';
|
||||
html = `<span class="state-badge ${powerState ? 'on' : 'off'}">${powerState ? 'An' : 'Aus'}</span>`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cover':
|
||||
if (state.position != null) {
|
||||
html = `<div class="state-primary">${state.position}%</div>`;
|
||||
html += `<div class="state-secondary">Position</div>`;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
html = '<div class="state-secondary">Kein Status</div>';
|
||||
}
|
||||
|
||||
stateDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
function connectRealtime() {
|
||||
try {
|
||||
// Use API client's connectRealtime method
|
||||
window.apiClient.connectRealtime((event) => {
|
||||
// Update device state
|
||||
if (event.device_id && event.state) {
|
||||
deviceStates[event.device_id] = event.state;
|
||||
|
||||
// Update card if visible
|
||||
const card = document.querySelector(`[data-device-id="${event.device_id}"]`);
|
||||
if (card) {
|
||||
const stateDiv = card.querySelector('.device-state');
|
||||
window.apiClient.getDevices().then(devices => {
|
||||
const device = window.apiClient.findDevice(devices, event.device_id);
|
||||
if (device) {
|
||||
updateDeviceCardState(stateDiv, device);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, (error) => {
|
||||
console.error('SSE connection error:', error);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to realtime events:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
window.apiClient.disconnectRealtime();
|
||||
});
|
||||
|
||||
// Load room on page load
|
||||
document.addEventListener('DOMContentLoaded', loadRoom);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
308
apps/ui/templates/rooms.html
Normal file
308
apps/ui/templates/rooms.html
Normal file
@@ -0,0 +1,308 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Räume - Home Automation</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
font-size: 28px;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rooms-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.rooms-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.rooms-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.room-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
min-height: 110px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.room-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.room-card:active {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.room-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.room-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.room-device-count {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.room-stats {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(255, 59, 48, 0.9);
|
||||
color: white;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #667eea;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🏠 Zuhause</h1>
|
||||
|
||||
<div id="error-container"></div>
|
||||
<div id="loading" class="loading">Lade Räume...</div>
|
||||
<div id="rooms-grid" class="rooms-grid" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// API configuration from backend
|
||||
window.API_BASE = '{{ api_base }}';
|
||||
</script>
|
||||
|
||||
<!-- Load API client AFTER API_BASE is set -->
|
||||
<script src="/static/types.js"></script>
|
||||
<script src="/static/api-client.js"></script>
|
||||
|
||||
<script>
|
||||
// Room icon mapping
|
||||
const roomIcons = {
|
||||
'wohnzimmer': '🛋️',
|
||||
'küche': '🍳',
|
||||
'kueche': '🍳',
|
||||
'schlafzimmer': '🛏️',
|
||||
'bad': '🚿',
|
||||
'badezimmer': '🚿',
|
||||
'bad oben': '🚿',
|
||||
'bad unten': '🚿',
|
||||
'flur': '🚪',
|
||||
'büro': '💼',
|
||||
'buero': '💼',
|
||||
'arbeitszimmer': '💼',
|
||||
'studierzimmer': '📚',
|
||||
'esszimmer': '🍽️',
|
||||
'garten': '🌳',
|
||||
'terrasse': '🌿',
|
||||
'garage': '🚗',
|
||||
'keller': '🔧',
|
||||
'dachboden': '📦',
|
||||
'kinderzimmer': '🧸',
|
||||
'patty': '👤',
|
||||
'wolfgang': '👤',
|
||||
'default': '🏡'
|
||||
};
|
||||
|
||||
function getRoomIcon(roomName) {
|
||||
const normalized = roomName.toLowerCase().trim();
|
||||
return roomIcons[normalized] || roomIcons['default'];
|
||||
}
|
||||
|
||||
async function loadRooms() {
|
||||
const loading = document.getElementById('loading');
|
||||
const grid = document.getElementById('rooms-grid');
|
||||
const errorContainer = document.getElementById('error-container');
|
||||
|
||||
try {
|
||||
// Load layout and devices using API client
|
||||
// NEW: Use device layout endpoint for each device
|
||||
// Fallback: load all rooms as before
|
||||
const layoutData = await window.apiClient.getLayout();
|
||||
const devicesData = await window.apiClient.getDevices();
|
||||
// Example: For each device, you could also fetch layout info via
|
||||
// await window.apiClient.fetch(window.apiClient.api(`/devices/${device_id}/layout`));
|
||||
|
||||
// Create device lookup
|
||||
const deviceMap = {};
|
||||
devicesData.forEach(device => {
|
||||
deviceMap[device.device_id] = device;
|
||||
});
|
||||
|
||||
// Render rooms
|
||||
loading.style.display = 'none';
|
||||
grid.style.display = 'grid';
|
||||
|
||||
layoutData.rooms.forEach(room => {
|
||||
const card = createRoomCard(room, deviceMap);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading rooms:', error);
|
||||
loading.style.display = 'none';
|
||||
errorContainer.innerHTML = `
|
||||
<div class="error">
|
||||
⚠️ Fehler beim Laden der Räume: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function createRoomCard(room, deviceMap) {
|
||||
const card = document.createElement('a');
|
||||
card.className = 'room-card';
|
||||
card.href = `/room/${encodeURIComponent(room.name)}`;
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'room-icon';
|
||||
icon.textContent = getRoomIcon(room.name);
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'room-name';
|
||||
name.textContent = room.name;
|
||||
|
||||
const deviceCount = document.createElement('div');
|
||||
deviceCount.className = 'room-device-count';
|
||||
deviceCount.textContent = `${room.devices.length} Gerät${room.devices.length !== 1 ? 'e' : ''}`;
|
||||
|
||||
// Optional: Calculate stats (lights on, windows open, etc.)
|
||||
const stats = calculateRoomStats(room.devices, deviceMap);
|
||||
if (stats) {
|
||||
const statsDiv = document.createElement('div');
|
||||
statsDiv.className = 'room-stats';
|
||||
statsDiv.textContent = stats;
|
||||
card.appendChild(icon);
|
||||
card.appendChild(name);
|
||||
card.appendChild(deviceCount);
|
||||
card.appendChild(statsDiv);
|
||||
} else {
|
||||
card.appendChild(icon);
|
||||
card.appendChild(name);
|
||||
card.appendChild(deviceCount);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function calculateRoomStats(roomDevices, deviceMap) {
|
||||
// Extract device IDs from room devices (they are objects now)
|
||||
const deviceIds = roomDevices.map(d => d.device_id || d);
|
||||
|
||||
// Count device types
|
||||
let lights = 0;
|
||||
let thermostats = 0;
|
||||
let contacts = 0;
|
||||
let sensors = 0;
|
||||
|
||||
deviceIds.forEach(deviceId => {
|
||||
const device = deviceMap[deviceId];
|
||||
if (!device) return;
|
||||
|
||||
switch(device.type) {
|
||||
case 'light':
|
||||
lights++;
|
||||
break;
|
||||
case 'thermostat':
|
||||
thermostats++;
|
||||
break;
|
||||
case 'contact':
|
||||
contacts++;
|
||||
break;
|
||||
case 'temp_humidity_sensor':
|
||||
sensors++;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Build compact stats string
|
||||
const parts = [];
|
||||
if (lights > 0) parts.push(`💡${lights}`);
|
||||
if (thermostats > 0) parts.push(`🌡️${thermostats}`);
|
||||
if (contacts > 0) parts.push(`🚪${contacts}`);
|
||||
|
||||
return parts.length > 0 ? parts.join(' ') : null;
|
||||
}
|
||||
|
||||
// Load rooms on page load
|
||||
document.addEventListener('DOMContentLoaded', loadRooms);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,4 @@
|
||||
version: 1
|
||||
|
||||
mqtt:
|
||||
broker: "172.16.2.16"
|
||||
port: 1883
|
||||
@@ -7,38 +6,800 @@ mqtt:
|
||||
username: null
|
||||
password: null
|
||||
keepalive: 60
|
||||
|
||||
redis:
|
||||
url: "redis://172.23.1.116:6379/8"
|
||||
channel: "ui:updates"
|
||||
|
||||
devices:
|
||||
- device_id: test_lampe_1
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
set: "vendor/test_lampe_1/set"
|
||||
state: "vendor/test_lampe_1/state"
|
||||
- device_id: test_lampe_2
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
topics:
|
||||
set: "vendor/test_lampe_2/set"
|
||||
state: "vendor/test_lampe_2/state"
|
||||
- device_id: test_lampe_3
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
set: "vendor/test_lampe_3/set"
|
||||
state: "vendor/test_lampe_3/state"
|
||||
- device_id: lampe_semeniere_wohnzimmer
|
||||
name: Semeniere
|
||||
type: relay
|
||||
cap_version: "relay@1.0.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0xf0d1b8000015480b"
|
||||
set: "zigbee2mqtt/0xf0d1b8000015480b/set"
|
||||
metadata:
|
||||
friendly_name: "Lampe Semeniere Wohnzimmer"
|
||||
ieee_address: "0xf0d1b8000015480b"
|
||||
model: "AC10691"
|
||||
vendor: "OSRAM"
|
||||
- device_id: stehlampe_esszimmer_spiegel
|
||||
name: Stehlampe Spiegel
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0x001788010d06ea09"
|
||||
set: "zigbee2mqtt/0x001788010d06ea09/set"
|
||||
- device_id: stehlampe_esszimmer_schrank
|
||||
name: Stehlampe Schrank
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0x001788010d09176c"
|
||||
set: "zigbee2mqtt/0x001788010d09176c/set"
|
||||
- device_id: grosse_lampe_wohnzimmer
|
||||
name: grosse Lampe
|
||||
type: relay
|
||||
cap_version: "relay@1.0.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0xf0d1b80000151aca"
|
||||
set: "zigbee2mqtt/0xf0d1b80000151aca/set"
|
||||
metadata:
|
||||
friendly_name: "grosse Lampe Wohnzimmer"
|
||||
ieee_address: "0xf0d1b80000151aca"
|
||||
model: "AC10691"
|
||||
vendor: "OSRAM"
|
||||
- device_id: lampe_naehtischchen_wohnzimmer
|
||||
name: Nähtischchen
|
||||
type: relay
|
||||
cap_version: "relay@1.0.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0x842e14fffee560ee"
|
||||
set: "zigbee2mqtt/0x842e14fffee560ee/set"
|
||||
metadata:
|
||||
friendly_name: "Lampe Naehtischchen Wohnzimmer"
|
||||
ieee_address: "0x842e14fffee560ee"
|
||||
model: "HG06337"
|
||||
vendor: "Lidl"
|
||||
- device_id: kleine_lampe_links_esszimmer
|
||||
name: kleine Lampe
|
||||
type: relay
|
||||
cap_version: "relay@1.0.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0xf0d1b80000153099"
|
||||
set: "zigbee2mqtt/0xf0d1b80000153099/set"
|
||||
metadata:
|
||||
friendly_name: "kleine Lampe links Esszimmer"
|
||||
ieee_address: "0xf0d1b80000153099"
|
||||
model: "AC10691"
|
||||
vendor: "OSRAM"
|
||||
- device_id: leselampe_esszimmer
|
||||
name: Leselampe
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0xec1bbdfffe7b84f2"
|
||||
set: "zigbee2mqtt/0xec1bbdfffe7b84f2/set"
|
||||
metadata:
|
||||
friendly_name: "Leselampe Esszimmer"
|
||||
ieee_address: "0xec1bbdfffe7b84f2"
|
||||
model: "LED1842G3"
|
||||
vendor: "IKEA"
|
||||
- device_id: medusalampe_schlafzimmer
|
||||
name: Medusa-Lampe
|
||||
type: relay
|
||||
cap_version: "relay@1.0.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0xf0d1b80000154c7c"
|
||||
set: "zigbee2mqtt/0xf0d1b80000154c7c/set"
|
||||
metadata:
|
||||
friendly_name: "Medusa-Lampe Schlafzimmer"
|
||||
ieee_address: "0xf0d1b80000154c7c"
|
||||
model: "AC10691"
|
||||
vendor: "OSRAM"
|
||||
- device_id: sportlicht_am_fernseher_studierzimmer
|
||||
type: light
|
||||
name: am Fernseher
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
color_temperature: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0x842e14fffe76a23a"
|
||||
set: "zigbee2mqtt/0x842e14fffe76a23a/set"
|
||||
metadata:
|
||||
friendly_name: "Sportlicht am Fernseher, Studierzimmer"
|
||||
ieee_address: "0x842e14fffe76a23a"
|
||||
model: "LED1733G7"
|
||||
vendor: "IKEA"
|
||||
- device_id: deckenlampe_schlafzimmer
|
||||
name: Deckenlampe
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0x0017880108a406a7"
|
||||
set: "zigbee2mqtt/0x0017880108a406a7/set"
|
||||
metadata:
|
||||
friendly_name: "Deckenlampe Schlafzimmer"
|
||||
ieee_address: "0x0017880108a406a7"
|
||||
model: "8718699688882"
|
||||
vendor: "Philips"
|
||||
- device_id: bettlicht_wolfgang
|
||||
name: Bettlicht Wolfgang
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0x00178801081570bf"
|
||||
set: "zigbee2mqtt/0x00178801081570bf/set"
|
||||
metadata:
|
||||
friendly_name: "Bettlicht Wolfgang"
|
||||
ieee_address: "0x00178801081570bf"
|
||||
model: "9290020399"
|
||||
vendor: "Philips"
|
||||
- device_id: bettlicht_patty
|
||||
name: Bettlicht Patty
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0x0017880108158b32"
|
||||
set: "zigbee2mqtt/0x0017880108158b32/set"
|
||||
metadata:
|
||||
friendly_name: "Bettlicht Patty"
|
||||
ieee_address: "0x0017880108158b32"
|
||||
model: "9290020399"
|
||||
vendor: "Philips"
|
||||
- device_id: schranklicht_hinten_patty
|
||||
name: Schranklicht hinten
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0x0017880106e29571"
|
||||
set: "zigbee2mqtt/0x0017880106e29571/set"
|
||||
metadata:
|
||||
friendly_name: "Schranklicht hinten Patty"
|
||||
ieee_address: "0x0017880106e29571"
|
||||
model: "8718699673147"
|
||||
vendor: "Philips"
|
||||
- device_id: schranklicht_vorne_patty
|
||||
name: Schranklicht vorne
|
||||
type: relay
|
||||
cap_version: "relay@1.0.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0xf0d1b80000154cf5"
|
||||
set: "zigbee2mqtt/0xf0d1b80000154cf5/set"
|
||||
metadata:
|
||||
friendly_name: "Schranklicht vorne Patty"
|
||||
ieee_address: "0xf0d1b80000154cf5"
|
||||
model: "AC10691"
|
||||
vendor: "OSRAM"
|
||||
- device_id: leselampe_patty
|
||||
name: Leselampe
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0x001788010600ec9d"
|
||||
set: "zigbee2mqtt/0x001788010600ec9d/set"
|
||||
metadata:
|
||||
friendly_name: "Leselampe Patty"
|
||||
ieee_address: "0x001788010600ec9d"
|
||||
model: "8718699673147"
|
||||
vendor: "Philips"
|
||||
- device_id: deckenlampe_esszimmer
|
||||
name: Deckenlampe
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0x0017880108a03e45"
|
||||
set: "zigbee2mqtt/0x0017880108a03e45/set"
|
||||
metadata:
|
||||
friendly_name: "Deckenlampe Esszimmer"
|
||||
ieee_address: "0x0017880108a03e45"
|
||||
model: "929002241201"
|
||||
vendor: "Philips"
|
||||
- device_id: haustuer
|
||||
name: Haustür-Lampe
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0xec1bbdfffea6a3da"
|
||||
set: "zigbee2mqtt/0xec1bbdfffea6a3da/set"
|
||||
metadata:
|
||||
friendly_name: "Haustür"
|
||||
ieee_address: "0xec1bbdfffea6a3da"
|
||||
model: "LED1842G3"
|
||||
vendor: "IKEA"
|
||||
- device_id: deckenlampe_flur_oben
|
||||
name: Deckenlampe oben
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
color_temperature: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0x001788010d2123a7"
|
||||
set: "zigbee2mqtt/0x001788010d2123a7/set"
|
||||
metadata:
|
||||
friendly_name: "Deckenlampe Flur oben"
|
||||
ieee_address: "0x001788010d2123a7"
|
||||
model: "929003099001"
|
||||
vendor: "Philips"
|
||||
- device_id: kueche_deckenlampe
|
||||
name: Deckenlampe
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0x001788010d2c40c4"
|
||||
set: "zigbee2mqtt/0x001788010d2c40c4/set"
|
||||
metadata:
|
||||
friendly_name: "Küche Deckenlampe"
|
||||
ieee_address: "0x001788010d2c40c4"
|
||||
model: "929002469202"
|
||||
vendor: "Philips"
|
||||
- device_id: sportlicht_tisch
|
||||
name: am Tisch
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0xf0d1b8be2409f31b"
|
||||
set: "zigbee2mqtt/0xf0d1b8be2409f31b/set"
|
||||
metadata:
|
||||
friendly_name: "Sportlicht Tisch"
|
||||
ieee_address: "0xf0d1b8be2409f31b"
|
||||
model: "4058075729063"
|
||||
vendor: "LEDVANCE"
|
||||
- device_id: sportlicht_regal
|
||||
name: am Regal
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0xf0d1b8be2409f569"
|
||||
set: "zigbee2mqtt/0xf0d1b8be2409f569/set"
|
||||
metadata:
|
||||
friendly_name: "Sportlicht Regal"
|
||||
ieee_address: "0xf0d1b8be2409f569"
|
||||
model: "4058075729063"
|
||||
vendor: "LEDVANCE"
|
||||
- device_id: licht_flur_oben_am_spiegel
|
||||
name: Spiegel
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
color_temperature: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0x842e14fffefe4ba4"
|
||||
set: "zigbee2mqtt/0x842e14fffefe4ba4/set"
|
||||
metadata:
|
||||
friendly_name: "Licht Flur oben am Spiegel"
|
||||
ieee_address: "0x842e14fffefe4ba4"
|
||||
model: "LED1732G11"
|
||||
vendor: "IKEA"
|
||||
- device_id: experimentlabtest
|
||||
name: Test Lampe
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0xf0d1b80000195038"
|
||||
set: "zigbee2mqtt/0xf0d1b80000195038/set"
|
||||
metadata:
|
||||
friendly_name: "ExperimentLabTest"
|
||||
ieee_address: "0xf0d1b80000195038"
|
||||
model: "4058075208421"
|
||||
vendor: "LEDVANCE"
|
||||
- device_id: thermostat_wolfgang
|
||||
name: Heizung
|
||||
type: thermostat
|
||||
cap_version: "thermostat@1.0.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
heating: true
|
||||
temperature_range:
|
||||
- 5
|
||||
- 30
|
||||
temperature_step: 0.5
|
||||
topics:
|
||||
state: "zigbee2mqtt/0x540f57fffe7e3cfe"
|
||||
set: "zigbee2mqtt/0x540f57fffe7e3cfe/set"
|
||||
metadata:
|
||||
friendly_name: "Wolfgang"
|
||||
ieee_address: "0x540f57fffe7e3cfe"
|
||||
model: "GS361A-H04"
|
||||
vendor: "Siterwell"
|
||||
- device_id: thermostat_kueche
|
||||
name: Heizung
|
||||
type: thermostat
|
||||
cap_version: "thermostat@1.0.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
heating: true
|
||||
temperature_range:
|
||||
- 5
|
||||
- 30
|
||||
temperature_step: 0.5
|
||||
topics:
|
||||
state: "zigbee2mqtt/0x94deb8fffe2e5c06"
|
||||
set: "zigbee2mqtt/0x94deb8fffe2e5c06/set"
|
||||
metadata:
|
||||
friendly_name: "Kueche"
|
||||
ieee_address: "0x94deb8fffe2e5c06"
|
||||
model: "GS361A-H04"
|
||||
vendor: "Siterwell"
|
||||
- device_id: thermostat_schlafzimmer
|
||||
name: Heizung
|
||||
type: thermostat
|
||||
cap_version: "thermostat@1.0.0"
|
||||
technology: max
|
||||
features:
|
||||
mode: true
|
||||
target: true
|
||||
current: false
|
||||
topics:
|
||||
set: "homegear/instance1/set/42/1/SET_TEMPERATURE"
|
||||
state: "homegear/instance1/plain/42/1/SET_TEMPERATURE"
|
||||
metadata:
|
||||
friendly_name: "Thermostat Schlafzimmer"
|
||||
location: "Schlafzimmer"
|
||||
vendor: "eQ-3"
|
||||
model: "MAX! Thermostat"
|
||||
peer_id: "42"
|
||||
channel: "1"
|
||||
- device_id: thermostat_esszimmer
|
||||
name: Heizung
|
||||
type: thermostat
|
||||
cap_version: "thermostat@1.0.0"
|
||||
technology: max
|
||||
features:
|
||||
mode: true
|
||||
target: true
|
||||
current: false
|
||||
topics:
|
||||
set: "homegear/instance1/set/45/1/SET_TEMPERATURE"
|
||||
state: "homegear/instance1/plain/45/1/SET_TEMPERATURE"
|
||||
metadata:
|
||||
friendly_name: "Thermostat Esszimmer"
|
||||
location: "Esszimmer"
|
||||
vendor: "eQ-3"
|
||||
model: "MAX! Thermostat"
|
||||
peer_id: "45"
|
||||
channel: "1"
|
||||
- device_id: thermostat_wohnzimmer
|
||||
name: Heizung
|
||||
type: thermostat
|
||||
cap_version: "thermostat@1.0.0"
|
||||
technology: max
|
||||
features:
|
||||
mode: true
|
||||
target: true
|
||||
current: false
|
||||
topics:
|
||||
set: "homegear/instance1/set/46/1/SET_TEMPERATURE"
|
||||
state: "homegear/instance1/plain/46/1/SET_TEMPERATURE"
|
||||
metadata:
|
||||
friendly_name: "Thermostat Wohnzimmer"
|
||||
location: "Wohnzimmer"
|
||||
vendor: "eQ-3"
|
||||
model: "MAX! Thermostat"
|
||||
peer_id: "46"
|
||||
channel: "1"
|
||||
- device_id: thermostat_patty
|
||||
name: Heizung
|
||||
type: thermostat
|
||||
cap_version: "thermostat@1.0.0"
|
||||
technology: max
|
||||
features:
|
||||
mode: true
|
||||
target: true
|
||||
current: false
|
||||
topics:
|
||||
set: "homegear/instance1/set/39/1/SET_TEMPERATURE"
|
||||
state: "homegear/instance1/plain/39/1/SET_TEMPERATURE"
|
||||
metadata:
|
||||
friendly_name: "Thermostat Patty"
|
||||
location: "Arbeitszimmer Patty"
|
||||
vendor: "eQ-3"
|
||||
model: "MAX! Thermostat"
|
||||
peer_id: "39"
|
||||
channel: "1"
|
||||
- device_id: thermostat_bad_oben
|
||||
name: Heizung
|
||||
type: thermostat
|
||||
cap_version: "thermostat@1.0.0"
|
||||
technology: max
|
||||
features:
|
||||
mode: true
|
||||
target: true
|
||||
current: false
|
||||
topics:
|
||||
set: "homegear/instance1/set/41/1/SET_TEMPERATURE"
|
||||
state: "homegear/instance1/plain/41/1/SET_TEMPERATURE"
|
||||
metadata:
|
||||
friendly_name: "Thermostat Bad Oben"
|
||||
location: "Bad Oben"
|
||||
vendor: "eQ-3"
|
||||
model: "MAX! Thermostat"
|
||||
peer_id: "41"
|
||||
channel: "1"
|
||||
- device_id: thermostat_bad_unten
|
||||
name: Heizung
|
||||
type: thermostat
|
||||
cap_version: "thermostat@1.0.0"
|
||||
technology: max
|
||||
features:
|
||||
mode: true
|
||||
target: true
|
||||
current: false
|
||||
topics:
|
||||
set: "homegear/instance1/set/48/1/SET_TEMPERATURE"
|
||||
state: "homegear/instance1/plain/48/1/SET_TEMPERATURE"
|
||||
metadata:
|
||||
friendly_name: "Thermostat Bad Unten"
|
||||
location: "Bad Unten"
|
||||
vendor: "eQ-3"
|
||||
model: "MAX! Thermostat"
|
||||
peer_id: "48"
|
||||
channel: "1"
|
||||
- device_id: sterne_wohnzimmer
|
||||
name: Sterne
|
||||
type: relay
|
||||
cap_version: "relay@1.0.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0xf0d1b80000155fc2"
|
||||
set: "zigbee2mqtt/0xf0d1b80000155fc2/set"
|
||||
metadata:
|
||||
friendly_name: "Sterne Wohnzimmer"
|
||||
ieee_address: "0xf0d1b80000155fc2"
|
||||
model: "AC10691"
|
||||
vendor: "OSRAM"
|
||||
- device_id: kontakt_schlafzimmer_strasse
|
||||
name: Fenster
|
||||
type: contact
|
||||
cap_version: contact_sensor@1.0.0
|
||||
technology: max
|
||||
topics:
|
||||
state: homegear/instance1/plain/52/1/STATE
|
||||
features: {}
|
||||
- device_id: kontakt_esszimmer_strasse_rechts
|
||||
type: contact
|
||||
name: Fenster rechts
|
||||
cap_version: contact_sensor@1.0.0
|
||||
technology: max
|
||||
topics:
|
||||
state: homegear/instance1/plain/26/1/STATE
|
||||
features: {}
|
||||
- device_id: kontakt_esszimmer_strasse_links
|
||||
name: Fenster links
|
||||
type: contact
|
||||
cap_version: contact_sensor@1.0.0
|
||||
technology: max
|
||||
topics:
|
||||
state: homegear/instance1/plain/27/1/STATE
|
||||
features: {}
|
||||
- device_id: kontakt_wohnzimmer_garten_rechts
|
||||
name: Fenster rechts
|
||||
type: contact
|
||||
cap_version: contact_sensor@1.0.0
|
||||
technology: max
|
||||
topics:
|
||||
state: homegear/instance1/plain/28/1/STATE
|
||||
features: {}
|
||||
- device_id: kontakt_wohnzimmer_garten_links
|
||||
name: Fenster links
|
||||
type: contact
|
||||
cap_version: contact_sensor@1.0.0
|
||||
technology: max
|
||||
topics:
|
||||
state: homegear/instance1/plain/29/1/STATE
|
||||
features: {}
|
||||
- device_id: kontakt_kueche_garten_fenster
|
||||
name: Fenster Garten
|
||||
type: contact
|
||||
cap_version: contact_sensor@1.0.0
|
||||
technology: zigbee2mqtt
|
||||
topics:
|
||||
state: zigbee2mqtt/0x00158d008b332785
|
||||
features: {}
|
||||
- device_id: kontakt_kueche_garten_tuer
|
||||
type: contact
|
||||
name: Terrassentür
|
||||
cap_version: contact_sensor@1.0.0
|
||||
technology: zigbee2mqtt
|
||||
topics:
|
||||
state: zigbee2mqtt/0x00158d008b332788
|
||||
features: {}
|
||||
- device_id: kontakt_kueche_strasse_rechts
|
||||
name: Fenster Straße rechts
|
||||
type: contact
|
||||
cap_version: contact_sensor@1.0.0
|
||||
technology: zigbee2mqtt
|
||||
topics:
|
||||
state: zigbee2mqtt/0x00158d008b151803
|
||||
features: {}
|
||||
- device_id: kontakt_kueche_strasse_links
|
||||
name: Fenster Straße links
|
||||
type: contact
|
||||
cap_version: contact_sensor@1.0.0
|
||||
technology: zigbee2mqtt
|
||||
topics:
|
||||
state: zigbee2mqtt/0x00158d008b331d0b
|
||||
features: {}
|
||||
- device_id: kontakt_patty_garten_rechts
|
||||
type: contact
|
||||
name: Fenster Garten rechts
|
||||
cap_version: contact_sensor@1.0.0
|
||||
technology: max
|
||||
topics:
|
||||
state: homegear/instance1/plain/18/1/STATE
|
||||
features: {}
|
||||
- device_id: kontakt_patty_garten_links
|
||||
type: contact
|
||||
name: Fenster Garten links
|
||||
cap_version: contact_sensor@1.0.0
|
||||
technology: max
|
||||
topics:
|
||||
state: homegear/instance1/plain/22/1/STATE
|
||||
features: {}
|
||||
- device_id: kontakt_patty_strasse
|
||||
type: contact
|
||||
name: Fenster Straße
|
||||
cap_version: contact_sensor@1.0.0
|
||||
technology: zigbee2mqtt
|
||||
topics:
|
||||
state: zigbee2mqtt/0x00158d000af457cf
|
||||
features: {}
|
||||
- device_id: kontakt_wolfgang_garten
|
||||
type: contact
|
||||
name: Fenster
|
||||
cap_version: contact_sensor@1.0.0
|
||||
technology: zigbee2mqtt
|
||||
topics:
|
||||
state: zigbee2mqtt/0x00158d008b3328da
|
||||
features: {}
|
||||
- device_id: kontakt_bad_oben_strasse
|
||||
type: contact
|
||||
name: Fenster
|
||||
cap_version: contact_sensor@1.0.0
|
||||
technology: zigbee2mqtt
|
||||
topics:
|
||||
state: zigbee2mqtt/0x00158d008b333aec
|
||||
features: {}
|
||||
- device_id: kontakt_bad_unten_strasse
|
||||
type: contact
|
||||
name: Fenster
|
||||
cap_version: contact_sensor@1.0.0
|
||||
technology: max
|
||||
topics:
|
||||
state: homegear/instance1/plain/44/1/STATE
|
||||
features: {}
|
||||
- device_id: sensor_schlafzimmer
|
||||
type: temp_humidity_sensor
|
||||
name: Thermometer
|
||||
cap_version: temp_humidity_sensor@1.0.0
|
||||
technology: zigbee2mqtt
|
||||
topics:
|
||||
state: zigbee2mqtt/0x00158d00043292dc
|
||||
features: {}
|
||||
- device_id: sensor_wohnzimmer
|
||||
type: temp_humidity_sensor
|
||||
name: Thermometer
|
||||
cap_version: temp_humidity_sensor@1.0.0
|
||||
technology: zigbee2mqtt
|
||||
topics:
|
||||
state: zigbee2mqtt/0x00158d0008975707
|
||||
features: {}
|
||||
- device_id: sensor_kueche
|
||||
type: temp_humidity_sensor
|
||||
name: Thermometer
|
||||
cap_version: temp_humidity_sensor@1.0.0
|
||||
technology: zigbee2mqtt
|
||||
topics:
|
||||
state: zigbee2mqtt/0x00158d00083299bb
|
||||
features: {}
|
||||
- device_id: sensor_arbeitszimmer_patty
|
||||
type: temp_humidity_sensor
|
||||
name: Thermometer
|
||||
cap_version: temp_humidity_sensor@1.0.0
|
||||
technology: zigbee2mqtt
|
||||
topics:
|
||||
state: zigbee2mqtt/0x00158d0003f052b7
|
||||
features: {}
|
||||
- device_id: sensor_arbeitszimmer_wolfgang
|
||||
type: temp_humidity_sensor
|
||||
name: Thermometer
|
||||
cap_version: temp_humidity_sensor@1.0.0
|
||||
technology: zigbee2mqtt
|
||||
topics:
|
||||
state: zigbee2mqtt/0x00158d000543fb99
|
||||
features: {}
|
||||
- device_id: sensor_bad_oben
|
||||
type: temp_humidity_sensor
|
||||
name: Thermometer
|
||||
cap_version: temp_humidity_sensor@1.0.0
|
||||
technology: zigbee2mqtt
|
||||
topics:
|
||||
state: zigbee2mqtt/0x00158d00093e8987
|
||||
features: {}
|
||||
- device_id: sensor_bad_unten
|
||||
type: temp_humidity_sensor
|
||||
name: Thermometer
|
||||
cap_version: temp_humidity_sensor@1.0.0
|
||||
technology: zigbee2mqtt
|
||||
topics:
|
||||
state: zigbee2mqtt/0x00158d00093e662a
|
||||
features: {}
|
||||
- device_id: sensor_flur
|
||||
type: temp_humidity_sensor
|
||||
name: Thermometer
|
||||
cap_version: temp_humidity_sensor@1.0.0
|
||||
technology: zigbee2mqtt
|
||||
topics:
|
||||
state: zigbee2mqtt/0x00158d000836ccc6
|
||||
features: {}
|
||||
- device_id: sensor_waschkueche
|
||||
type: temp_humidity_sensor
|
||||
name: Thermometer
|
||||
cap_version: temp_humidity_sensor@1.0.0
|
||||
technology: zigbee2mqtt
|
||||
topics:
|
||||
state: zigbee2mqtt/0x00158d000449f3bc
|
||||
features: {}
|
||||
- device_id: sensor_sportzimmer
|
||||
type: temp_humidity_sensor
|
||||
name: Thermometer
|
||||
cap_version: temp_humidity_sensor@1.0.0
|
||||
technology: zigbee2mqtt
|
||||
topics:
|
||||
state: zigbee2mqtt/0x00158d0009421422
|
||||
features: {}
|
||||
- device_id: licht_spuele_kueche
|
||||
name: Spüle
|
||||
type: relay
|
||||
cap_version: "relay@1.0.0"
|
||||
technology: shelly
|
||||
features:
|
||||
power: true
|
||||
topics:
|
||||
set: "shellies/LightKitchenSink/relay/0/command"
|
||||
state: "shellies/LightKitchenSink/relay/0"
|
||||
- device_id: licht_schrank_esszimmer
|
||||
name: Schrank
|
||||
type: relay
|
||||
cap_version: "relay@1.0.0"
|
||||
technology: shelly
|
||||
features:
|
||||
power: true
|
||||
topics:
|
||||
set: "shellies/schrankesszimmer/relay/0/command"
|
||||
state: "shellies/schrankesszimmer/relay/0"
|
||||
- device_id: licht_regal_wohnzimmer
|
||||
type: relay
|
||||
name: Regal
|
||||
cap_version: "relay@1.0.0"
|
||||
technology: shelly
|
||||
features:
|
||||
power: true
|
||||
topics:
|
||||
set: "shellies/wohnzimmer-regal/relay/0/command"
|
||||
state: "shellies/wohnzimmer-regal/relay/0"
|
||||
- device_id: licht_flur_schrank
|
||||
type: relay
|
||||
name: Schrank
|
||||
cap_version: "relay@1.0.0"
|
||||
technology: shelly
|
||||
features:
|
||||
power: true
|
||||
topics:
|
||||
set: "shellies/schrankflur/relay/0/command"
|
||||
state: "shellies/schrankflur/relay/0"
|
||||
- device_id: licht_terasse
|
||||
name: Terrasse
|
||||
type: relay
|
||||
cap_version: "relay@1.0.0"
|
||||
technology: shelly
|
||||
features:
|
||||
power: true
|
||||
topics:
|
||||
set: "shellies/lichtterasse/relay/0/command"
|
||||
state: "shellies/lichtterasse/relay/0"
|
||||
|
||||
- device_id: power_relay_caroutlet
|
||||
name: Car Outlet
|
||||
type: relay
|
||||
cap_version: "relay@1.0.0"
|
||||
technology: hottis_modbus
|
||||
features:
|
||||
power: true
|
||||
topics:
|
||||
set: "caroutlet/cmd"
|
||||
state: "caroutlet/state"
|
||||
|
||||
- device_id: powermeter_caroutlet
|
||||
name: Car Outlet
|
||||
type: three_phase_powermeter
|
||||
cap_version: "three_phase_powermeter@1.0.0"
|
||||
technology: hottis_modbus
|
||||
topics:
|
||||
state: "caroutlet/powermeter"
|
||||
|
||||
|
||||
|
||||
36
config/groups.yaml
Normal file
36
config/groups.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
version: 1
|
||||
groups:
|
||||
- id: "kueche_lichter"
|
||||
name: "Küche – alle Lampen"
|
||||
selector:
|
||||
type: "light"
|
||||
room: "Küche"
|
||||
capabilities:
|
||||
power: true
|
||||
brightness: true
|
||||
|
||||
- id: "alles_lichter"
|
||||
name: "Alle Lichter"
|
||||
selector:
|
||||
type: "light"
|
||||
capabilities:
|
||||
power: true
|
||||
|
||||
- id: "schlafzimmer_lichter"
|
||||
name: "Schlafzimmer – alle Lampen"
|
||||
selector:
|
||||
type: "light"
|
||||
room: "Schlafzimmer"
|
||||
capabilities:
|
||||
power: true
|
||||
brightness: true
|
||||
|
||||
- id: "schlafzimmer_schlummer_licht"
|
||||
name: "Schlafzimmer – Schlummerlicht"
|
||||
device_ids:
|
||||
- bettlicht_patty
|
||||
- bettlicht_wolfgang
|
||||
- medusalampe_schlafzimmer
|
||||
capabilities:
|
||||
power: true
|
||||
brightness: true
|
||||
@@ -1,25 +1,293 @@
|
||||
# UI Layout Configuration
|
||||
# Defines rooms and device tiles for the home automation UI
|
||||
|
||||
rooms:
|
||||
- name: Wohnzimmer
|
||||
devices:
|
||||
- device_id: test_lampe_2
|
||||
title: Deckenlampe
|
||||
icon: "💡"
|
||||
rank: 5
|
||||
- device_id: test_lampe_1
|
||||
title: Stehlampe
|
||||
icon: "<22>"
|
||||
rank: 10
|
||||
|
||||
- name: Schlafzimmer
|
||||
devices:
|
||||
- device_id: test_lampe_3
|
||||
title: Nachttischlampe
|
||||
icon: "🛏️"
|
||||
rank: 10
|
||||
|
||||
|
||||
- name: Schlafzimmer
|
||||
devices:
|
||||
- device_id: bettlicht_patty
|
||||
title: Bettlicht Patty
|
||||
icon: 🛏️
|
||||
rank: 10
|
||||
- device_id: bettlicht_wolfgang
|
||||
title: Bettlicht Wolfgang
|
||||
icon: 🛏️
|
||||
rank: 20
|
||||
- device_id: deckenlampe_schlafzimmer
|
||||
title: Deckenlampe Schlafzimmer
|
||||
icon: 💡
|
||||
rank: 30
|
||||
- device_id: medusalampe_schlafzimmer
|
||||
title: Medusa-Lampe Schlafzimmer
|
||||
icon: 💡
|
||||
rank: 40
|
||||
- device_id: thermostat_schlafzimmer
|
||||
title: Thermostat Schlafzimmer
|
||||
icon: 🌡️
|
||||
rank: 45
|
||||
- device_id: kontakt_schlafzimmer_strasse
|
||||
title: Kontakt Straße
|
||||
icon: 🪟
|
||||
rank: 46
|
||||
- device_id: sensor_schlafzimmer
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 47
|
||||
- name: Esszimmer
|
||||
devices:
|
||||
- device_id: deckenlampe_esszimmer
|
||||
title: Deckenlampe Esszimmer
|
||||
icon: 💡
|
||||
rank: 50
|
||||
- device_id: leselampe_esszimmer
|
||||
title: Leselampe Esszimmer
|
||||
icon: 💡
|
||||
rank: 60
|
||||
# - device_id: standlampe_esszimmer
|
||||
# title: Standlampe Esszimmer
|
||||
# icon: 💡
|
||||
# rank: 70
|
||||
- device_id: kleine_lampe_links_esszimmer
|
||||
title: kleine Lampe links Esszimmer
|
||||
icon: 💡
|
||||
rank: 80
|
||||
- device_id: stehlampe_esszimmer_spiegel
|
||||
title: Stehlampe Esszimmer Spiegel
|
||||
icon: 💡
|
||||
rank: 81
|
||||
- device_id: stehlampe_esszimmer_schrank
|
||||
title: Stehlampe Esszimmer Schrank
|
||||
icon: 💡
|
||||
rank: 82
|
||||
# - device_id: kleine_lampe_rechts_esszimmer
|
||||
# title: kleine Lampe rechts Esszimmer
|
||||
# icon: 💡
|
||||
# rank: 90
|
||||
- device_id: licht_schrank_esszimmer
|
||||
title: Schranklicht Esszimmer
|
||||
icon: 💡
|
||||
rank: 92
|
||||
- device_id: thermostat_esszimmer
|
||||
title: Thermostat Esszimmer
|
||||
icon: 🌡️
|
||||
rank: 95
|
||||
- device_id: kontakt_esszimmer_strasse_rechts
|
||||
title: Kontakt Straße rechtsFtest
|
||||
icon: 🪟
|
||||
rank: 96
|
||||
- device_id: kontakt_esszimmer_strasse_links
|
||||
title: Kontakt Straße links
|
||||
icon: 🪟
|
||||
rank: 97
|
||||
- name: Wohnzimmer
|
||||
devices:
|
||||
- device_id: lampe_naehtischchen_wohnzimmer
|
||||
title: Lampe Naehtischchen Wohnzimmer
|
||||
icon: 💡
|
||||
rank: 100
|
||||
- device_id: lampe_semeniere_wohnzimmer
|
||||
title: Lampe Semeniere Wohnzimmer
|
||||
icon: 💡
|
||||
rank: 110
|
||||
- device_id: sterne_wohnzimmer
|
||||
title: Sterne Wohnzimmer
|
||||
icon: 💡
|
||||
rank: 120
|
||||
- device_id: grosse_lampe_wohnzimmer
|
||||
title: grosse Lampe Wohnzimmer
|
||||
icon: 💡
|
||||
rank: 130
|
||||
- device_id: licht_regal_wohnzimmer
|
||||
title: Regallicht Wohnzimmer
|
||||
icon: 💡
|
||||
rank: 132
|
||||
- device_id: thermostat_wohnzimmer
|
||||
title: Thermostat Wohnzimmer
|
||||
icon: 🌡️
|
||||
rank: 135
|
||||
- device_id: kontakt_wohnzimmer_garten_rechts
|
||||
title: Kontakt Garten rechts
|
||||
icon: 🪟
|
||||
rank: 136
|
||||
- device_id: kontakt_wohnzimmer_garten_links
|
||||
title: Kontakt Garten links
|
||||
icon: 🪟
|
||||
rank: 137
|
||||
- device_id: sensor_wohnzimmer
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 138
|
||||
- name: Küche
|
||||
devices:
|
||||
- device_id: kueche_deckenlampe
|
||||
title: Küche Deckenlampe
|
||||
icon: 💡
|
||||
rank: 140
|
||||
- device_id: licht_spuele_kueche
|
||||
title: Küche Spüle
|
||||
icon: 💡
|
||||
rank: 142
|
||||
- device_id: thermostat_kueche
|
||||
title: Kueche
|
||||
icon: 🌡️
|
||||
rank: 150
|
||||
- device_id: kontakt_kueche_garten_fenster
|
||||
title: Kontakt Garten Fenster
|
||||
icon: 🪟
|
||||
rank: 151
|
||||
- device_id: kontakt_kueche_garten_tuer
|
||||
title: Kontakt Garten Tür
|
||||
icon: 🪟
|
||||
rank: 152
|
||||
- device_id: kontakt_kueche_strasse_rechts
|
||||
title: Kontakt Straße rechts
|
||||
icon: 🪟
|
||||
rank: 153
|
||||
- device_id: kontakt_kueche_strasse_links
|
||||
title: Kontakt Straße links
|
||||
icon: 🪟
|
||||
rank: 154
|
||||
- device_id: sensor_kueche
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 155
|
||||
- name: Arbeitszimmer Patty
|
||||
devices:
|
||||
- device_id: leselampe_patty
|
||||
title: Leselampe Patty
|
||||
icon: 💡
|
||||
rank: 160
|
||||
- device_id: schranklicht_hinten_patty
|
||||
title: Schranklicht hinten Patty
|
||||
icon: 💡
|
||||
rank: 170
|
||||
- device_id: schranklicht_vorne_patty
|
||||
title: Schranklicht vorne Patty
|
||||
icon: 💡
|
||||
rank: 180
|
||||
- device_id: thermostat_patty
|
||||
title: Thermostat Patty
|
||||
icon: 🌡️
|
||||
rank: 185
|
||||
- device_id: kontakt_patty_garten_rechts
|
||||
title: Kontakt Garten rechts
|
||||
icon: 🪟
|
||||
rank: 186
|
||||
- device_id: kontakt_patty_garten_links
|
||||
title: Kontakt Garten links
|
||||
icon: 🪟
|
||||
rank: 187
|
||||
- device_id: kontakt_patty_strasse
|
||||
title: Kontakt Straße
|
||||
icon: 🪟
|
||||
rank: 188
|
||||
- device_id: sensor_arbeitszimmer_patty
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 189
|
||||
- name: Arbeitszimmer Wolfgang
|
||||
devices:
|
||||
- device_id: thermostat_wolfgang
|
||||
title: Wolfgang
|
||||
icon: 🌡️
|
||||
rank: 190
|
||||
- device_id: experimentlabtest
|
||||
title: ExperimentLabTest
|
||||
icon: 💡
|
||||
rank: 200
|
||||
- device_id: kontakt_wolfgang_garten
|
||||
title: Kontakt Garten
|
||||
icon: 🪟
|
||||
rank: 201
|
||||
- device_id: sensor_arbeitszimmer_wolfgang
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 202
|
||||
- name: Flur
|
||||
devices:
|
||||
- device_id: deckenlampe_flur_oben
|
||||
title: Deckenlampe Flur oben
|
||||
icon: 💡
|
||||
rank: 210
|
||||
- device_id: haustuer
|
||||
title: Haustür
|
||||
icon: 💡
|
||||
rank: 220
|
||||
- device_id: licht_flur_schrank
|
||||
title: Schranklicht Flur
|
||||
icon: 💡
|
||||
rank: 222
|
||||
- device_id: licht_flur_oben_am_spiegel
|
||||
title: Licht Flur oben am Spiegel
|
||||
icon: 💡
|
||||
rank: 230
|
||||
- device_id: sensor_flur
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 235
|
||||
- name: Sportzimmer
|
||||
devices:
|
||||
- device_id: sportlicht_regal
|
||||
title: Sportlicht Regal
|
||||
icon: 🏃
|
||||
rank: 240
|
||||
- device_id: sportlicht_tisch
|
||||
title: Sportlicht Tisch
|
||||
icon: 🏃
|
||||
rank: 250
|
||||
- device_id: sportlicht_am_fernseher_studierzimmer
|
||||
title: Sportlicht am Fernseher, Studierzimmer
|
||||
icon: 🏃
|
||||
rank: 260
|
||||
- device_id: sensor_sportzimmer
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 265
|
||||
- name: Bad Oben
|
||||
devices:
|
||||
- device_id: thermostat_bad_oben
|
||||
title: Thermostat Bad Oben
|
||||
icon: 🌡️
|
||||
rank: 270
|
||||
- device_id: kontakt_bad_oben_strasse
|
||||
title: Kontakt Straße
|
||||
icon: 🪟
|
||||
rank: 271
|
||||
- device_id: sensor_bad_oben
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 272
|
||||
- name: Bad Unten
|
||||
devices:
|
||||
- device_id: thermostat_bad_unten
|
||||
title: Thermostat Bad Unten
|
||||
icon: 🌡️
|
||||
rank: 280
|
||||
- device_id: kontakt_bad_unten_strasse
|
||||
title: Kontakt Straße
|
||||
icon: 🪟
|
||||
rank: 281
|
||||
- device_id: sensor_bad_unten
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 282
|
||||
- name: Waschküche
|
||||
devices:
|
||||
- device_id: sensor_waschkueche
|
||||
title: Temperatur & Luftfeuchte
|
||||
icon: 🌡️
|
||||
rank: 290
|
||||
- name: Outdoor
|
||||
devices:
|
||||
- device_id: licht_terasse
|
||||
title: Licht Terasse
|
||||
icon: 💡
|
||||
rank: 290
|
||||
- name: Garage
|
||||
devices:
|
||||
- device_id: power_relay_caroutlet
|
||||
title: Ladestrom
|
||||
icon: ⚡
|
||||
rank: 310
|
||||
- device_id: powermeter_caroutlet
|
||||
title: Ladestrom
|
||||
icon: 📊
|
||||
rank: 320
|
||||
|
||||
|
||||
|
||||
94
config/rules.yaml
Normal file
94
config/rules.yaml
Normal file
@@ -0,0 +1,94 @@
|
||||
# Rules Configuration
|
||||
# Auto-generated from devices.yaml
|
||||
|
||||
rules:
|
||||
- id: window_setback_esszimmer
|
||||
enabled: false
|
||||
name: Fensterabsenkung Esszimmer
|
||||
type: window_setback@1.0
|
||||
objects:
|
||||
contacts:
|
||||
- kontakt_esszimmer_strasse_links
|
||||
- kontakt_esszimmer_strasse_rechts
|
||||
thermostats:
|
||||
- thermostat_esszimmer
|
||||
params:
|
||||
eco_target: 16.0
|
||||
open_min_secs: 20
|
||||
close_min_secs: 20
|
||||
previous_target_ttl_secs: 86400
|
||||
- id: window_setback_kueche
|
||||
enabled: false
|
||||
name: Fensterabsenkung Küche
|
||||
type: window_setback@1.0
|
||||
objects:
|
||||
contacts:
|
||||
- kontakt_kueche_garten_fenster
|
||||
- kontakt_kueche_garten_tuer
|
||||
- kontakt_kueche_strasse_links
|
||||
- kontakt_kueche_strasse_rechts
|
||||
thermostats:
|
||||
- thermostat_kueche
|
||||
params:
|
||||
eco_target: 16.0
|
||||
open_min_secs: 20
|
||||
close_min_secs: 20
|
||||
previous_target_ttl_secs: 86400
|
||||
- id: window_setback_patty
|
||||
enabled: false
|
||||
name: Fensterabsenkung Arbeitszimmer Patty
|
||||
type: window_setback@1.0
|
||||
objects:
|
||||
contacts:
|
||||
- kontakt_patty_garten_links
|
||||
- kontakt_patty_garten_rechts
|
||||
- kontakt_patty_strasse
|
||||
thermostats:
|
||||
- thermostat_patty
|
||||
params:
|
||||
eco_target: 16.0
|
||||
open_min_secs: 20
|
||||
close_min_secs: 20
|
||||
previous_target_ttl_secs: 86400
|
||||
- id: window_setback_schlafzimmer
|
||||
enabled: false
|
||||
name: Fensterabsenkung Schlafzimmer
|
||||
type: window_setback@1.0
|
||||
objects:
|
||||
contacts:
|
||||
- kontakt_schlafzimmer_strasse
|
||||
thermostats:
|
||||
- thermostat_schlafzimmer
|
||||
params:
|
||||
eco_target: 16.0
|
||||
open_min_secs: 20
|
||||
close_min_secs: 20
|
||||
previous_target_ttl_secs: 86400
|
||||
- id: window_setback_wohnzimmer
|
||||
enabled: false
|
||||
name: Fensterabsenkung Wohnzimmer
|
||||
type: window_setback@1.0
|
||||
objects:
|
||||
contacts:
|
||||
- kontakt_wohnzimmer_garten_links
|
||||
- kontakt_wohnzimmer_garten_rechts
|
||||
thermostats:
|
||||
- thermostat_wohnzimmer
|
||||
params:
|
||||
eco_target: 16.0
|
||||
open_min_secs: 20
|
||||
close_min_secs: 20
|
||||
previous_target_ttl_secs: 86400
|
||||
- id: window_setback_wolfgang
|
||||
enabled: true
|
||||
name: Fensterabsenkung Arbeitszimmer Wolfgang
|
||||
type: window_setback@1.0
|
||||
objects:
|
||||
contacts:
|
||||
- kontakt_wolfgang_garten
|
||||
thermostats:
|
||||
- thermostat_wolfgang
|
||||
params:
|
||||
eco_target: 16.0
|
||||
open_min_secs: 20
|
||||
close_min_secs: 20
|
||||
24
config/scenes.yaml
Normal file
24
config/scenes.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
version: 1
|
||||
scenes:
|
||||
- id: "alles_aus"
|
||||
name: "Alles aus"
|
||||
steps:
|
||||
- selector: { type: "light" }
|
||||
action:
|
||||
type: "light"
|
||||
payload: { power: "off" }
|
||||
- selector: { type: "relay" }
|
||||
action:
|
||||
type: "relay"
|
||||
payload: { power: "off" }
|
||||
|
||||
- id: "kueche_gemuetlich"
|
||||
name: "Küche gemütlich"
|
||||
steps:
|
||||
- group_id: "kueche_lichter"
|
||||
action:
|
||||
type: "light"
|
||||
payload:
|
||||
power: "on"
|
||||
brightness: 35
|
||||
|
||||
25
debug/2025-11-18-01-MQTT_message_received_on.log
Normal file
25
debug/2025-11-18-01-MQTT_message_received_on.log
Normal file
@@ -0,0 +1,25 @@
|
||||
abstraction | 2025-11-18 12:04:42,875 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/42/1/SET_TEMPERATURE: 21
|
||||
abstraction | 2025-11-18 12:04:42,914 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/45/1/SET_TEMPERATURE: 15
|
||||
abstraction | 2025-11-18 12:04:42,950 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/46/1/SET_TEMPERATURE: 15
|
||||
abstraction | 2025-11-18 12:04:42,987 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/39/1/SET_TEMPERATURE: 22
|
||||
abstraction | 2025-11-18 12:04:43,029 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/41/1/SET_TEMPERATURE: 20
|
||||
abstraction | 2025-11-18 12:04:43,071 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/48/1/SET_TEMPERATURE: 21
|
||||
abstraction | 2025-11-18 12:04:43,108 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/52/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,145 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/26/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,182 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/27/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,219 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/28/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,256 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/29/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,292 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/18/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,331 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/22/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,368 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/44/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:48,498 - __main__ - DEBUG - MQTT message received on shellies/schrankesszimmer/relay/0: off
|
||||
abstraction | 2025-11-18 12:04:52,989 - __main__ - DEBUG - MQTT message received on zigbee2mqtt/0x00158d0003f052b7: {"battery":100,"humidity":55.04,"linkquality":83,"power_outage_count":38416,"pressure":1002.6,"temperature":22.13,"voltage":3015}
|
||||
abstraction | 2025-11-18 12:04:53,024 - __main__ - DEBUG - MQTT message received on zigbee2mqtt/0x00158d0003f052b7: {"battery":100,"humidity":54.82,"linkquality":83,"power_outage_count":38416,"pressure":1002.6,"temperature":22.13,"voltage":3015}
|
||||
abstraction | 2025-11-18 12:04:53,061 - __main__ - DEBUG - MQTT message received on zigbee2mqtt/0x00158d0003f052b7: {"battery":100,"humidity":54.82,"linkquality":83,"power_outage_count":38416,"pressure":1002.4,"temperature":22.13,"voltage":3015}
|
||||
abstraction | 2025-11-18 12:05:03,058 - __main__ - DEBUG - MQTT message received on shellies/lichtterasse/relay/0: off
|
||||
abstraction | 2025-11-18 12:05:08,209 - __main__ - DEBUG - MQTT message received on shellies/wohnzimmer-regal/relay/0: off
|
||||
abstraction | 2025-11-18 12:05:10,881 - __main__ - DEBUG - MQTT message received on shellies/LightKitchenSink/relay/0: on
|
||||
abstraction | 2025-11-18 12:05:12,622 - __main__ - DEBUG - MQTT message received on zigbee2mqtt/0x00158d00083299bb: {"battery":63,"humidity":47.69,"linkquality":87,"power_outage_count":4906,"pressure":1009.9,"temperature":19.74,"voltage":2945}
|
||||
abstraction | 2025-11-18 12:05:12,656 - __main__ - DEBUG - MQTT message received on zigbee2mqtt/0x00158d00083299bb: {"battery":63,"humidity":47.69,"linkquality":87,"power_outage_count":4906,"pressure":1009.9,"temperature":19.74,"voltage":2945}
|
||||
abstraction | 2025-11-18 12:05:12,690 - __main__ - DEBUG - MQTT message received on zigbee2mqtt/0x00158d00083299bb: {"battery":63,"humidity":47.69,"linkquality":87,"power_outage_count":4906,"pressure":1009.7,"temperature":19.74,"voltage":2945}
|
||||
abstraction | 2025-11-18 12:05:18,507 - __main__ - DEBUG - MQTT message received on shellies/schrankesszimmer/relay/0: off
|
||||
261
debug/2025-11-18-01-abstraction-debug.log
Normal file
261
debug/2025-11-18-01-abstraction-debug.log
Normal file
@@ -0,0 +1,261 @@
|
||||
abstraction | 2025-11-18 12:04:40,901 - asyncio - DEBUG - Using selector: EpollSelector
|
||||
abstraction | 2025-11-18 12:04:40,952 - __main__ - INFO - Loaded configuration from /app/config/devices.yaml
|
||||
abstraction | 2025-11-18 12:04:40,953 - __main__ - INFO - Loaded 64 device(s): lampe_semeniere_wohnzimmer, stehlampe_esszimmer_spiegel, stehlampe_esszimmer_schrank, grosse_lampe_wohnzimmer, lampe_naehtischchen_wohnzimmer, kleine_lampe_rechts_esszimmer, kleine_lampe_links_esszimmer, leselampe_esszimmer, medusalampe_schlafzimmer, sportlicht_am_fernseher_studierzimmer, deckenlampe_schlafzimmer, bettlicht_wolfgang, bettlicht_patty, schranklicht_hinten_patty, schranklicht_vorne_patty, leselampe_patty, deckenlampe_esszimmer, standlampe_esszimmer, haustuer, deckenlampe_flur_oben, kueche_deckenlampe, sportlicht_tisch, sportlicht_regal, licht_flur_oben_am_spiegel, experimentlabtest, thermostat_wolfgang, thermostat_kueche, thermostat_schlafzimmer, thermostat_esszimmer, thermostat_wohnzimmer, thermostat_patty, thermostat_bad_oben, thermostat_bad_unten, sterne_wohnzimmer, kontakt_schlafzimmer_strasse, kontakt_esszimmer_strasse_rechts, kontakt_esszimmer_strasse_links, kontakt_wohnzimmer_garten_rechts, kontakt_wohnzimmer_garten_links, kontakt_kueche_garten_fenster, kontakt_kueche_garten_tuer, kontakt_kueche_strasse_rechts, kontakt_kueche_strasse_links, kontakt_patty_garten_rechts, kontakt_patty_garten_links, kontakt_patty_strasse, kontakt_wolfgang_garten, kontakt_bad_oben_strasse, kontakt_bad_unten_strasse, sensor_schlafzimmer, sensor_wohnzimmer, sensor_kueche, sensor_arbeitszimmer_patty, sensor_arbeitszimmer_wolfgang, sensor_bad_oben, sensor_bad_unten, sensor_flur, sensor_waschkueche, sensor_sportzimmer, licht_spuele_kueche, licht_schrank_esszimmer, licht_regal_wohnzimmer, licht_flur_schrank, licht_terasse
|
||||
abstraction | 2025-11-18 12:04:40,953 - __main__ - INFO - Loaded 64 device(s) from configuration
|
||||
abstraction | 2025-11-18 12:04:41,003 - __main__ - INFO - Connected to Redis: redis://172.23.1.116:6379/8
|
||||
abstraction | 2025-11-18 12:04:41,003 - __main__ - INFO - Abstraction worker started
|
||||
abstraction | 2025-11-18 12:04:41,003 - __main__ - INFO - Connecting to MQTT broker: 172.23.1.102:1883
|
||||
abstraction | 2025-11-18 12:04:41,053 - __main__ - INFO - Connected to MQTT broker as home-automation-abstraction-b39304
|
||||
abstraction | 2025-11-18 12:04:41,072 - __main__ - INFO - Subscribed to abstract SET: home/relay/lampe_semeniere_wohnzimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,091 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b8000015480b
|
||||
abstraction | 2025-11-18 12:04:41,107 - __main__ - INFO - Subscribed to abstract SET: home/light/stehlampe_esszimmer_spiegel/set
|
||||
abstraction | 2025-11-18 12:04:41,125 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010d06ea09
|
||||
abstraction | 2025-11-18 12:04:41,141 - __main__ - INFO - Subscribed to abstract SET: home/light/stehlampe_esszimmer_schrank/set
|
||||
abstraction | 2025-11-18 12:04:41,159 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010d09176c
|
||||
abstraction | 2025-11-18 12:04:41,176 - __main__ - INFO - Subscribed to abstract SET: home/relay/grosse_lampe_wohnzimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,192 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000151aca
|
||||
abstraction | 2025-11-18 12:04:41,209 - __main__ - INFO - Subscribed to abstract SET: home/relay/lampe_naehtischchen_wohnzimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,225 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x842e14fffee560ee
|
||||
abstraction | 2025-11-18 12:04:41,242 - __main__ - INFO - Subscribed to abstract SET: home/relay/kleine_lampe_rechts_esszimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,259 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000156645
|
||||
abstraction | 2025-11-18 12:04:41,276 - __main__ - INFO - Subscribed to abstract SET: home/relay/kleine_lampe_links_esszimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,293 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000153099
|
||||
abstraction | 2025-11-18 12:04:41,310 - __main__ - INFO - Subscribed to abstract SET: home/light/leselampe_esszimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,327 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xec1bbdfffe7b84f2
|
||||
abstraction | 2025-11-18 12:04:41,344 - __main__ - INFO - Subscribed to abstract SET: home/relay/medusalampe_schlafzimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,361 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000154c7c
|
||||
abstraction | 2025-11-18 12:04:41,378 - __main__ - INFO - Subscribed to abstract SET: home/light/sportlicht_am_fernseher_studierzimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,395 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x842e14fffe76a23a
|
||||
abstraction | 2025-11-18 12:04:41,415 - __main__ - INFO - Subscribed to abstract SET: home/light/deckenlampe_schlafzimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,432 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x0017880108a406a7
|
||||
abstraction | 2025-11-18 12:04:41,449 - __main__ - INFO - Subscribed to abstract SET: home/light/bettlicht_wolfgang/set
|
||||
abstraction | 2025-11-18 12:04:41,466 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00178801081570bf
|
||||
abstraction | 2025-11-18 12:04:41,484 - __main__ - INFO - Subscribed to abstract SET: home/light/bettlicht_patty/set
|
||||
abstraction | 2025-11-18 12:04:41,500 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x0017880108158b32
|
||||
abstraction | 2025-11-18 12:04:41,518 - __main__ - INFO - Subscribed to abstract SET: home/light/schranklicht_hinten_patty/set
|
||||
abstraction | 2025-11-18 12:04:41,535 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x0017880106e29571
|
||||
abstraction | 2025-11-18 12:04:41,552 - __main__ - INFO - Subscribed to abstract SET: home/relay/schranklicht_vorne_patty/set
|
||||
abstraction | 2025-11-18 12:04:41,569 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000154cf5
|
||||
abstraction | 2025-11-18 12:04:41,586 - __main__ - INFO - Subscribed to abstract SET: home/light/leselampe_patty/set
|
||||
abstraction | 2025-11-18 12:04:41,603 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010600ec9d
|
||||
abstraction | 2025-11-18 12:04:41,620 - __main__ - INFO - Subscribed to abstract SET: home/light/deckenlampe_esszimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,637 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x0017880108a03e45
|
||||
abstraction | 2025-11-18 12:04:41,655 - __main__ - INFO - Subscribed to abstract SET: home/light/standlampe_esszimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,674 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xbc33acfffe21f547
|
||||
abstraction | 2025-11-18 12:04:41,692 - __main__ - INFO - Subscribed to abstract SET: home/light/haustuer/set
|
||||
abstraction | 2025-11-18 12:04:41,711 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xec1bbdfffea6a3da
|
||||
abstraction | 2025-11-18 12:04:41,728 - __main__ - INFO - Subscribed to abstract SET: home/light/deckenlampe_flur_oben/set
|
||||
abstraction | 2025-11-18 12:04:41,746 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010d2123a7
|
||||
abstraction | 2025-11-18 12:04:41,764 - __main__ - INFO - Subscribed to abstract SET: home/light/kueche_deckenlampe/set
|
||||
abstraction | 2025-11-18 12:04:41,781 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010d2c40c4
|
||||
abstraction | 2025-11-18 12:04:41,798 - __main__ - INFO - Subscribed to abstract SET: home/light/sportlicht_tisch/set
|
||||
abstraction | 2025-11-18 12:04:41,814 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b8be2409f31b
|
||||
abstraction | 2025-11-18 12:04:41,831 - __main__ - INFO - Subscribed to abstract SET: home/light/sportlicht_regal/set
|
||||
abstraction | 2025-11-18 12:04:41,848 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b8be2409f569
|
||||
abstraction | 2025-11-18 12:04:41,865 - __main__ - INFO - Subscribed to abstract SET: home/light/licht_flur_oben_am_spiegel/set
|
||||
abstraction | 2025-11-18 12:04:41,883 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x842e14fffefe4ba4
|
||||
abstraction | 2025-11-18 12:04:41,899 - __main__ - INFO - Subscribed to abstract SET: home/light/experimentlabtest/set
|
||||
abstraction | 2025-11-18 12:04:41,918 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000195038
|
||||
abstraction | 2025-11-18 12:04:41,936 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_wolfgang/set
|
||||
abstraction | 2025-11-18 12:04:41,955 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x540f57fffe7e3cfe
|
||||
abstraction | 2025-11-18 12:04:41,974 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_kueche/set
|
||||
abstraction | 2025-11-18 12:04:41,991 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x94deb8fffe2e5c06
|
||||
abstraction | 2025-11-18 12:04:42,008 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_schlafzimmer/set
|
||||
abstraction | 2025-11-18 12:04:42,025 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/42/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 12:04:42,042 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_esszimmer/set
|
||||
abstraction | 2025-11-18 12:04:42,059 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/45/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 12:04:42,080 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_wohnzimmer/set
|
||||
abstraction | 2025-11-18 12:04:42,097 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/46/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 12:04:42,114 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_patty/set
|
||||
abstraction | 2025-11-18 12:04:42,131 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/39/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 12:04:42,150 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_bad_oben/set
|
||||
abstraction | 2025-11-18 12:04:42,171 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/41/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 12:04:42,189 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_bad_unten/set
|
||||
abstraction | 2025-11-18 12:04:42,207 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/48/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 12:04:42,224 - __main__ - INFO - Subscribed to abstract SET: home/relay/sterne_wohnzimmer/set
|
||||
abstraction | 2025-11-18 12:04:42,240 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000155fc2
|
||||
abstraction | 2025-11-18 12:04:42,240 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_schlafzimmer_strasse
|
||||
abstraction | 2025-11-18 12:04:42,258 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/52/1/STATE
|
||||
abstraction | 2025-11-18 12:04:42,258 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_esszimmer_strasse_rechts
|
||||
abstraction | 2025-11-18 12:04:42,275 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/26/1/STATE
|
||||
abstraction | 2025-11-18 12:04:42,275 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_esszimmer_strasse_links
|
||||
abstraction | 2025-11-18 12:04:42,293 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/27/1/STATE
|
||||
abstraction | 2025-11-18 12:04:42,293 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_wohnzimmer_garten_rechts
|
||||
abstraction | 2025-11-18 12:04:42,313 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/28/1/STATE
|
||||
abstraction | 2025-11-18 12:04:42,313 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_wohnzimmer_garten_links
|
||||
abstraction | 2025-11-18 12:04:42,331 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/29/1/STATE
|
||||
abstraction | 2025-11-18 12:04:42,331 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_kueche_garten_fenster
|
||||
abstraction | 2025-11-18 12:04:42,351 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b332785
|
||||
abstraction | 2025-11-18 12:04:42,351 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_kueche_garten_tuer
|
||||
abstraction | 2025-11-18 12:04:42,371 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b332788
|
||||
abstraction | 2025-11-18 12:04:42,371 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_kueche_strasse_rechts
|
||||
abstraction | 2025-11-18 12:04:42,390 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b151803
|
||||
abstraction | 2025-11-18 12:04:42,390 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_kueche_strasse_links
|
||||
abstraction | 2025-11-18 12:04:42,408 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b331d0b
|
||||
abstraction | 2025-11-18 12:04:42,408 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_patty_garten_rechts
|
||||
abstraction | 2025-11-18 12:04:42,424 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/18/1/STATE
|
||||
abstraction | 2025-11-18 12:04:42,424 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_patty_garten_links
|
||||
abstraction | 2025-11-18 12:04:42,441 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/22/1/STATE
|
||||
abstraction | 2025-11-18 12:04:42,441 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_patty_strasse
|
||||
abstraction | 2025-11-18 12:04:42,462 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d000af457cf
|
||||
abstraction | 2025-11-18 12:04:42,462 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_wolfgang_garten
|
||||
abstraction | 2025-11-18 12:04:42,479 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b3328da
|
||||
abstraction | 2025-11-18 12:04:42,480 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_bad_oben_strasse
|
||||
abstraction | 2025-11-18 12:04:42,496 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b333aec
|
||||
abstraction | 2025-11-18 12:04:42,496 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_bad_unten_strasse
|
||||
abstraction | 2025-11-18 12:04:42,513 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/44/1/STATE
|
||||
abstraction | 2025-11-18 12:04:42,513 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_schlafzimmer
|
||||
abstraction | 2025-11-18 12:04:42,532 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d00043292dc
|
||||
abstraction | 2025-11-18 12:04:42,532 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_wohnzimmer
|
||||
abstraction | 2025-11-18 12:04:42,552 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d0008975707
|
||||
abstraction | 2025-11-18 12:04:42,552 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_kueche
|
||||
abstraction | 2025-11-18 12:04:42,571 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d00083299bb
|
||||
abstraction | 2025-11-18 12:04:42,571 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_arbeitszimmer_patty
|
||||
abstraction | 2025-11-18 12:04:42,589 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d0003f052b7
|
||||
abstraction | 2025-11-18 12:04:42,589 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_arbeitszimmer_wolfgang
|
||||
abstraction | 2025-11-18 12:04:42,608 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d000543fb99
|
||||
abstraction | 2025-11-18 12:04:42,608 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_bad_oben
|
||||
abstraction | 2025-11-18 12:04:42,625 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d00093e8987
|
||||
abstraction | 2025-11-18 12:04:42,625 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_bad_unten
|
||||
abstraction | 2025-11-18 12:04:42,645 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d00093e662a
|
||||
abstraction | 2025-11-18 12:04:42,645 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_flur
|
||||
abstraction | 2025-11-18 12:04:42,664 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d000836ccc6
|
||||
abstraction | 2025-11-18 12:04:42,664 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_waschkueche
|
||||
abstraction | 2025-11-18 12:04:42,682 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d000449f3bc
|
||||
abstraction | 2025-11-18 12:04:42,682 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_sportzimmer
|
||||
abstraction | 2025-11-18 12:04:42,699 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d0009421422
|
||||
abstraction | 2025-11-18 12:04:42,716 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_spuele_kueche/set
|
||||
abstraction | 2025-11-18 12:04:42,734 - __main__ - INFO - Subscribed to vendor STATE: shellies/LightKitchenSink/relay/0
|
||||
abstraction | 2025-11-18 12:04:42,751 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_schrank_esszimmer/set
|
||||
abstraction | 2025-11-18 12:04:42,770 - __main__ - INFO - Subscribed to vendor STATE: shellies/schrankesszimmer/relay/0
|
||||
abstraction | 2025-11-18 12:04:42,790 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_regal_wohnzimmer/set
|
||||
abstraction | 2025-11-18 12:04:42,807 - __main__ - INFO - Subscribed to vendor STATE: shellies/wohnzimmer-regal/relay/0
|
||||
abstraction | 2025-11-18 12:04:42,823 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_flur_schrank/set
|
||||
abstraction | 2025-11-18 12:04:42,841 - __main__ - INFO - Subscribed to vendor STATE: shellies/schrankflur/relay/0
|
||||
abstraction | 2025-11-18 12:04:42,858 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_terasse/set
|
||||
abstraction | 2025-11-18 12:04:42,875 - __main__ - INFO - Subscribed to vendor STATE: shellies/lichtterasse/relay/0
|
||||
abstraction | 2025-11-18 12:04:42,875 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/42/1/SET_TEMPERATURE: 21
|
||||
abstraction | 2025-11-18 12:04:42,875 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=21
|
||||
abstraction | 2025-11-18 12:04:42,876 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 21.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 12:04:42,876 - __main__ - INFO - ← abstract STATE thermostat_schlafzimmer: home/thermostat/thermostat_schlafzimmer/state → {"target": 21.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 12:04:42,897 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_schlafzimmer", "payload": {"target": 21.0, "mode": "heat"}, "ts": "2025-11-18T12:04:42.897310+00:00"}
|
||||
abstraction | 2025-11-18 12:04:42,914 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/45/1/SET_TEMPERATURE: 15
|
||||
abstraction | 2025-11-18 12:04:42,914 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=15
|
||||
abstraction | 2025-11-18 12:04:42,914 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 15.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 12:04:42,914 - __main__ - INFO - ← abstract STATE thermostat_esszimmer: home/thermostat/thermostat_esszimmer/state → {"target": 15.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 12:04:42,934 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_esszimmer", "payload": {"target": 15.0, "mode": "heat"}, "ts": "2025-11-18T12:04:42.934255+00:00"}
|
||||
abstraction | 2025-11-18 12:04:42,950 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/46/1/SET_TEMPERATURE: 15
|
||||
abstraction | 2025-11-18 12:04:42,950 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=15
|
||||
abstraction | 2025-11-18 12:04:42,950 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 15.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 12:04:42,951 - __main__ - INFO - ← abstract STATE thermostat_wohnzimmer: home/thermostat/thermostat_wohnzimmer/state → {"target": 15.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 12:04:42,970 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_wohnzimmer", "payload": {"target": 15.0, "mode": "heat"}, "ts": "2025-11-18T12:04:42.970936+00:00"}
|
||||
abstraction | 2025-11-18 12:04:42,987 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/39/1/SET_TEMPERATURE: 22
|
||||
abstraction | 2025-11-18 12:04:42,988 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=22
|
||||
abstraction | 2025-11-18 12:04:42,988 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 22.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 12:04:42,988 - __main__ - INFO - ← abstract STATE thermostat_patty: home/thermostat/thermostat_patty/state → {"target": 22.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 12:04:43,009 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_patty", "payload": {"target": 22.0, "mode": "heat"}, "ts": "2025-11-18T12:04:43.009673+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,029 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/41/1/SET_TEMPERATURE: 20
|
||||
abstraction | 2025-11-18 12:04:43,029 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=20
|
||||
abstraction | 2025-11-18 12:04:43,029 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 20.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 12:04:43,029 - __main__ - INFO - ← abstract STATE thermostat_bad_oben: home/thermostat/thermostat_bad_oben/state → {"target": 20.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 12:04:43,053 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_bad_oben", "payload": {"target": 20.0, "mode": "heat"}, "ts": "2025-11-18T12:04:43.053895+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,071 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/48/1/SET_TEMPERATURE: 21
|
||||
abstraction | 2025-11-18 12:04:43,071 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=21
|
||||
abstraction | 2025-11-18 12:04:43,071 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 21.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 12:04:43,072 - __main__ - INFO - ← abstract STATE thermostat_bad_unten: home/thermostat/thermostat_bad_unten/state → {"target": 21.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 12:04:43,092 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_bad_unten", "payload": {"target": 21.0, "mode": "heat"}, "ts": "2025-11-18T12:04:43.092210+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,108 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/52/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,108 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 12:04:43,108 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 12:04:43,109 - __main__ - INFO - ← abstract STATE kontakt_schlafzimmer_strasse: home/contact/kontakt_schlafzimmer_strasse/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 12:04:43,128 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_schlafzimmer_strasse", "payload": {"contact": "closed"}, "ts": "2025-11-18T12:04:43.128506+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,145 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/26/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,145 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 12:04:43,145 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 12:04:43,146 - __main__ - INFO - ← abstract STATE kontakt_esszimmer_strasse_rechts: home/contact/kontakt_esszimmer_strasse_rechts/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 12:04:43,165 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_esszimmer_strasse_rechts", "payload": {"contact": "closed"}, "ts": "2025-11-18T12:04:43.165958+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,182 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/27/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,182 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 12:04:43,183 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 12:04:43,183 - __main__ - INFO - ← abstract STATE kontakt_esszimmer_strasse_links: home/contact/kontakt_esszimmer_strasse_links/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 12:04:43,202 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_esszimmer_strasse_links", "payload": {"contact": "closed"}, "ts": "2025-11-18T12:04:43.202580+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,219 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/28/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,219 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 12:04:43,219 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 12:04:43,220 - __main__ - INFO - ← abstract STATE kontakt_wohnzimmer_garten_rechts: home/contact/kontakt_wohnzimmer_garten_rechts/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 12:04:43,239 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_wohnzimmer_garten_rechts", "payload": {"contact": "closed"}, "ts": "2025-11-18T12:04:43.239653+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,256 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/29/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,256 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 12:04:43,256 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 12:04:43,257 - __main__ - INFO - ← abstract STATE kontakt_wohnzimmer_garten_links: home/contact/kontakt_wohnzimmer_garten_links/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 12:04:43,275 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_wohnzimmer_garten_links", "payload": {"contact": "closed"}, "ts": "2025-11-18T12:04:43.275832+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,292 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/18/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,292 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 12:04:43,292 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 12:04:43,293 - __main__ - INFO - ← abstract STATE kontakt_patty_garten_rechts: home/contact/kontakt_patty_garten_rechts/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 12:04:43,314 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_patty_garten_rechts", "payload": {"contact": "closed"}, "ts": "2025-11-18T12:04:43.314579+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,331 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/22/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,331 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 12:04:43,331 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 12:04:43,332 - __main__ - INFO - ← abstract STATE kontakt_patty_garten_links: home/contact/kontakt_patty_garten_links/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 12:04:43,351 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_patty_garten_links", "payload": {"contact": "closed"}, "ts": "2025-11-18T12:04:43.351704+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,368 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/44/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,368 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 12:04:43,368 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 12:04:43,369 - __main__ - INFO - ← abstract STATE kontakt_bad_unten_strasse: home/contact/kontakt_bad_unten_strasse/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 12:04:43,388 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_bad_unten_strasse", "payload": {"contact": "closed"}, "ts": "2025-11-18T12:04:43.388390+00:00"}
|
||||
abstraction | 2025-11-18 12:04:48,498 - __main__ - DEBUG - MQTT message received on shellies/schrankesszimmer/relay/0: off
|
||||
abstraction | 2025-11-18 12:04:48,498 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 12:04:48,498 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 12:04:48,498 - __main__ - INFO - ← abstract STATE licht_schrank_esszimmer: home/relay/licht_schrank_esszimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 12:04:48,518 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_schrank_esszimmer", "payload": {"power": "off"}, "ts": "2025-11-18T12:04:48.518525+00:00"}
|
||||
abstraction | 2025-11-18 12:04:52,989 - __main__ - DEBUG - MQTT message received on zigbee2mqtt/0x00158d0003f052b7: {"battery":100,"humidity":55.04,"linkquality":83,"power_outage_count":38416,"pressure":1002.6,"temperature":22.13,"voltage":3015}
|
||||
abstraction | 2025-11-18 12:04:52,989 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={"battery":100,"humidity":55.04,"linkquality":83,"power_outage_count":38416,"pressure":1002.6,"temperature":22.13,"voltage":3015}
|
||||
abstraction | 2025-11-18 12:04:52,989 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 100, 'humidity': 55.04, 'linkquality': 83, 'power_outage_count': 38416, 'pressure': 1002.6, 'temperature': 22.13, 'voltage': 3015}
|
||||
abstraction | 2025-11-18 12:04:52,989 - __main__ - INFO - ← abstract STATE sensor_arbeitszimmer_patty: home/temp_humidity/sensor_arbeitszimmer_patty/state → {"battery": 100, "humidity": 55.04, "linkquality": 83, "power_outage_count": 38416, "pressure": 1002.6, "temperature": 22.13, "voltage": 3015}
|
||||
abstraction | 2025-11-18 12:04:53,009 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "sensor_arbeitszimmer_patty", "payload": {"battery": 100, "humidity": 55.04, "linkquality": 83, "power_outage_count": 38416, "pressure": 1002.6, "temperature": 22.13, "voltage": 3015}, "ts": "2025-11-18T12:04:53.009776+00:00"}
|
||||
abstraction | 2025-11-18 12:04:53,024 - __main__ - DEBUG - MQTT message received on zigbee2mqtt/0x00158d0003f052b7: {"battery":100,"humidity":54.82,"linkquality":83,"power_outage_count":38416,"pressure":1002.6,"temperature":22.13,"voltage":3015}
|
||||
abstraction | 2025-11-18 12:04:53,025 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={"battery":100,"humidity":54.82,"linkquality":83,"power_outage_count":38416,"pressure":1002.6,"temperature":22.13,"voltage":3015}
|
||||
abstraction | 2025-11-18 12:04:53,025 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 100, 'humidity': 54.82, 'linkquality': 83, 'power_outage_count': 38416, 'pressure': 1002.6, 'temperature': 22.13, 'voltage': 3015}
|
||||
abstraction | 2025-11-18 12:04:53,025 - __main__ - INFO - ← abstract STATE sensor_arbeitszimmer_patty: home/temp_humidity/sensor_arbeitszimmer_patty/state → {"battery": 100, "humidity": 54.82, "linkquality": 83, "power_outage_count": 38416, "pressure": 1002.6, "temperature": 22.13, "voltage": 3015}
|
||||
abstraction | 2025-11-18 12:04:53,044 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "sensor_arbeitszimmer_patty", "payload": {"battery": 100, "humidity": 54.82, "linkquality": 83, "power_outage_count": 38416, "pressure": 1002.6, "temperature": 22.13, "voltage": 3015}, "ts": "2025-11-18T12:04:53.044379+00:00"}
|
||||
abstraction | 2025-11-18 12:04:53,061 - __main__ - DEBUG - MQTT message received on zigbee2mqtt/0x00158d0003f052b7: {"battery":100,"humidity":54.82,"linkquality":83,"power_outage_count":38416,"pressure":1002.4,"temperature":22.13,"voltage":3015}
|
||||
abstraction | 2025-11-18 12:04:53,061 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={"battery":100,"humidity":54.82,"linkquality":83,"power_outage_count":38416,"pressure":1002.4,"temperature":22.13,"voltage":3015}
|
||||
abstraction | 2025-11-18 12:04:53,061 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 100, 'humidity': 54.82, 'linkquality': 83, 'power_outage_count': 38416, 'pressure': 1002.4, 'temperature': 22.13, 'voltage': 3015}
|
||||
abstraction | 2025-11-18 12:04:53,061 - __main__ - INFO - ← abstract STATE sensor_arbeitszimmer_patty: home/temp_humidity/sensor_arbeitszimmer_patty/state → {"battery": 100, "humidity": 54.82, "linkquality": 83, "power_outage_count": 38416, "pressure": 1002.4, "temperature": 22.13, "voltage": 3015}
|
||||
abstraction | 2025-11-18 12:04:53,084 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "sensor_arbeitszimmer_patty", "payload": {"battery": 100, "humidity": 54.82, "linkquality": 83, "power_outage_count": 38416, "pressure": 1002.4, "temperature": 22.13, "voltage": 3015}, "ts": "2025-11-18T12:04:53.083988+00:00"}
|
||||
abstraction | 2025-11-18 12:05:03,058 - __main__ - DEBUG - MQTT message received on shellies/lichtterasse/relay/0: off
|
||||
abstraction | 2025-11-18 12:05:03,058 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 12:05:03,058 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 12:05:03,058 - __main__ - INFO - ← abstract STATE licht_terasse: home/relay/licht_terasse/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 12:05:03,075 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_terasse", "payload": {"power": "off"}, "ts": "2025-11-18T12:05:03.075262+00:00"}
|
||||
abstraction | 2025-11-18 12:05:08,209 - __main__ - DEBUG - MQTT message received on shellies/wohnzimmer-regal/relay/0: off
|
||||
abstraction | 2025-11-18 12:05:08,210 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 12:05:08,210 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 12:05:08,210 - __main__ - INFO - ← abstract STATE licht_regal_wohnzimmer: home/relay/licht_regal_wohnzimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 12:05:08,228 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_regal_wohnzimmer", "payload": {"power": "off"}, "ts": "2025-11-18T12:05:08.228758+00:00"}
|
||||
abstraction | 2025-11-18 12:05:10,881 - __main__ - DEBUG - MQTT message received on shellies/LightKitchenSink/relay/0: on
|
||||
abstraction | 2025-11-18 12:05:10,881 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=on
|
||||
abstraction | 2025-11-18 12:05:10,881 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'on'}
|
||||
abstraction | 2025-11-18 12:05:10,881 - __main__ - INFO - ← abstract STATE licht_spuele_kueche: home/relay/licht_spuele_kueche/state → {"power": "on"}
|
||||
abstraction | 2025-11-18 12:05:10,899 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_spuele_kueche", "payload": {"power": "on"}, "ts": "2025-11-18T12:05:10.899207+00:00"}
|
||||
abstraction | 2025-11-18 12:05:12,622 - __main__ - DEBUG - MQTT message received on zigbee2mqtt/0x00158d00083299bb: {"battery":63,"humidity":47.69,"linkquality":87,"power_outage_count":4906,"pressure":1009.9,"temperature":19.74,"voltage":2945}
|
||||
abstraction | 2025-11-18 12:05:12,622 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={"battery":63,"humidity":47.69,"linkquality":87,"power_outage_count":4906,"pressure":1009.9,"temperature":19.74,"voltage":2945}
|
||||
abstraction | 2025-11-18 12:05:12,622 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 63, 'humidity': 47.69, 'linkquality': 87, 'power_outage_count': 4906, 'pressure': 1009.9, 'temperature': 19.74, 'voltage': 2945}
|
||||
abstraction | 2025-11-18 12:05:12,622 - __main__ - INFO - ← abstract STATE sensor_kueche: home/temp_humidity/sensor_kueche/state → {"battery": 63, "humidity": 47.69, "linkquality": 87, "power_outage_count": 4906, "pressure": 1009.9, "temperature": 19.74, "voltage": 2945}
|
||||
abstraction | 2025-11-18 12:05:12,640 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "sensor_kueche", "payload": {"battery": 63, "humidity": 47.69, "linkquality": 87, "power_outage_count": 4906, "pressure": 1009.9, "temperature": 19.74, "voltage": 2945}, "ts": "2025-11-18T12:05:12.640129+00:00"}
|
||||
abstraction | 2025-11-18 12:05:12,656 - __main__ - DEBUG - MQTT message received on zigbee2mqtt/0x00158d00083299bb: {"battery":63,"humidity":47.69,"linkquality":87,"power_outage_count":4906,"pressure":1009.9,"temperature":19.74,"voltage":2945}
|
||||
abstraction | 2025-11-18 12:05:12,656 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={"battery":63,"humidity":47.69,"linkquality":87,"power_outage_count":4906,"pressure":1009.9,"temperature":19.74,"voltage":2945}
|
||||
abstraction | 2025-11-18 12:05:12,656 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 63, 'humidity': 47.69, 'linkquality': 87, 'power_outage_count': 4906, 'pressure': 1009.9, 'temperature': 19.74, 'voltage': 2945}
|
||||
abstraction | 2025-11-18 12:05:12,657 - __main__ - INFO - ← abstract STATE sensor_kueche: home/temp_humidity/sensor_kueche/state → {"battery": 63, "humidity": 47.69, "linkquality": 87, "power_outage_count": 4906, "pressure": 1009.9, "temperature": 19.74, "voltage": 2945}
|
||||
abstraction | 2025-11-18 12:05:12,674 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "sensor_kueche", "payload": {"battery": 63, "humidity": 47.69, "linkquality": 87, "power_outage_count": 4906, "pressure": 1009.9, "temperature": 19.74, "voltage": 2945}, "ts": "2025-11-18T12:05:12.674372+00:00"}
|
||||
abstraction | 2025-11-18 12:05:12,690 - __main__ - DEBUG - MQTT message received on zigbee2mqtt/0x00158d00083299bb: {"battery":63,"humidity":47.69,"linkquality":87,"power_outage_count":4906,"pressure":1009.7,"temperature":19.74,"voltage":2945}
|
||||
abstraction | 2025-11-18 12:05:12,690 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={"battery":63,"humidity":47.69,"linkquality":87,"power_outage_count":4906,"pressure":1009.7,"temperature":19.74,"voltage":2945}
|
||||
abstraction | 2025-11-18 12:05:12,690 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 63, 'humidity': 47.69, 'linkquality': 87, 'power_outage_count': 4906, 'pressure': 1009.7, 'temperature': 19.74, 'voltage': 2945}
|
||||
abstraction | 2025-11-18 12:05:12,690 - __main__ - INFO - ← abstract STATE sensor_kueche: home/temp_humidity/sensor_kueche/state → {"battery": 63, "humidity": 47.69, "linkquality": 87, "power_outage_count": 4906, "pressure": 1009.7, "temperature": 19.74, "voltage": 2945}
|
||||
abstraction | 2025-11-18 12:05:12,708 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "sensor_kueche", "payload": {"battery": 63, "humidity": 47.69, "linkquality": 87, "power_outage_count": 4906, "pressure": 1009.7, "temperature": 19.74, "voltage": 2945}, "ts": "2025-11-18T12:05:12.708715+00:00"}
|
||||
abstraction | 2025-11-18 12:05:18,507 - __main__ - DEBUG - MQTT message received on shellies/schrankesszimmer/relay/0: off
|
||||
abstraction | 2025-11-18 12:05:18,508 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 12:05:18,508 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 12:05:18,508 - __main__ - INFO - ← abstract STATE licht_schrank_esszimmer: home/relay/licht_schrank_esszimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 12:05:18,526 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_schrank_esszimmer", "payload": {"power": "off"}, "ts": "2025-11-18T12:05:18.525971+00:00"}
|
||||
311
debug/2025-11-18-01-debug.log
Normal file
311
debug/2025-11-18-01-debug.log
Normal file
@@ -0,0 +1,311 @@
|
||||
rules | 2025-11-18 12:04:40,835 - asyncio - DEBUG - Using selector: EpollSelector
|
||||
rules | 2025-11-18 12:04:40,835 - __main__ - INFO - ============================================================
|
||||
rules | 2025-11-18 12:04:40,835 - __main__ - INFO - Rules Engine Starting
|
||||
rules | 2025-11-18 12:04:40,835 - __main__ - INFO - ============================================================
|
||||
rules | 2025-11-18 12:04:40,835 - __main__ - INFO - Config: /app/config/rules.yaml
|
||||
rules | 2025-11-18 12:04:40,835 - __main__ - INFO - MQTT: 172.23.1.102:1883
|
||||
rules | 2025-11-18 12:04:40,835 - __main__ - INFO - Redis: redis://172.23.1.116:6379/8
|
||||
rules | 2025-11-18 12:04:40,836 - __main__ - INFO - ============================================================
|
||||
rules | 2025-11-18 12:04:40,836 - __main__ - INFO - Loading rules configuration from /app/config/rules.yaml
|
||||
rules | 2025-11-18 12:04:40,841 - __main__ - INFO - Loaded 6 rule(s) from configuration
|
||||
rules | 2025-11-18 12:04:40,841 - __main__ - INFO - - window_setback_esszimmer (type: window_setback@1.0) [DISABLED]
|
||||
rules | 2025-11-18 12:04:40,842 - __main__ - INFO - - window_setback_kueche (type: window_setback@1.0) [DISABLED]
|
||||
rules | 2025-11-18 12:04:40,842 - __main__ - INFO - - window_setback_patty (type: window_setback@1.0) [DISABLED]
|
||||
rules | 2025-11-18 12:04:40,842 - __main__ - INFO - - window_setback_schlafzimmer (type: window_setback@1.0) [DISABLED]
|
||||
rules | 2025-11-18 12:04:40,842 - __main__ - INFO - - window_setback_wohnzimmer (type: window_setback@1.0) [DISABLED]
|
||||
rules | 2025-11-18 12:04:40,846 - __main__ - INFO - - window_setback_wolfgang (type: window_setback@1.0)
|
||||
rules | 2025-11-18 12:04:40,846 - __main__ - INFO - Successfully loaded 1 rule implementation(s) (5 disabled)
|
||||
rules | 2025-11-18 12:04:40,846 - __main__ - INFO - Rule window_setback_wolfgang validated: 1 contacts, 1 thermostats
|
||||
rules | 2025-11-18 12:04:40,846 - __main__ - DEBUG - Rule window_setback_wolfgang subscribes to 2 topic(s)
|
||||
rules | 2025-11-18 12:04:40,847 - __main__ - INFO - Total MQTT subscriptions needed: 2
|
||||
rules | 2025-11-18 12:04:40,847 - __main__ - INFO - Starting event processing loop
|
||||
abstraction | 2025-11-18 12:04:40,901 - asyncio - DEBUG - Using selector: EpollSelector
|
||||
abstraction | 2025-11-18 12:04:40,952 - __main__ - INFO - Loaded configuration from /app/config/devices.yaml
|
||||
abstraction | 2025-11-18 12:04:40,953 - __main__ - INFO - Loaded 64 device(s): lampe_semeniere_wohnzimmer, stehlampe_esszimmer_spiegel, stehlampe_esszimmer_schrank, grosse_lampe_wohnzimmer, lampe_naehtischchen_wohnzimmer, kleine_lampe_rechts_esszimmer, kleine_lampe_links_esszimmer, leselampe_esszimmer, medusalampe_schlafzimmer, sportlicht_am_fernseher_studierzimmer, deckenlampe_schlafzimmer, bettlicht_wolfgang, bettlicht_patty, schranklicht_hinten_patty, schranklicht_vorne_patty, leselampe_patty, deckenlampe_esszimmer, standlampe_esszimmer, haustuer, deckenlampe_flur_oben, kueche_deckenlampe, sportlicht_tisch, sportlicht_regal, licht_flur_oben_am_spiegel, experimentlabtest, thermostat_wolfgang, thermostat_kueche, thermostat_schlafzimmer, thermostat_esszimmer, thermostat_wohnzimmer, thermostat_patty, thermostat_bad_oben, thermostat_bad_unten, sterne_wohnzimmer, kontakt_schlafzimmer_strasse, kontakt_esszimmer_strasse_rechts, kontakt_esszimmer_strasse_links, kontakt_wohnzimmer_garten_rechts, kontakt_wohnzimmer_garten_links, kontakt_kueche_garten_fenster, kontakt_kueche_garten_tuer, kontakt_kueche_strasse_rechts, kontakt_kueche_strasse_links, kontakt_patty_garten_rechts, kontakt_patty_garten_links, kontakt_patty_strasse, kontakt_wolfgang_garten, kontakt_bad_oben_strasse, kontakt_bad_unten_strasse, sensor_schlafzimmer, sensor_wohnzimmer, sensor_kueche, sensor_arbeitszimmer_patty, sensor_arbeitszimmer_wolfgang, sensor_bad_oben, sensor_bad_unten, sensor_flur, sensor_waschkueche, sensor_sportzimmer, licht_spuele_kueche, licht_schrank_esszimmer, licht_regal_wohnzimmer, licht_flur_schrank, licht_terasse
|
||||
abstraction | 2025-11-18 12:04:40,953 - __main__ - INFO - Loaded 64 device(s) from configuration
|
||||
rules | 2025-11-18 12:04:40,999 - __main__ - INFO - Connecting to MQTT broker 172.23.1.102:1883 (client_id=rule_engine-0d8cce)
|
||||
abstraction | 2025-11-18 12:04:41,003 - __main__ - INFO - Connected to Redis: redis://172.23.1.116:6379/8
|
||||
abstraction | 2025-11-18 12:04:41,003 - __main__ - INFO - Abstraction worker started
|
||||
abstraction | 2025-11-18 12:04:41,003 - __main__ - INFO - Connecting to MQTT broker: 172.23.1.102:1883
|
||||
rules | 2025-11-18 12:04:41,051 - __main__ - INFO - Connected to MQTT broker 172.23.1.102:1883
|
||||
abstraction | 2025-11-18 12:04:41,053 - __main__ - INFO - Connected to MQTT broker as home-automation-abstraction-b39304
|
||||
abstraction | 2025-11-18 12:04:41,072 - __main__ - INFO - Subscribed to abstract SET: home/relay/lampe_semeniere_wohnzimmer/set
|
||||
rules | 2025-11-18 12:04:41,084 - __main__ - INFO - Subscribed to 2 topic(s): home/thermostat/thermostat_wolfgang/state, home/contact/kontakt_wolfgang_garten/state
|
||||
rules | 2025-11-18 12:04:41,085 - __main__ - DEBUG - Received event: {'topic': 'home/thermostat/thermostat_wolfgang/state', 'type': 'state', 'cap': 'thermostat', 'device_id': 'thermostat_wolfgang', 'payload': {'target': 23.0, 'current': 23.5, 'mode': 'heat'}, 'ts': '2025-11-18T12:04:41.085220'}
|
||||
abstraction | 2025-11-18 12:04:41,091 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b8000015480b
|
||||
rules | 2025-11-18 12:04:41,085 - __main__ - DEBUG - Filtering for cap=thermostat, device_id=thermostat_wolfgang
|
||||
rules | 2025-11-18 12:04:41,085 - __main__ - DEBUG - Rule window_setback_wolfgang: checking thermostats ['thermostat_wolfgang']
|
||||
rules | 2025-11-18 12:04:41,086 - __main__ - INFO - Event thermostat/thermostat_wolfgang: 1 matching rule(s)
|
||||
abstraction | 2025-11-18 12:04:41,107 - __main__ - INFO - Subscribed to abstract SET: home/light/stehlampe_esszimmer_spiegel/set
|
||||
abstraction | 2025-11-18 12:04:41,125 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010d06ea09
|
||||
abstraction | 2025-11-18 12:04:41,141 - __main__ - INFO - Subscribed to abstract SET: home/light/stehlampe_esszimmer_schrank/set
|
||||
abstraction | 2025-11-18 12:04:41,159 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010d09176c
|
||||
abstraction | 2025-11-18 12:04:41,176 - __main__ - INFO - Subscribed to abstract SET: home/relay/grosse_lampe_wohnzimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,192 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000151aca
|
||||
rules | 2025-11-18 12:04:41,197 - __main__ - DEBUG - Rule window_setback_wolfgang: Updated current target for thermostat_wolfgang: 23.0°C
|
||||
rules | 2025-11-18 12:04:41,197 - __main__ - DEBUG - Received event: {'topic': 'home/contact/kontakt_wolfgang_garten/state', 'type': 'state', 'cap': 'contact', 'device_id': 'kontakt_wolfgang_garten', 'payload': {'contact': 'closed', 'battery': 100, 'linkquality': 32, 'device_temperature': 28, 'voltage': 3025}, 'ts': '2025-11-18T12:04:41.197402'}
|
||||
rules | 2025-11-18 12:04:41,198 - __main__ - DEBUG - Filtering for cap=contact, device_id=kontakt_wolfgang_garten
|
||||
rules | 2025-11-18 12:04:41,198 - __main__ - DEBUG - Rule window_setback_wolfgang: checking contacts ['kontakt_wolfgang_garten']
|
||||
rules | 2025-11-18 12:04:41,199 - __main__ - INFO - Event contact/kontakt_wolfgang_garten: 1 matching rule(s)
|
||||
abstraction | 2025-11-18 12:04:41,209 - __main__ - INFO - Subscribed to abstract SET: home/relay/lampe_naehtischchen_wohnzimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,225 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x842e14fffee560ee
|
||||
rules | 2025-11-18 12:04:41,233 - __main__ - INFO - Rule window_setback_wolfgang: Window closed, restoring 1 thermostats to previous temperatures
|
||||
abstraction | 2025-11-18 12:04:41,242 - __main__ - INFO - Subscribed to abstract SET: home/relay/kleine_lampe_rechts_esszimmer/set
|
||||
rules | 2025-11-18 12:04:41,250 - __main__ - WARNING - No previous target found for thermostat_wolfgang, cannot restore
|
||||
abstraction | 2025-11-18 12:04:41,259 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000156645
|
||||
abstraction | 2025-11-18 12:04:41,276 - __main__ - INFO - Subscribed to abstract SET: home/relay/kleine_lampe_links_esszimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,293 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000153099
|
||||
abstraction | 2025-11-18 12:04:41,310 - __main__ - INFO - Subscribed to abstract SET: home/light/leselampe_esszimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,327 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xec1bbdfffe7b84f2
|
||||
abstraction | 2025-11-18 12:04:41,344 - __main__ - INFO - Subscribed to abstract SET: home/relay/medusalampe_schlafzimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,361 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000154c7c
|
||||
abstraction | 2025-11-18 12:04:41,378 - __main__ - INFO - Subscribed to abstract SET: home/light/sportlicht_am_fernseher_studierzimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,395 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x842e14fffe76a23a
|
||||
abstraction | 2025-11-18 12:04:41,415 - __main__ - INFO - Subscribed to abstract SET: home/light/deckenlampe_schlafzimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,432 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x0017880108a406a7
|
||||
abstraction | 2025-11-18 12:04:41,449 - __main__ - INFO - Subscribed to abstract SET: home/light/bettlicht_wolfgang/set
|
||||
api | INFO: Started server process [1]
|
||||
api | INFO: Waiting for application startup.
|
||||
abstraction | 2025-11-18 12:04:41,466 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00178801081570bf
|
||||
abstraction | 2025-11-18 12:04:41,484 - __main__ - INFO - Subscribed to abstract SET: home/light/bettlicht_patty/set
|
||||
api | INFO: Application startup complete.
|
||||
abstraction | 2025-11-18 12:04:41,500 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x0017880108158b32
|
||||
api | INFO: Uvicorn running on http://0.0.0.0:8001 (Press CTRL+C to quit)
|
||||
abstraction | 2025-11-18 12:04:41,518 - __main__ - INFO - Subscribed to abstract SET: home/light/schranklicht_hinten_patty/set
|
||||
abstraction | 2025-11-18 12:04:41,535 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x0017880106e29571
|
||||
abstraction | 2025-11-18 12:04:41,552 - __main__ - INFO - Subscribed to abstract SET: home/relay/schranklicht_vorne_patty/set
|
||||
abstraction | 2025-11-18 12:04:41,569 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000154cf5
|
||||
abstraction | 2025-11-18 12:04:41,586 - __main__ - INFO - Subscribed to abstract SET: home/light/leselampe_patty/set
|
||||
abstraction | 2025-11-18 12:04:41,603 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010600ec9d
|
||||
abstraction | 2025-11-18 12:04:41,620 - __main__ - INFO - Subscribed to abstract SET: home/light/deckenlampe_esszimmer/set
|
||||
abstraction | 2025-11-18 12:04:41,637 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x0017880108a03e45
|
||||
abstraction | 2025-11-18 12:04:41,655 - __main__ - INFO - Subscribed to abstract SET: home/light/standlampe_esszimmer/set
|
||||
ui | UI using API_BASE: http://172.19.1.11:8001
|
||||
ui | UI using BASE_PATH: /
|
||||
ui | INFO: Started server process [1]
|
||||
ui | INFO: Waiting for application startup.
|
||||
ui | INFO: Application startup complete.
|
||||
abstraction | 2025-11-18 12:04:41,674 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xbc33acfffe21f547
|
||||
ui | INFO: Uvicorn running on http://0.0.0.0:8002 (Press CTRL+C to quit)
|
||||
abstraction | 2025-11-18 12:04:41,692 - __main__ - INFO - Subscribed to abstract SET: home/light/haustuer/set
|
||||
abstraction | 2025-11-18 12:04:41,711 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xec1bbdfffea6a3da
|
||||
abstraction | 2025-11-18 12:04:41,728 - __main__ - INFO - Subscribed to abstract SET: home/light/deckenlampe_flur_oben/set
|
||||
abstraction | 2025-11-18 12:04:41,746 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010d2123a7
|
||||
abstraction | 2025-11-18 12:04:41,764 - __main__ - INFO - Subscribed to abstract SET: home/light/kueche_deckenlampe/set
|
||||
abstraction | 2025-11-18 12:04:41,781 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010d2c40c4
|
||||
abstraction | 2025-11-18 12:04:41,798 - __main__ - INFO - Subscribed to abstract SET: home/light/sportlicht_tisch/set
|
||||
abstraction | 2025-11-18 12:04:41,814 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b8be2409f31b
|
||||
abstraction | 2025-11-18 12:04:41,831 - __main__ - INFO - Subscribed to abstract SET: home/light/sportlicht_regal/set
|
||||
abstraction | 2025-11-18 12:04:41,848 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b8be2409f569
|
||||
abstraction | 2025-11-18 12:04:41,865 - __main__ - INFO - Subscribed to abstract SET: home/light/licht_flur_oben_am_spiegel/set
|
||||
abstraction | 2025-11-18 12:04:41,883 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x842e14fffefe4ba4
|
||||
abstraction | 2025-11-18 12:04:41,899 - __main__ - INFO - Subscribed to abstract SET: home/light/experimentlabtest/set
|
||||
abstraction | 2025-11-18 12:04:41,918 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000195038
|
||||
abstraction | 2025-11-18 12:04:41,936 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_wolfgang/set
|
||||
abstraction | 2025-11-18 12:04:41,955 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x540f57fffe7e3cfe
|
||||
abstraction | 2025-11-18 12:04:41,974 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_kueche/set
|
||||
abstraction | 2025-11-18 12:04:41,991 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x94deb8fffe2e5c06
|
||||
abstraction | 2025-11-18 12:04:42,008 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_schlafzimmer/set
|
||||
abstraction | 2025-11-18 12:04:42,025 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/42/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 12:04:42,042 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_esszimmer/set
|
||||
abstraction | 2025-11-18 12:04:42,059 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/45/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 12:04:42,080 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_wohnzimmer/set
|
||||
abstraction | 2025-11-18 12:04:42,097 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/46/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 12:04:42,114 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_patty/set
|
||||
abstraction | 2025-11-18 12:04:42,131 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/39/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 12:04:42,150 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_bad_oben/set
|
||||
abstraction | 2025-11-18 12:04:42,171 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/41/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 12:04:42,189 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_bad_unten/set
|
||||
abstraction | 2025-11-18 12:04:42,207 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/48/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 12:04:42,224 - __main__ - INFO - Subscribed to abstract SET: home/relay/sterne_wohnzimmer/set
|
||||
abstraction | 2025-11-18 12:04:42,240 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000155fc2
|
||||
abstraction | 2025-11-18 12:04:42,240 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_schlafzimmer_strasse
|
||||
abstraction | 2025-11-18 12:04:42,258 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/52/1/STATE
|
||||
abstraction | 2025-11-18 12:04:42,258 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_esszimmer_strasse_rechts
|
||||
abstraction | 2025-11-18 12:04:42,275 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/26/1/STATE
|
||||
abstraction | 2025-11-18 12:04:42,275 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_esszimmer_strasse_links
|
||||
abstraction | 2025-11-18 12:04:42,293 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/27/1/STATE
|
||||
abstraction | 2025-11-18 12:04:42,293 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_wohnzimmer_garten_rechts
|
||||
abstraction | 2025-11-18 12:04:42,313 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/28/1/STATE
|
||||
abstraction | 2025-11-18 12:04:42,313 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_wohnzimmer_garten_links
|
||||
abstraction | 2025-11-18 12:04:42,331 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/29/1/STATE
|
||||
abstraction | 2025-11-18 12:04:42,331 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_kueche_garten_fenster
|
||||
abstraction | 2025-11-18 12:04:42,351 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b332785
|
||||
abstraction | 2025-11-18 12:04:42,351 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_kueche_garten_tuer
|
||||
abstraction | 2025-11-18 12:04:42,371 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b332788
|
||||
abstraction | 2025-11-18 12:04:42,371 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_kueche_strasse_rechts
|
||||
abstraction | 2025-11-18 12:04:42,390 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b151803
|
||||
abstraction | 2025-11-18 12:04:42,390 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_kueche_strasse_links
|
||||
abstraction | 2025-11-18 12:04:42,408 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b331d0b
|
||||
abstraction | 2025-11-18 12:04:42,408 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_patty_garten_rechts
|
||||
abstraction | 2025-11-18 12:04:42,424 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/18/1/STATE
|
||||
abstraction | 2025-11-18 12:04:42,424 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_patty_garten_links
|
||||
abstraction | 2025-11-18 12:04:42,441 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/22/1/STATE
|
||||
abstraction | 2025-11-18 12:04:42,441 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_patty_strasse
|
||||
abstraction | 2025-11-18 12:04:42,462 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d000af457cf
|
||||
abstraction | 2025-11-18 12:04:42,462 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_wolfgang_garten
|
||||
abstraction | 2025-11-18 12:04:42,479 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b3328da
|
||||
abstraction | 2025-11-18 12:04:42,480 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_bad_oben_strasse
|
||||
abstraction | 2025-11-18 12:04:42,496 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b333aec
|
||||
abstraction | 2025-11-18 12:04:42,496 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_bad_unten_strasse
|
||||
abstraction | 2025-11-18 12:04:42,513 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/44/1/STATE
|
||||
abstraction | 2025-11-18 12:04:42,513 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_schlafzimmer
|
||||
abstraction | 2025-11-18 12:04:42,532 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d00043292dc
|
||||
abstraction | 2025-11-18 12:04:42,532 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_wohnzimmer
|
||||
abstraction | 2025-11-18 12:04:42,552 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d0008975707
|
||||
abstraction | 2025-11-18 12:04:42,552 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_kueche
|
||||
abstraction | 2025-11-18 12:04:42,571 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d00083299bb
|
||||
abstraction | 2025-11-18 12:04:42,571 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_arbeitszimmer_patty
|
||||
abstraction | 2025-11-18 12:04:42,589 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d0003f052b7
|
||||
abstraction | 2025-11-18 12:04:42,589 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_arbeitszimmer_wolfgang
|
||||
abstraction | 2025-11-18 12:04:42,608 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d000543fb99
|
||||
abstraction | 2025-11-18 12:04:42,608 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_bad_oben
|
||||
abstraction | 2025-11-18 12:04:42,625 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d00093e8987
|
||||
abstraction | 2025-11-18 12:04:42,625 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_bad_unten
|
||||
abstraction | 2025-11-18 12:04:42,645 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d00093e662a
|
||||
abstraction | 2025-11-18 12:04:42,645 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_flur
|
||||
abstraction | 2025-11-18 12:04:42,664 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d000836ccc6
|
||||
abstraction | 2025-11-18 12:04:42,664 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_waschkueche
|
||||
abstraction | 2025-11-18 12:04:42,682 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d000449f3bc
|
||||
abstraction | 2025-11-18 12:04:42,682 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_sportzimmer
|
||||
abstraction | 2025-11-18 12:04:42,699 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d0009421422
|
||||
abstraction | 2025-11-18 12:04:42,716 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_spuele_kueche/set
|
||||
abstraction | 2025-11-18 12:04:42,734 - __main__ - INFO - Subscribed to vendor STATE: shellies/LightKitchenSink/relay/0
|
||||
abstraction | 2025-11-18 12:04:42,751 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_schrank_esszimmer/set
|
||||
abstraction | 2025-11-18 12:04:42,770 - __main__ - INFO - Subscribed to vendor STATE: shellies/schrankesszimmer/relay/0
|
||||
abstraction | 2025-11-18 12:04:42,790 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_regal_wohnzimmer/set
|
||||
abstraction | 2025-11-18 12:04:42,807 - __main__ - INFO - Subscribed to vendor STATE: shellies/wohnzimmer-regal/relay/0
|
||||
abstraction | 2025-11-18 12:04:42,823 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_flur_schrank/set
|
||||
abstraction | 2025-11-18 12:04:42,841 - __main__ - INFO - Subscribed to vendor STATE: shellies/schrankflur/relay/0
|
||||
abstraction | 2025-11-18 12:04:42,858 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_terasse/set
|
||||
abstraction | 2025-11-18 12:04:42,875 - __main__ - INFO - Subscribed to vendor STATE: shellies/lichtterasse/relay/0
|
||||
abstraction | 2025-11-18 12:04:42,875 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/42/1/SET_TEMPERATURE: 21
|
||||
abstraction | 2025-11-18 12:04:42,875 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=21
|
||||
abstraction | 2025-11-18 12:04:42,876 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 21.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 12:04:42,876 - __main__ - INFO - ← abstract STATE thermostat_schlafzimmer: home/thermostat/thermostat_schlafzimmer/state → {"target": 21.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 12:04:42,897 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_schlafzimmer", "payload": {"target": 21.0, "mode": "heat"}, "ts": "2025-11-18T12:04:42.897310+00:00"}
|
||||
abstraction | 2025-11-18 12:04:42,914 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/45/1/SET_TEMPERATURE: 15
|
||||
abstraction | 2025-11-18 12:04:42,914 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=15
|
||||
abstraction | 2025-11-18 12:04:42,914 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 15.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 12:04:42,914 - __main__ - INFO - ← abstract STATE thermostat_esszimmer: home/thermostat/thermostat_esszimmer/state → {"target": 15.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 12:04:42,934 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_esszimmer", "payload": {"target": 15.0, "mode": "heat"}, "ts": "2025-11-18T12:04:42.934255+00:00"}
|
||||
abstraction | 2025-11-18 12:04:42,950 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/46/1/SET_TEMPERATURE: 15
|
||||
abstraction | 2025-11-18 12:04:42,950 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=15
|
||||
abstraction | 2025-11-18 12:04:42,950 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 15.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 12:04:42,951 - __main__ - INFO - ← abstract STATE thermostat_wohnzimmer: home/thermostat/thermostat_wohnzimmer/state → {"target": 15.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 12:04:42,970 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_wohnzimmer", "payload": {"target": 15.0, "mode": "heat"}, "ts": "2025-11-18T12:04:42.970936+00:00"}
|
||||
abstraction | 2025-11-18 12:04:42,987 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/39/1/SET_TEMPERATURE: 22
|
||||
abstraction | 2025-11-18 12:04:42,988 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=22
|
||||
abstraction | 2025-11-18 12:04:42,988 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 22.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 12:04:42,988 - __main__ - INFO - ← abstract STATE thermostat_patty: home/thermostat/thermostat_patty/state → {"target": 22.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 12:04:43,009 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_patty", "payload": {"target": 22.0, "mode": "heat"}, "ts": "2025-11-18T12:04:43.009673+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,029 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/41/1/SET_TEMPERATURE: 20
|
||||
abstraction | 2025-11-18 12:04:43,029 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=20
|
||||
abstraction | 2025-11-18 12:04:43,029 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 20.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 12:04:43,029 - __main__ - INFO - ← abstract STATE thermostat_bad_oben: home/thermostat/thermostat_bad_oben/state → {"target": 20.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 12:04:43,053 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_bad_oben", "payload": {"target": 20.0, "mode": "heat"}, "ts": "2025-11-18T12:04:43.053895+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,071 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/48/1/SET_TEMPERATURE: 21
|
||||
abstraction | 2025-11-18 12:04:43,071 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=21
|
||||
abstraction | 2025-11-18 12:04:43,071 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 21.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 12:04:43,072 - __main__ - INFO - ← abstract STATE thermostat_bad_unten: home/thermostat/thermostat_bad_unten/state → {"target": 21.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 12:04:43,092 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_bad_unten", "payload": {"target": 21.0, "mode": "heat"}, "ts": "2025-11-18T12:04:43.092210+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,108 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/52/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,108 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 12:04:43,108 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 12:04:43,109 - __main__ - INFO - ← abstract STATE kontakt_schlafzimmer_strasse: home/contact/kontakt_schlafzimmer_strasse/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 12:04:43,128 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_schlafzimmer_strasse", "payload": {"contact": "closed"}, "ts": "2025-11-18T12:04:43.128506+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,145 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/26/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,145 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 12:04:43,145 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 12:04:43,146 - __main__ - INFO - ← abstract STATE kontakt_esszimmer_strasse_rechts: home/contact/kontakt_esszimmer_strasse_rechts/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 12:04:43,165 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_esszimmer_strasse_rechts", "payload": {"contact": "closed"}, "ts": "2025-11-18T12:04:43.165958+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,182 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/27/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,182 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 12:04:43,183 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 12:04:43,183 - __main__ - INFO - ← abstract STATE kontakt_esszimmer_strasse_links: home/contact/kontakt_esszimmer_strasse_links/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 12:04:43,202 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_esszimmer_strasse_links", "payload": {"contact": "closed"}, "ts": "2025-11-18T12:04:43.202580+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,219 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/28/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,219 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 12:04:43,219 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 12:04:43,220 - __main__ - INFO - ← abstract STATE kontakt_wohnzimmer_garten_rechts: home/contact/kontakt_wohnzimmer_garten_rechts/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 12:04:43,239 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_wohnzimmer_garten_rechts", "payload": {"contact": "closed"}, "ts": "2025-11-18T12:04:43.239653+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,256 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/29/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,256 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 12:04:43,256 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 12:04:43,257 - __main__ - INFO - ← abstract STATE kontakt_wohnzimmer_garten_links: home/contact/kontakt_wohnzimmer_garten_links/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 12:04:43,275 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_wohnzimmer_garten_links", "payload": {"contact": "closed"}, "ts": "2025-11-18T12:04:43.275832+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,292 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/18/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,292 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 12:04:43,292 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 12:04:43,293 - __main__ - INFO - ← abstract STATE kontakt_patty_garten_rechts: home/contact/kontakt_patty_garten_rechts/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 12:04:43,314 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_patty_garten_rechts", "payload": {"contact": "closed"}, "ts": "2025-11-18T12:04:43.314579+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,331 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/22/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,331 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 12:04:43,331 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 12:04:43,332 - __main__ - INFO - ← abstract STATE kontakt_patty_garten_links: home/contact/kontakt_patty_garten_links/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 12:04:43,351 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_patty_garten_links", "payload": {"contact": "closed"}, "ts": "2025-11-18T12:04:43.351704+00:00"}
|
||||
abstraction | 2025-11-18 12:04:43,368 - __main__ - DEBUG - MQTT message received on homegear/instance1/plain/44/1/STATE: false
|
||||
abstraction | 2025-11-18 12:04:43,368 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 12:04:43,368 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 12:04:43,369 - __main__ - INFO - ← abstract STATE kontakt_bad_unten_strasse: home/contact/kontakt_bad_unten_strasse/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 12:04:43,388 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_bad_unten_strasse", "payload": {"contact": "closed"}, "ts": "2025-11-18T12:04:43.388390+00:00"}
|
||||
api | INFO: 172.16.3.98:60163 - "GET /realtime HTTP/1.1" 200 OK
|
||||
ui | INFO: 127.0.0.1:35036 - "GET /health HTTP/1.1" 200 OK
|
||||
api | INFO: 172.16.3.98:60172 - "GET /realtime HTTP/1.1" 200 OK
|
||||
abstraction | 2025-11-18 12:04:48,498 - __main__ - DEBUG - MQTT message received on shellies/schrankesszimmer/relay/0: off
|
||||
abstraction | 2025-11-18 12:04:48,498 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 12:04:48,498 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 12:04:48,498 - __main__ - INFO - ← abstract STATE licht_schrank_esszimmer: home/relay/licht_schrank_esszimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 12:04:48,518 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_schrank_esszimmer", "payload": {"power": "off"}, "ts": "2025-11-18T12:04:48.518525+00:00"}
|
||||
api | INFO: 172.16.3.98:60187 - "GET /realtime HTTP/1.1" 200 OK
|
||||
abstraction | 2025-11-18 12:04:52,989 - __main__ - DEBUG - MQTT message received on zigbee2mqtt/0x00158d0003f052b7: {"battery":100,"humidity":55.04,"linkquality":83,"power_outage_count":38416,"pressure":1002.6,"temperature":22.13,"voltage":3015}
|
||||
abstraction | 2025-11-18 12:04:52,989 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={"battery":100,"humidity":55.04,"linkquality":83,"power_outage_count":38416,"pressure":1002.6,"temperature":22.13,"voltage":3015}
|
||||
abstraction | 2025-11-18 12:04:52,989 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 100, 'humidity': 55.04, 'linkquality': 83, 'power_outage_count': 38416, 'pressure': 1002.6, 'temperature': 22.13, 'voltage': 3015}
|
||||
abstraction | 2025-11-18 12:04:52,989 - __main__ - INFO - ← abstract STATE sensor_arbeitszimmer_patty: home/temp_humidity/sensor_arbeitszimmer_patty/state → {"battery": 100, "humidity": 55.04, "linkquality": 83, "power_outage_count": 38416, "pressure": 1002.6, "temperature": 22.13, "voltage": 3015}
|
||||
abstraction | 2025-11-18 12:04:53,009 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "sensor_arbeitszimmer_patty", "payload": {"battery": 100, "humidity": 55.04, "linkquality": 83, "power_outage_count": 38416, "pressure": 1002.6, "temperature": 22.13, "voltage": 3015}, "ts": "2025-11-18T12:04:53.009776+00:00"}
|
||||
abstraction | 2025-11-18 12:04:53,024 - __main__ - DEBUG - MQTT message received on zigbee2mqtt/0x00158d0003f052b7: {"battery":100,"humidity":54.82,"linkquality":83,"power_outage_count":38416,"pressure":1002.6,"temperature":22.13,"voltage":3015}
|
||||
abstraction | 2025-11-18 12:04:53,025 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={"battery":100,"humidity":54.82,"linkquality":83,"power_outage_count":38416,"pressure":1002.6,"temperature":22.13,"voltage":3015}
|
||||
abstraction | 2025-11-18 12:04:53,025 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 100, 'humidity': 54.82, 'linkquality': 83, 'power_outage_count': 38416, 'pressure': 1002.6, 'temperature': 22.13, 'voltage': 3015}
|
||||
abstraction | 2025-11-18 12:04:53,025 - __main__ - INFO - ← abstract STATE sensor_arbeitszimmer_patty: home/temp_humidity/sensor_arbeitszimmer_patty/state → {"battery": 100, "humidity": 54.82, "linkquality": 83, "power_outage_count": 38416, "pressure": 1002.6, "temperature": 22.13, "voltage": 3015}
|
||||
abstraction | 2025-11-18 12:04:53,044 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "sensor_arbeitszimmer_patty", "payload": {"battery": 100, "humidity": 54.82, "linkquality": 83, "power_outage_count": 38416, "pressure": 1002.6, "temperature": 22.13, "voltage": 3015}, "ts": "2025-11-18T12:04:53.044379+00:00"}
|
||||
abstraction | 2025-11-18 12:04:53,061 - __main__ - DEBUG - MQTT message received on zigbee2mqtt/0x00158d0003f052b7: {"battery":100,"humidity":54.82,"linkquality":83,"power_outage_count":38416,"pressure":1002.4,"temperature":22.13,"voltage":3015}
|
||||
abstraction | 2025-11-18 12:04:53,061 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={"battery":100,"humidity":54.82,"linkquality":83,"power_outage_count":38416,"pressure":1002.4,"temperature":22.13,"voltage":3015}
|
||||
abstraction | 2025-11-18 12:04:53,061 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 100, 'humidity': 54.82, 'linkquality': 83, 'power_outage_count': 38416, 'pressure': 1002.4, 'temperature': 22.13, 'voltage': 3015}
|
||||
abstraction | 2025-11-18 12:04:53,061 - __main__ - INFO - ← abstract STATE sensor_arbeitszimmer_patty: home/temp_humidity/sensor_arbeitszimmer_patty/state → {"battery": 100, "humidity": 54.82, "linkquality": 83, "power_outage_count": 38416, "pressure": 1002.4, "temperature": 22.13, "voltage": 3015}
|
||||
abstraction | 2025-11-18 12:04:53,084 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "sensor_arbeitszimmer_patty", "payload": {"battery": 100, "humidity": 54.82, "linkquality": 83, "power_outage_count": 38416, "pressure": 1002.4, "temperature": 22.13, "voltage": 3015}, "ts": "2025-11-18T12:04:53.083988+00:00"}
|
||||
abstraction | 2025-11-18 12:05:03,058 - __main__ - DEBUG - MQTT message received on shellies/lichtterasse/relay/0: off
|
||||
abstraction | 2025-11-18 12:05:03,058 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 12:05:03,058 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 12:05:03,058 - __main__ - INFO - ← abstract STATE licht_terasse: home/relay/licht_terasse/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 12:05:03,075 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_terasse", "payload": {"power": "off"}, "ts": "2025-11-18T12:05:03.075262+00:00"}
|
||||
abstraction | 2025-11-18 12:05:08,209 - __main__ - DEBUG - MQTT message received on shellies/wohnzimmer-regal/relay/0: off
|
||||
abstraction | 2025-11-18 12:05:08,210 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 12:05:08,210 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 12:05:08,210 - __main__ - INFO - ← abstract STATE licht_regal_wohnzimmer: home/relay/licht_regal_wohnzimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 12:05:08,228 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_regal_wohnzimmer", "payload": {"power": "off"}, "ts": "2025-11-18T12:05:08.228758+00:00"}
|
||||
abstraction | 2025-11-18 12:05:10,881 - __main__ - DEBUG - MQTT message received on shellies/LightKitchenSink/relay/0: on
|
||||
abstraction | 2025-11-18 12:05:10,881 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=on
|
||||
abstraction | 2025-11-18 12:05:10,881 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'on'}
|
||||
abstraction | 2025-11-18 12:05:10,881 - __main__ - INFO - ← abstract STATE licht_spuele_kueche: home/relay/licht_spuele_kueche/state → {"power": "on"}
|
||||
abstraction | 2025-11-18 12:05:10,899 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_spuele_kueche", "payload": {"power": "on"}, "ts": "2025-11-18T12:05:10.899207+00:00"}
|
||||
abstraction | 2025-11-18 12:05:12,622 - __main__ - DEBUG - MQTT message received on zigbee2mqtt/0x00158d00083299bb: {"battery":63,"humidity":47.69,"linkquality":87,"power_outage_count":4906,"pressure":1009.9,"temperature":19.74,"voltage":2945}
|
||||
abstraction | 2025-11-18 12:05:12,622 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={"battery":63,"humidity":47.69,"linkquality":87,"power_outage_count":4906,"pressure":1009.9,"temperature":19.74,"voltage":2945}
|
||||
abstraction | 2025-11-18 12:05:12,622 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 63, 'humidity': 47.69, 'linkquality': 87, 'power_outage_count': 4906, 'pressure': 1009.9, 'temperature': 19.74, 'voltage': 2945}
|
||||
abstraction | 2025-11-18 12:05:12,622 - __main__ - INFO - ← abstract STATE sensor_kueche: home/temp_humidity/sensor_kueche/state → {"battery": 63, "humidity": 47.69, "linkquality": 87, "power_outage_count": 4906, "pressure": 1009.9, "temperature": 19.74, "voltage": 2945}
|
||||
abstraction | 2025-11-18 12:05:12,640 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "sensor_kueche", "payload": {"battery": 63, "humidity": 47.69, "linkquality": 87, "power_outage_count": 4906, "pressure": 1009.9, "temperature": 19.74, "voltage": 2945}, "ts": "2025-11-18T12:05:12.640129+00:00"}
|
||||
abstraction | 2025-11-18 12:05:12,656 - __main__ - DEBUG - MQTT message received on zigbee2mqtt/0x00158d00083299bb: {"battery":63,"humidity":47.69,"linkquality":87,"power_outage_count":4906,"pressure":1009.9,"temperature":19.74,"voltage":2945}
|
||||
abstraction | 2025-11-18 12:05:12,656 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={"battery":63,"humidity":47.69,"linkquality":87,"power_outage_count":4906,"pressure":1009.9,"temperature":19.74,"voltage":2945}
|
||||
abstraction | 2025-11-18 12:05:12,656 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 63, 'humidity': 47.69, 'linkquality': 87, 'power_outage_count': 4906, 'pressure': 1009.9, 'temperature': 19.74, 'voltage': 2945}
|
||||
abstraction | 2025-11-18 12:05:12,657 - __main__ - INFO - ← abstract STATE sensor_kueche: home/temp_humidity/sensor_kueche/state → {"battery": 63, "humidity": 47.69, "linkquality": 87, "power_outage_count": 4906, "pressure": 1009.9, "temperature": 19.74, "voltage": 2945}
|
||||
abstraction | 2025-11-18 12:05:12,674 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "sensor_kueche", "payload": {"battery": 63, "humidity": 47.69, "linkquality": 87, "power_outage_count": 4906, "pressure": 1009.9, "temperature": 19.74, "voltage": 2945}, "ts": "2025-11-18T12:05:12.674372+00:00"}
|
||||
abstraction | 2025-11-18 12:05:12,690 - __main__ - DEBUG - MQTT message received on zigbee2mqtt/0x00158d00083299bb: {"battery":63,"humidity":47.69,"linkquality":87,"power_outage_count":4906,"pressure":1009.7,"temperature":19.74,"voltage":2945}
|
||||
abstraction | 2025-11-18 12:05:12,690 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={"battery":63,"humidity":47.69,"linkquality":87,"power_outage_count":4906,"pressure":1009.7,"temperature":19.74,"voltage":2945}
|
||||
abstraction | 2025-11-18 12:05:12,690 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 63, 'humidity': 47.69, 'linkquality': 87, 'power_outage_count': 4906, 'pressure': 1009.7, 'temperature': 19.74, 'voltage': 2945}
|
||||
abstraction | 2025-11-18 12:05:12,690 - __main__ - INFO - ← abstract STATE sensor_kueche: home/temp_humidity/sensor_kueche/state → {"battery": 63, "humidity": 47.69, "linkquality": 87, "power_outage_count": 4906, "pressure": 1009.7, "temperature": 19.74, "voltage": 2945}
|
||||
abstraction | 2025-11-18 12:05:12,708 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "sensor_kueche", "payload": {"battery": 63, "humidity": 47.69, "linkquality": 87, "power_outage_count": 4906, "pressure": 1009.7, "temperature": 19.74, "voltage": 2945}, "ts": "2025-11-18T12:05:12.708715+00:00"}
|
||||
ui | INFO: 127.0.0.1:35638 - "GET /health HTTP/1.1" 200 OK
|
||||
abstraction | 2025-11-18 12:05:18,507 - __main__ - DEBUG - MQTT message received on shellies/schrankesszimmer/relay/0: off
|
||||
abstraction | 2025-11-18 12:05:18,508 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 12:05:18,508 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 12:05:18,508 - __main__ - INFO - ← abstract STATE licht_schrank_esszimmer: home/relay/licht_schrank_esszimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 12:05:18,526 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_schrank_esszimmer", "payload": {"power": "off"}, "ts": "2025-11-18T12:05:18.525971+00:00"}
|
||||
268
debug/2025-11-18-abstraction-debug.log
Normal file
268
debug/2025-11-18-abstraction-debug.log
Normal file
@@ -0,0 +1,268 @@
|
||||
abstraction | 2025-11-18 10:23:59,179 - asyncio - DEBUG - Using selector: EpollSelector
|
||||
abstraction | 2025-11-18 10:23:59,240 - __main__ - INFO - Loaded configuration from /app/config/devices.yaml
|
||||
abstraction | 2025-11-18 10:23:59,240 - __main__ - INFO - Loaded 64 device(s): lampe_semeniere_wohnzimmer, stehlampe_esszimmer_spiegel, stehlampe_esszimmer_schrank, grosse_lampe_wohnzimmer, lampe_naehtischchen_wohnzimmer, kleine_lampe_rechts_esszimmer, kleine_lampe_links_esszimmer, leselampe_esszimmer, medusalampe_schlafzimmer, sportlicht_am_fernseher_studierzimmer, deckenlampe_schlafzimmer, bettlicht_wolfgang, bettlicht_patty, schranklicht_hinten_patty, schranklicht_vorne_patty, leselampe_patty, deckenlampe_esszimmer, standlampe_esszimmer, haustuer, deckenlampe_flur_oben, kueche_deckenlampe, sportlicht_tisch, sportlicht_regal, licht_flur_oben_am_spiegel, experimentlabtest, thermostat_wolfgang, thermostat_kueche, thermostat_schlafzimmer, thermostat_esszimmer, thermostat_wohnzimmer, thermostat_patty, thermostat_bad_oben, thermostat_bad_unten, sterne_wohnzimmer, kontakt_schlafzimmer_strasse, kontakt_esszimmer_strasse_rechts, kontakt_esszimmer_strasse_links, kontakt_wohnzimmer_garten_rechts, kontakt_wohnzimmer_garten_links, kontakt_kueche_garten_fenster, kontakt_kueche_garten_tuer, kontakt_kueche_strasse_rechts, kontakt_kueche_strasse_links, kontakt_patty_garten_rechts, kontakt_patty_garten_links, kontakt_patty_strasse, kontakt_wolfgang_garten, kontakt_bad_oben_strasse, kontakt_bad_unten_strasse, sensor_schlafzimmer, sensor_wohnzimmer, sensor_kueche, sensor_arbeitszimmer_patty, sensor_arbeitszimmer_wolfgang, sensor_bad_oben, sensor_bad_unten, sensor_flur, sensor_waschkueche, sensor_sportzimmer, licht_spuele_kueche, licht_schrank_esszimmer, licht_regal_wohnzimmer, licht_flur_schrank, licht_terasse
|
||||
abstraction | 2025-11-18 10:23:59,241 - __main__ - INFO - Loaded 64 device(s) from configuration
|
||||
abstraction | 2025-11-18 10:23:59,292 - __main__ - INFO - Connected to Redis: redis://172.23.1.116:6379/8
|
||||
abstraction | 2025-11-18 10:23:59,292 - __main__ - INFO - Abstraction worker started
|
||||
abstraction | 2025-11-18 10:23:59,293 - __main__ - INFO - Connecting to MQTT broker: 172.23.1.102:1883
|
||||
abstraction | 2025-11-18 10:23:59,341 - __main__ - INFO - Connected to MQTT broker as home-automation-abstraction-2cfdfa
|
||||
abstraction | 2025-11-18 10:23:59,359 - __main__ - INFO - Subscribed to abstract SET: home/relay/lampe_semeniere_wohnzimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,377 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b8000015480b
|
||||
abstraction | 2025-11-18 10:23:59,394 - __main__ - INFO - Subscribed to abstract SET: home/light/stehlampe_esszimmer_spiegel/set
|
||||
abstraction | 2025-11-18 10:23:59,411 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010d06ea09
|
||||
abstraction | 2025-11-18 10:23:59,428 - __main__ - INFO - Subscribed to abstract SET: home/light/stehlampe_esszimmer_schrank/set
|
||||
abstraction | 2025-11-18 10:23:59,444 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010d09176c
|
||||
abstraction | 2025-11-18 10:23:59,460 - __main__ - INFO - Subscribed to abstract SET: home/relay/grosse_lampe_wohnzimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,477 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000151aca
|
||||
abstraction | 2025-11-18 10:23:59,493 - __main__ - INFO - Subscribed to abstract SET: home/relay/lampe_naehtischchen_wohnzimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,510 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x842e14fffee560ee
|
||||
abstraction | 2025-11-18 10:23:59,526 - __main__ - INFO - Subscribed to abstract SET: home/relay/kleine_lampe_rechts_esszimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,543 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000156645
|
||||
abstraction | 2025-11-18 10:23:59,560 - __main__ - INFO - Subscribed to abstract SET: home/relay/kleine_lampe_links_esszimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,578 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000153099
|
||||
abstraction | 2025-11-18 10:23:59,595 - __main__ - INFO - Subscribed to abstract SET: home/light/leselampe_esszimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,612 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xec1bbdfffe7b84f2
|
||||
abstraction | 2025-11-18 10:23:59,630 - __main__ - INFO - Subscribed to abstract SET: home/relay/medusalampe_schlafzimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,647 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000154c7c
|
||||
abstraction | 2025-11-18 10:23:59,665 - __main__ - INFO - Subscribed to abstract SET: home/light/sportlicht_am_fernseher_studierzimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,682 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x842e14fffe76a23a
|
||||
abstraction | 2025-11-18 10:23:59,700 - __main__ - INFO - Subscribed to abstract SET: home/light/deckenlampe_schlafzimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,717 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x0017880108a406a7
|
||||
abstraction | 2025-11-18 10:23:59,735 - __main__ - INFO - Subscribed to abstract SET: home/light/bettlicht_wolfgang/set
|
||||
abstraction | 2025-11-18 10:23:59,753 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00178801081570bf
|
||||
abstraction | 2025-11-18 10:23:59,770 - __main__ - INFO - Subscribed to abstract SET: home/light/bettlicht_patty/set
|
||||
abstraction | 2025-11-18 10:23:59,788 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x0017880108158b32
|
||||
abstraction | 2025-11-18 10:23:59,807 - __main__ - INFO - Subscribed to abstract SET: home/light/schranklicht_hinten_patty/set
|
||||
abstraction | 2025-11-18 10:23:59,825 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x0017880106e29571
|
||||
abstraction | 2025-11-18 10:23:59,844 - __main__ - INFO - Subscribed to abstract SET: home/relay/schranklicht_vorne_patty/set
|
||||
abstraction | 2025-11-18 10:23:59,862 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000154cf5
|
||||
abstraction | 2025-11-18 10:23:59,881 - __main__ - INFO - Subscribed to abstract SET: home/light/leselampe_patty/set
|
||||
abstraction | 2025-11-18 10:23:59,901 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010600ec9d
|
||||
abstraction | 2025-11-18 10:23:59,920 - __main__ - INFO - Subscribed to abstract SET: home/light/deckenlampe_esszimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,940 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x0017880108a03e45
|
||||
abstraction | 2025-11-18 10:23:59,959 - __main__ - INFO - Subscribed to abstract SET: home/light/standlampe_esszimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,979 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xbc33acfffe21f547
|
||||
abstraction | 2025-11-18 10:23:59,999 - __main__ - INFO - Subscribed to abstract SET: home/light/haustuer/set
|
||||
abstraction | 2025-11-18 10:24:00,016 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xec1bbdfffea6a3da
|
||||
abstraction | 2025-11-18 10:24:00,034 - __main__ - INFO - Subscribed to abstract SET: home/light/deckenlampe_flur_oben/set
|
||||
abstraction | 2025-11-18 10:24:00,053 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010d2123a7
|
||||
abstraction | 2025-11-18 10:24:00,072 - __main__ - INFO - Subscribed to abstract SET: home/light/kueche_deckenlampe/set
|
||||
abstraction | 2025-11-18 10:24:00,090 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010d2c40c4
|
||||
abstraction | 2025-11-18 10:24:00,108 - __main__ - INFO - Subscribed to abstract SET: home/light/sportlicht_tisch/set
|
||||
abstraction | 2025-11-18 10:24:00,127 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b8be2409f31b
|
||||
abstraction | 2025-11-18 10:24:00,145 - __main__ - INFO - Subscribed to abstract SET: home/light/sportlicht_regal/set
|
||||
abstraction | 2025-11-18 10:24:00,163 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b8be2409f569
|
||||
abstraction | 2025-11-18 10:24:00,183 - __main__ - INFO - Subscribed to abstract SET: home/light/licht_flur_oben_am_spiegel/set
|
||||
abstraction | 2025-11-18 10:24:00,201 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x842e14fffefe4ba4
|
||||
abstraction | 2025-11-18 10:24:00,218 - __main__ - INFO - Subscribed to abstract SET: home/light/experimentlabtest/set
|
||||
abstraction | 2025-11-18 10:24:00,237 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000195038
|
||||
abstraction | 2025-11-18 10:24:00,255 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_wolfgang/set
|
||||
abstraction | 2025-11-18 10:24:00,271 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x540f57fffe7e3cfe
|
||||
abstraction | 2025-11-18 10:24:00,292 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_kueche/set
|
||||
abstraction | 2025-11-18 10:24:00,313 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x94deb8fffe2e5c06
|
||||
abstraction | 2025-11-18 10:24:00,334 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_schlafzimmer/set
|
||||
abstraction | 2025-11-18 10:24:00,356 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/42/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 10:24:00,377 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_esszimmer/set
|
||||
abstraction | 2025-11-18 10:24:00,398 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/45/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 10:24:00,420 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_wohnzimmer/set
|
||||
abstraction | 2025-11-18 10:24:00,440 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/46/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 10:24:00,457 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_patty/set
|
||||
abstraction | 2025-11-18 10:24:00,475 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/39/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 10:24:00,493 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_bad_oben/set
|
||||
abstraction | 2025-11-18 10:24:00,509 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/41/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 10:24:00,530 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_bad_unten/set
|
||||
abstraction | 2025-11-18 10:24:00,551 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/48/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 10:24:00,572 - __main__ - INFO - Subscribed to abstract SET: home/relay/sterne_wohnzimmer/set
|
||||
abstraction | 2025-11-18 10:24:00,593 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000155fc2
|
||||
abstraction | 2025-11-18 10:24:00,593 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_schlafzimmer_strasse
|
||||
abstraction | 2025-11-18 10:24:00,614 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/52/1/STATE
|
||||
abstraction | 2025-11-18 10:24:00,614 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_esszimmer_strasse_rechts
|
||||
abstraction | 2025-11-18 10:24:00,630 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/26/1/STATE
|
||||
abstraction | 2025-11-18 10:24:00,630 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_esszimmer_strasse_links
|
||||
abstraction | 2025-11-18 10:24:00,647 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/27/1/STATE
|
||||
abstraction | 2025-11-18 10:24:00,647 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_wohnzimmer_garten_rechts
|
||||
abstraction | 2025-11-18 10:24:00,668 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/28/1/STATE
|
||||
abstraction | 2025-11-18 10:24:00,668 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_wohnzimmer_garten_links
|
||||
abstraction | 2025-11-18 10:24:00,691 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/29/1/STATE
|
||||
abstraction | 2025-11-18 10:24:00,691 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_kueche_garten_fenster
|
||||
abstraction | 2025-11-18 10:24:00,708 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b332785
|
||||
abstraction | 2025-11-18 10:24:00,708 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_kueche_garten_tuer
|
||||
abstraction | 2025-11-18 10:24:00,728 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b332788
|
||||
abstraction | 2025-11-18 10:24:00,728 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_kueche_strasse_rechts
|
||||
abstraction | 2025-11-18 10:24:00,747 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b151803
|
||||
abstraction | 2025-11-18 10:24:00,747 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_kueche_strasse_links
|
||||
abstraction | 2025-11-18 10:24:00,767 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b331d0b
|
||||
abstraction | 2025-11-18 10:24:00,767 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_patty_garten_rechts
|
||||
abstraction | 2025-11-18 10:24:00,784 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/18/1/STATE
|
||||
abstraction | 2025-11-18 10:24:00,784 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_patty_garten_links
|
||||
abstraction | 2025-11-18 10:24:00,802 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/22/1/STATE
|
||||
abstraction | 2025-11-18 10:24:00,802 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_patty_strasse
|
||||
abstraction | 2025-11-18 10:24:00,821 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d000af457cf
|
||||
abstraction | 2025-11-18 10:24:00,821 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_wolfgang_garten
|
||||
abstraction | 2025-11-18 10:24:00,838 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b3328da
|
||||
abstraction | 2025-11-18 10:24:00,838 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_bad_oben_strasse
|
||||
abstraction | 2025-11-18 10:24:00,855 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b333aec
|
||||
abstraction | 2025-11-18 10:24:00,855 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_bad_unten_strasse
|
||||
abstraction | 2025-11-18 10:24:00,872 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/44/1/STATE
|
||||
abstraction | 2025-11-18 10:24:00,872 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_schlafzimmer
|
||||
abstraction | 2025-11-18 10:24:00,891 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d00043292dc
|
||||
abstraction | 2025-11-18 10:24:00,891 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_wohnzimmer
|
||||
abstraction | 2025-11-18 10:24:00,907 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d0008975707
|
||||
abstraction | 2025-11-18 10:24:00,907 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_kueche
|
||||
abstraction | 2025-11-18 10:24:00,925 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d00083299bb
|
||||
abstraction | 2025-11-18 10:24:00,925 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_arbeitszimmer_patty
|
||||
abstraction | 2025-11-18 10:24:00,947 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d0003f052b7
|
||||
abstraction | 2025-11-18 10:24:00,947 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_arbeitszimmer_wolfgang
|
||||
abstraction | 2025-11-18 10:24:00,969 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d000543fb99
|
||||
abstraction | 2025-11-18 10:24:00,969 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_bad_oben
|
||||
abstraction | 2025-11-18 10:24:00,986 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d00093e8987
|
||||
abstraction | 2025-11-18 10:24:00,986 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_bad_unten
|
||||
abstraction | 2025-11-18 10:24:01,004 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d00093e662a
|
||||
abstraction | 2025-11-18 10:24:01,004 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_flur
|
||||
abstraction | 2025-11-18 10:24:01,022 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d000836ccc6
|
||||
abstraction | 2025-11-18 10:24:01,022 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_waschkueche
|
||||
abstraction | 2025-11-18 10:24:01,038 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d000449f3bc
|
||||
abstraction | 2025-11-18 10:24:01,038 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_sportzimmer
|
||||
abstraction | 2025-11-18 10:24:01,058 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d0009421422
|
||||
abstraction | 2025-11-18 10:24:01,074 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_spuele_kueche/set
|
||||
abstraction | 2025-11-18 10:24:01,090 - __main__ - INFO - Subscribed to vendor STATE: shellies/LightKitchenSink/relay/0
|
||||
abstraction | 2025-11-18 10:24:01,107 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_schrank_esszimmer/set
|
||||
abstraction | 2025-11-18 10:24:01,122 - __main__ - INFO - Subscribed to vendor STATE: shellies/schrankesszimmer/relay/0
|
||||
abstraction | 2025-11-18 10:24:01,139 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_regal_wohnzimmer/set
|
||||
abstraction | 2025-11-18 10:24:01,155 - __main__ - INFO - Subscribed to vendor STATE: shellies/wohnzimmer-regal/relay/0
|
||||
abstraction | 2025-11-18 10:24:01,172 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_flur_schrank/set
|
||||
abstraction | 2025-11-18 10:24:01,189 - __main__ - INFO - Subscribed to vendor STATE: shellies/schrankflur/relay/0
|
||||
abstraction | 2025-11-18 10:24:01,205 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_terasse/set
|
||||
abstraction | 2025-11-18 10:24:01,222 - __main__ - INFO - Subscribed to vendor STATE: shellies/lichtterasse/relay/0
|
||||
abstraction | 2025-11-18 10:24:01,222 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=21
|
||||
abstraction | 2025-11-18 10:24:01,222 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 21.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 10:24:01,222 - __main__ - INFO - ← abstract STATE thermostat_schlafzimmer: home/thermostat/thermostat_schlafzimmer/state → {"target": 21.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 10:24:01,243 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_schlafzimmer", "payload": {"target": 21.0, "mode": "heat"}, "ts": "2025-11-18T10:24:01.243641+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,260 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=15
|
||||
abstraction | 2025-11-18 10:24:01,260 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 15.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 10:24:01,260 - __main__ - INFO - ← abstract STATE thermostat_esszimmer: home/thermostat/thermostat_esszimmer/state → {"target": 15.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 10:24:01,280 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_esszimmer", "payload": {"target": 15.0, "mode": "heat"}, "ts": "2025-11-18T10:24:01.280285+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,296 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=15
|
||||
abstraction | 2025-11-18 10:24:01,296 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 15.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 10:24:01,296 - __main__ - INFO - ← abstract STATE thermostat_wohnzimmer: home/thermostat/thermostat_wohnzimmer/state → {"target": 15.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 10:24:01,317 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_wohnzimmer", "payload": {"target": 15.0, "mode": "heat"}, "ts": "2025-11-18T10:24:01.317708+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,334 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=22
|
||||
abstraction | 2025-11-18 10:24:01,334 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 22.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 10:24:01,334 - __main__ - INFO - ← abstract STATE thermostat_patty: home/thermostat/thermostat_patty/state → {"target": 22.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 10:24:01,357 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_patty", "payload": {"target": 22.0, "mode": "heat"}, "ts": "2025-11-18T10:24:01.357082+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,373 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=20
|
||||
abstraction | 2025-11-18 10:24:01,373 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 20.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 10:24:01,373 - __main__ - INFO - ← abstract STATE thermostat_bad_oben: home/thermostat/thermostat_bad_oben/state → {"target": 20.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 10:24:01,395 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_bad_oben", "payload": {"target": 20.0, "mode": "heat"}, "ts": "2025-11-18T10:24:01.395470+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,411 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=5
|
||||
abstraction | 2025-11-18 10:24:01,411 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 5.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 10:24:01,411 - __main__ - INFO - ← abstract STATE thermostat_bad_unten: home/thermostat/thermostat_bad_unten/state → {"target": 5.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 10:24:01,431 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_bad_unten", "payload": {"target": 5.0, "mode": "heat"}, "ts": "2025-11-18T10:24:01.431068+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,448 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 10:24:01,448 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 10:24:01,449 - __main__ - INFO - ← abstract STATE kontakt_schlafzimmer_strasse: home/contact/kontakt_schlafzimmer_strasse/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 10:24:01,472 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_schlafzimmer_strasse", "payload": {"contact": "closed"}, "ts": "2025-11-18T10:24:01.472456+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,491 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 10:24:01,491 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 10:24:01,491 - __main__ - INFO - ← abstract STATE kontakt_esszimmer_strasse_rechts: home/contact/kontakt_esszimmer_strasse_rechts/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 10:24:01,733 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_esszimmer_strasse_rechts", "payload": {"contact": "closed"}, "ts": "2025-11-18T10:24:01.733873+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,750 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 10:24:01,750 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 10:24:01,750 - __main__ - INFO - ← abstract STATE kontakt_esszimmer_strasse_links: home/contact/kontakt_esszimmer_strasse_links/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 10:24:01,771 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_esszimmer_strasse_links", "payload": {"contact": "closed"}, "ts": "2025-11-18T10:24:01.771380+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,788 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 10:24:01,788 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 10:24:01,788 - __main__ - INFO - ← abstract STATE kontakt_wohnzimmer_garten_rechts: home/contact/kontakt_wohnzimmer_garten_rechts/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 10:24:01,808 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_wohnzimmer_garten_rechts", "payload": {"contact": "closed"}, "ts": "2025-11-18T10:24:01.808516+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,825 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 10:24:01,825 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 10:24:01,825 - __main__ - INFO - ← abstract STATE kontakt_wohnzimmer_garten_links: home/contact/kontakt_wohnzimmer_garten_links/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 10:24:01,844 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_wohnzimmer_garten_links", "payload": {"contact": "closed"}, "ts": "2025-11-18T10:24:01.844046+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,860 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 10:24:01,861 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 10:24:01,861 - __main__ - INFO - ← abstract STATE kontakt_patty_garten_rechts: home/contact/kontakt_patty_garten_rechts/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 10:24:01,881 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_patty_garten_rechts", "payload": {"contact": "closed"}, "ts": "2025-11-18T10:24:01.881922+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,898 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 10:24:01,898 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 10:24:01,898 - __main__ - INFO - ← abstract STATE kontakt_patty_garten_links: home/contact/kontakt_patty_garten_links/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 10:24:01,922 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_patty_garten_links", "payload": {"contact": "closed"}, "ts": "2025-11-18T10:24:01.922254+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,940 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=true
|
||||
abstraction | 2025-11-18 10:24:01,940 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'open'}
|
||||
abstraction | 2025-11-18 10:24:01,940 - __main__ - INFO - ← abstract STATE kontakt_bad_unten_strasse: home/contact/kontakt_bad_unten_strasse/state → {"contact": "open"}
|
||||
abstraction | 2025-11-18 10:24:01,959 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_bad_unten_strasse", "payload": {"contact": "open"}, "ts": "2025-11-18T10:24:01.959678+00:00"}
|
||||
abstraction | 2025-11-18 10:24:02,354 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:24:02,354 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:24:02,354 - __main__ - INFO - ← abstract STATE licht_terasse: home/relay/licht_terasse/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:24:02,373 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_terasse", "payload": {"power": "off"}, "ts": "2025-11-18T10:24:02.373461+00:00"}
|
||||
abstraction | 2025-11-18 10:24:07,440 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:24:07,440 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:24:07,441 - __main__ - INFO - ← abstract STATE licht_regal_wohnzimmer: home/relay/licht_regal_wohnzimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:24:07,459 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_regal_wohnzimmer", "payload": {"power": "off"}, "ts": "2025-11-18T10:24:07.459082+00:00"}
|
||||
abstraction | 2025-11-18 10:24:08,817 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 100, 'humidity': 54.37, 'linkquality': 83, 'power_outage_count': 38416, 'pressure': 1004.2, 'temperature': 22.16, 'voltage': 3015}
|
||||
abstraction | 2025-11-18 10:24:08,817 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 100, 'humidity': 54.37, 'linkquality': 83, 'power_outage_count': 38416, 'pressure': 1004.2, 'temperature': 22.16, 'voltage': 3015}
|
||||
abstraction | 2025-11-18 10:24:08,817 - __main__ - INFO - ← abstract STATE sensor_arbeitszimmer_patty: home/temp_humidity/sensor_arbeitszimmer_patty/state → {"battery": 100, "humidity": 54.37, "linkquality": 83, "power_outage_count": 38416, "pressure": 1004.2, "temperature": 22.16, "voltage": 3015}
|
||||
abstraction | 2025-11-18 10:24:08,835 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "sensor_arbeitszimmer_patty", "payload": {"battery": 100, "humidity": 54.37, "linkquality": 83, "power_outage_count": 38416, "pressure": 1004.2, "temperature": 22.16, "voltage": 3015}, "ts": "2025-11-18T10:24:08.835488+00:00"}
|
||||
abstraction | 2025-11-18 10:24:08,852 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 100, 'humidity': 54.22, 'linkquality': 83, 'power_outage_count': 38416, 'pressure': 1004.2, 'temperature': 22.16, 'voltage': 3015}
|
||||
abstraction | 2025-11-18 10:24:08,852 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 100, 'humidity': 54.22, 'linkquality': 83, 'power_outage_count': 38416, 'pressure': 1004.2, 'temperature': 22.16, 'voltage': 3015}
|
||||
abstraction | 2025-11-18 10:24:08,852 - __main__ - INFO - ← abstract STATE sensor_arbeitszimmer_patty: home/temp_humidity/sensor_arbeitszimmer_patty/state → {"battery": 100, "humidity": 54.22, "linkquality": 83, "power_outage_count": 38416, "pressure": 1004.2, "temperature": 22.16, "voltage": 3015}
|
||||
abstraction | 2025-11-18 10:24:08,870 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "sensor_arbeitszimmer_patty", "payload": {"battery": 100, "humidity": 54.22, "linkquality": 83, "power_outage_count": 38416, "pressure": 1004.2, "temperature": 22.16, "voltage": 3015}, "ts": "2025-11-18T10:24:08.870674+00:00"}
|
||||
abstraction | 2025-11-18 10:24:08,887 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 100, 'humidity': 54.22, 'linkquality': 83, 'power_outage_count': 38416, 'pressure': 1003.9, 'temperature': 22.16, 'voltage': 3015}
|
||||
abstraction | 2025-11-18 10:24:08,887 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 100, 'humidity': 54.22, 'linkquality': 83, 'power_outage_count': 38416, 'pressure': 1003.9, 'temperature': 22.16, 'voltage': 3015}
|
||||
abstraction | 2025-11-18 10:24:08,887 - __main__ - INFO - ← abstract STATE sensor_arbeitszimmer_patty: home/temp_humidity/sensor_arbeitszimmer_patty/state → {"battery": 100, "humidity": 54.22, "linkquality": 83, "power_outage_count": 38416, "pressure": 1003.9, "temperature": 22.16, "voltage": 3015}
|
||||
abstraction | 2025-11-18 10:24:08,907 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "sensor_arbeitszimmer_patty", "payload": {"battery": 100, "humidity": 54.22, "linkquality": 83, "power_outage_count": 38416, "pressure": 1003.9, "temperature": 22.16, "voltage": 3015}, "ts": "2025-11-18T10:24:08.907729+00:00"}
|
||||
abstraction | 2025-11-18 10:24:10,178 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=on
|
||||
abstraction | 2025-11-18 10:24:10,178 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'on'}
|
||||
abstraction | 2025-11-18 10:24:10,178 - __main__ - INFO - ← abstract STATE licht_spuele_kueche: home/relay/licht_spuele_kueche/state → {"power": "on"}
|
||||
abstraction | 2025-11-18 10:24:10,196 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_spuele_kueche", "payload": {"power": "on"}, "ts": "2025-11-18T10:24:10.196762+00:00"}
|
||||
abstraction | 2025-11-18 10:24:17,815 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:24:17,815 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:24:17,815 - __main__ - INFO - ← abstract STATE licht_schrank_esszimmer: home/relay/licht_schrank_esszimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:24:17,834 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_schrank_esszimmer", "payload": {"power": "off"}, "ts": "2025-11-18T10:24:17.834042+00:00"}
|
||||
abstraction | 2025-11-18 10:24:32,370 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:24:32,370 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:24:32,370 - __main__ - INFO - ← abstract STATE licht_terasse: home/relay/licht_terasse/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:24:32,405 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_terasse", "payload": {"power": "off"}, "ts": "2025-11-18T10:24:32.405754+00:00"}
|
||||
abstraction | 2025-11-18 10:24:37,447 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:24:37,447 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:24:37,447 - __main__ - INFO - ← abstract STATE licht_regal_wohnzimmer: home/relay/licht_regal_wohnzimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:24:37,465 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_regal_wohnzimmer", "payload": {"power": "off"}, "ts": "2025-11-18T10:24:37.465220+00:00"}
|
||||
abstraction | 2025-11-18 10:24:40,188 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=on
|
||||
abstraction | 2025-11-18 10:24:40,189 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'on'}
|
||||
abstraction | 2025-11-18 10:24:40,189 - __main__ - INFO - ← abstract STATE licht_spuele_kueche: home/relay/licht_spuele_kueche/state → {"power": "on"}
|
||||
abstraction | 2025-11-18 10:24:40,207 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_spuele_kueche", "payload": {"power": "on"}, "ts": "2025-11-18T10:24:40.207222+00:00"}
|
||||
abstraction | 2025-11-18 10:24:47,833 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:24:47,833 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:24:47,833 - __main__ - INFO - ← abstract STATE licht_schrank_esszimmer: home/relay/licht_schrank_esszimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:24:47,868 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_schrank_esszimmer", "payload": {"power": "off"}, "ts": "2025-11-18T10:24:47.868787+00:00"}
|
||||
abstraction | 2025-11-18 10:25:02,363 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:25:02,363 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:25:02,363 - __main__ - INFO - ← abstract STATE licht_terasse: home/relay/licht_terasse/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:25:02,381 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_terasse", "payload": {"power": "off"}, "ts": "2025-11-18T10:25:02.381792+00:00"}
|
||||
abstraction | 2025-11-18 10:25:07,447 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:25:07,448 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:25:07,448 - __main__ - INFO - ← abstract STATE licht_regal_wohnzimmer: home/relay/licht_regal_wohnzimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:25:07,465 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_regal_wohnzimmer", "payload": {"power": "off"}, "ts": "2025-11-18T10:25:07.465566+00:00"}
|
||||
abstraction | 2025-11-18 10:25:10,185 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=on
|
||||
abstraction | 2025-11-18 10:25:10,185 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'on'}
|
||||
abstraction | 2025-11-18 10:25:10,185 - __main__ - INFO - ← abstract STATE licht_spuele_kueche: home/relay/licht_spuele_kueche/state → {"power": "on"}
|
||||
abstraction | 2025-11-18 10:25:10,202 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_spuele_kueche", "payload": {"power": "on"}, "ts": "2025-11-18T10:25:10.202372+00:00"}
|
||||
abstraction | 2025-11-18 10:25:17,820 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:25:17,820 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:25:17,820 - __main__ - INFO - ← abstract STATE licht_schrank_esszimmer: home/relay/licht_schrank_esszimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:25:17,838 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_schrank_esszimmer", "payload": {"power": "off"}, "ts": "2025-11-18T10:25:17.838140+00:00"}
|
||||
abstraction | 2025-11-18 10:25:32,361 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:25:32,361 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:25:32,361 - __main__ - INFO - ← abstract STATE licht_terasse: home/relay/licht_terasse/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:25:32,379 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_terasse", "payload": {"power": "off"}, "ts": "2025-11-18T10:25:32.379286+00:00"}
|
||||
abstraction | 2025-11-18 10:25:37,455 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:25:37,455 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:25:37,455 - __main__ - INFO - ← abstract STATE licht_regal_wohnzimmer: home/relay/licht_regal_wohnzimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:25:37,473 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_regal_wohnzimmer", "payload": {"power": "off"}, "ts": "2025-11-18T10:25:37.473171+00:00"}
|
||||
abstraction | 2025-11-18 10:25:40,193 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=on
|
||||
abstraction | 2025-11-18 10:25:40,194 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'on'}
|
||||
abstraction | 2025-11-18 10:25:40,194 - __main__ - INFO - ← abstract STATE licht_spuele_kueche: home/relay/licht_spuele_kueche/state → {"power": "on"}
|
||||
abstraction | 2025-11-18 10:25:40,211 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_spuele_kueche", "payload": {"power": "on"}, "ts": "2025-11-18T10:25:40.211493+00:00"}
|
||||
abstraction | 2025-11-18 10:25:47,821 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:25:47,821 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:25:47,821 - __main__ - INFO - ← abstract STATE licht_schrank_esszimmer: home/relay/licht_schrank_esszimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:25:47,838 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_schrank_esszimmer", "payload": {"power": "off"}, "ts": "2025-11-18T10:25:47.838508+00:00"}
|
||||
332
debug/2025-11-18-debug.log
Normal file
332
debug/2025-11-18-debug.log
Normal file
@@ -0,0 +1,332 @@
|
||||
✔ home-automation-abstraction Built 0.0s
|
||||
✔ home-automation-api Built 0.0s
|
||||
✔ home-automation-rules Built 0.0s
|
||||
✔ home-automation-ui Built 0.0s
|
||||
Attaching to abstraction, api, rules, ui
|
||||
abstraction | 2025-11-18 10:23:59,179 - asyncio - DEBUG - Using selector: EpollSelector
|
||||
rules | 2025-11-18 10:23:59,207 - asyncio - DEBUG - Using selector: EpollSelector
|
||||
rules | 2025-11-18 10:23:59,208 - __main__ - INFO - ============================================================
|
||||
rules | 2025-11-18 10:23:59,208 - __main__ - INFO - Rules Engine Starting
|
||||
rules | 2025-11-18 10:23:59,209 - __main__ - INFO - ============================================================
|
||||
rules | 2025-11-18 10:23:59,209 - __main__ - INFO - Config: /app/config/rules.yaml
|
||||
rules | 2025-11-18 10:23:59,210 - __main__ - INFO - MQTT: 172.23.1.102:1883
|
||||
rules | 2025-11-18 10:23:59,210 - __main__ - INFO - Redis: redis://172.23.1.116:6379/8
|
||||
rules | 2025-11-18 10:23:59,210 - __main__ - INFO - ============================================================
|
||||
rules | 2025-11-18 10:23:59,211 - __main__ - INFO - Loading rules configuration from /app/config/rules.yaml
|
||||
rules | 2025-11-18 10:23:59,217 - __main__ - INFO - Loaded 6 rule(s) from configuration
|
||||
rules | 2025-11-18 10:23:59,218 - __main__ - INFO - - window_setback_esszimmer (type: window_setback@1.0) [DISABLED]
|
||||
rules | 2025-11-18 10:23:59,218 - __main__ - INFO - - window_setback_kueche (type: window_setback@1.0) [DISABLED]
|
||||
rules | 2025-11-18 10:23:59,219 - __main__ - INFO - - window_setback_patty (type: window_setback@1.0) [DISABLED]
|
||||
rules | 2025-11-18 10:23:59,219 - __main__ - INFO - - window_setback_schlafzimmer (type: window_setback@1.0) [DISABLED]
|
||||
rules | 2025-11-18 10:23:59,220 - __main__ - INFO - - window_setback_wohnzimmer (type: window_setback@1.0) [DISABLED]
|
||||
rules | 2025-11-18 10:23:59,225 - __main__ - INFO - - window_setback_wolfgang (type: window_setback@1.0)
|
||||
rules | 2025-11-18 10:23:59,225 - __main__ - INFO - Successfully loaded 1 rule implementation(s) (5 disabled)
|
||||
rules | 2025-11-18 10:23:59,226 - __main__ - INFO - Rule window_setback_wolfgang validated: 1 contacts, 1 thermostats
|
||||
rules | 2025-11-18 10:23:59,226 - __main__ - DEBUG - Rule window_setback_wolfgang subscribes to 2 topic(s)
|
||||
rules | 2025-11-18 10:23:59,227 - __main__ - INFO - Total MQTT subscriptions needed: 2
|
||||
rules | 2025-11-18 10:23:59,227 - __main__ - INFO - Starting event processing loop
|
||||
abstraction | 2025-11-18 10:23:59,240 - __main__ - INFO - Loaded configuration from /app/config/devices.yaml
|
||||
abstraction | 2025-11-18 10:23:59,240 - __main__ - INFO - Loaded 64 device(s): lampe_semeniere_wohnzimmer, stehlampe_esszimmer_spiegel, stehlampe_esszimmer_schrank, grosse_lampe_wohnzimmer, lampe_naehtischchen_wohnzimmer, kleine_lampe_rechts_esszimmer, kleine_lampe_links_esszimmer, leselampe_esszimmer, medusalampe_schlafzimmer, sportlicht_am_fernseher_studierzimmer, deckenlampe_schlafzimmer, bettlicht_wolfgang, bettlicht_patty, schranklicht_hinten_patty, schranklicht_vorne_patty, leselampe_patty, deckenlampe_esszimmer, standlampe_esszimmer, haustuer, deckenlampe_flur_oben, kueche_deckenlampe, sportlicht_tisch, sportlicht_regal, licht_flur_oben_am_spiegel, experimentlabtest, thermostat_wolfgang, thermostat_kueche, thermostat_schlafzimmer, thermostat_esszimmer, thermostat_wohnzimmer, thermostat_patty, thermostat_bad_oben, thermostat_bad_unten, sterne_wohnzimmer, kontakt_schlafzimmer_strasse, kontakt_esszimmer_strasse_rechts, kontakt_esszimmer_strasse_links, kontakt_wohnzimmer_garten_rechts, kontakt_wohnzimmer_garten_links, kontakt_kueche_garten_fenster, kontakt_kueche_garten_tuer, kontakt_kueche_strasse_rechts, kontakt_kueche_strasse_links, kontakt_patty_garten_rechts, kontakt_patty_garten_links, kontakt_patty_strasse, kontakt_wolfgang_garten, kontakt_bad_oben_strasse, kontakt_bad_unten_strasse, sensor_schlafzimmer, sensor_wohnzimmer, sensor_kueche, sensor_arbeitszimmer_patty, sensor_arbeitszimmer_wolfgang, sensor_bad_oben, sensor_bad_unten, sensor_flur, sensor_waschkueche, sensor_sportzimmer, licht_spuele_kueche, licht_schrank_esszimmer, licht_regal_wohnzimmer, licht_flur_schrank, licht_terasse
|
||||
abstraction | 2025-11-18 10:23:59,241 - __main__ - INFO - Loaded 64 device(s) from configuration
|
||||
abstraction | 2025-11-18 10:23:59,292 - __main__ - INFO - Connected to Redis: redis://172.23.1.116:6379/8
|
||||
abstraction | 2025-11-18 10:23:59,292 - __main__ - INFO - Abstraction worker started
|
||||
abstraction | 2025-11-18 10:23:59,293 - __main__ - INFO - Connecting to MQTT broker: 172.23.1.102:1883
|
||||
abstraction | 2025-11-18 10:23:59,341 - __main__ - INFO - Connected to MQTT broker as home-automation-abstraction-2cfdfa
|
||||
abstraction | 2025-11-18 10:23:59,359 - __main__ - INFO - Subscribed to abstract SET: home/relay/lampe_semeniere_wohnzimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,377 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b8000015480b
|
||||
rules | 2025-11-18 10:23:59,378 - __main__ - INFO - Connecting to MQTT broker 172.23.1.102:1883 (client_id=rule_engine-782522)
|
||||
abstraction | 2025-11-18 10:23:59,394 - __main__ - INFO - Subscribed to abstract SET: home/light/stehlampe_esszimmer_spiegel/set
|
||||
abstraction | 2025-11-18 10:23:59,411 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010d06ea09
|
||||
abstraction | 2025-11-18 10:23:59,428 - __main__ - INFO - Subscribed to abstract SET: home/light/stehlampe_esszimmer_schrank/set
|
||||
rules | 2025-11-18 10:23:59,431 - __main__ - INFO - Connected to MQTT broker 172.23.1.102:1883
|
||||
abstraction | 2025-11-18 10:23:59,444 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010d09176c
|
||||
abstraction | 2025-11-18 10:23:59,460 - __main__ - INFO - Subscribed to abstract SET: home/relay/grosse_lampe_wohnzimmer/set
|
||||
rules | 2025-11-18 10:23:59,466 - __main__ - INFO - Subscribed to 2 topic(s): home/thermostat/thermostat_wolfgang/state, home/contact/kontakt_wolfgang_garten/state
|
||||
rules | 2025-11-18 10:23:59,467 - __main__ - DEBUG - Received event: {'topic': 'home/thermostat/thermostat_wolfgang/state', 'type': 'state', 'cap': 'thermostat', 'device_id': 'thermostat_wolfgang', 'payload': {'target': 23.0, 'current': 22.5, 'mode': 'heat'}, 'ts': '2025-11-18T10:23:59.467177'}
|
||||
rules | 2025-11-18 10:23:59,467 - __main__ - DEBUG - Filtering for cap=thermostat, device_id=thermostat_wolfgang
|
||||
rules | 2025-11-18 10:23:59,468 - __main__ - DEBUG - Rule window_setback_wolfgang: checking thermostats ['thermostat_wolfgang']
|
||||
rules | 2025-11-18 10:23:59,468 - __main__ - INFO - Event thermostat/thermostat_wolfgang: 1 matching rule(s)
|
||||
abstraction | 2025-11-18 10:23:59,477 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000151aca
|
||||
abstraction | 2025-11-18 10:23:59,493 - __main__ - INFO - Subscribed to abstract SET: home/relay/lampe_naehtischchen_wohnzimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,510 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x842e14fffee560ee
|
||||
abstraction | 2025-11-18 10:23:59,526 - __main__ - INFO - Subscribed to abstract SET: home/relay/kleine_lampe_rechts_esszimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,543 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000156645
|
||||
abstraction | 2025-11-18 10:23:59,560 - __main__ - INFO - Subscribed to abstract SET: home/relay/kleine_lampe_links_esszimmer/set
|
||||
rules | 2025-11-18 10:23:59,572 - __main__ - DEBUG - Rule window_setback_wolfgang: Updated current target for thermostat_wolfgang: 23.0°C
|
||||
rules | 2025-11-18 10:23:59,573 - __main__ - DEBUG - Received event: {'topic': 'home/contact/kontakt_wolfgang_garten/state', 'type': 'state', 'cap': 'contact', 'device_id': 'kontakt_wolfgang_garten', 'payload': {'contact': 'closed', 'battery': 100, 'linkquality': 32, 'device_temperature': 26, 'voltage': 3025}, 'ts': '2025-11-18T10:23:59.573073'}
|
||||
rules | 2025-11-18 10:23:59,573 - __main__ - DEBUG - Filtering for cap=contact, device_id=kontakt_wolfgang_garten
|
||||
rules | 2025-11-18 10:23:59,573 - __main__ - DEBUG - Rule window_setback_wolfgang: checking contacts ['kontakt_wolfgang_garten']
|
||||
rules | 2025-11-18 10:23:59,574 - __main__ - INFO - Event contact/kontakt_wolfgang_garten: 1 matching rule(s)
|
||||
abstraction | 2025-11-18 10:23:59,578 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000153099
|
||||
abstraction | 2025-11-18 10:23:59,595 - __main__ - INFO - Subscribed to abstract SET: home/light/leselampe_esszimmer/set
|
||||
rules | 2025-11-18 10:23:59,610 - __main__ - INFO - Rule window_setback_wolfgang: Window closed, restoring 1 thermostats to previous temperatures
|
||||
abstraction | 2025-11-18 10:23:59,612 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xec1bbdfffe7b84f2
|
||||
rules | 2025-11-18 10:23:59,627 - __main__ - WARNING - No previous target found for thermostat_wolfgang, cannot restore
|
||||
abstraction | 2025-11-18 10:23:59,630 - __main__ - INFO - Subscribed to abstract SET: home/relay/medusalampe_schlafzimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,647 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000154c7c
|
||||
abstraction | 2025-11-18 10:23:59,665 - __main__ - INFO - Subscribed to abstract SET: home/light/sportlicht_am_fernseher_studierzimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,682 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x842e14fffe76a23a
|
||||
abstraction | 2025-11-18 10:23:59,700 - __main__ - INFO - Subscribed to abstract SET: home/light/deckenlampe_schlafzimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,717 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x0017880108a406a7
|
||||
api | INFO: Started server process [1]
|
||||
api | INFO: Waiting for application startup.
|
||||
abstraction | 2025-11-18 10:23:59,735 - __main__ - INFO - Subscribed to abstract SET: home/light/bettlicht_wolfgang/set
|
||||
api | INFO: Application startup complete.
|
||||
abstraction | 2025-11-18 10:23:59,753 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00178801081570bf
|
||||
api | INFO: Uvicorn running on http://0.0.0.0:8001 (Press CTRL+C to quit)
|
||||
abstraction | 2025-11-18 10:23:59,770 - __main__ - INFO - Subscribed to abstract SET: home/light/bettlicht_patty/set
|
||||
abstraction | 2025-11-18 10:23:59,788 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x0017880108158b32
|
||||
abstraction | 2025-11-18 10:23:59,807 - __main__ - INFO - Subscribed to abstract SET: home/light/schranklicht_hinten_patty/set
|
||||
abstraction | 2025-11-18 10:23:59,825 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x0017880106e29571
|
||||
abstraction | 2025-11-18 10:23:59,844 - __main__ - INFO - Subscribed to abstract SET: home/relay/schranklicht_vorne_patty/set
|
||||
abstraction | 2025-11-18 10:23:59,862 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000154cf5
|
||||
abstraction | 2025-11-18 10:23:59,881 - __main__ - INFO - Subscribed to abstract SET: home/light/leselampe_patty/set
|
||||
abstraction | 2025-11-18 10:23:59,901 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010600ec9d
|
||||
abstraction | 2025-11-18 10:23:59,920 - __main__ - INFO - Subscribed to abstract SET: home/light/deckenlampe_esszimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,940 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x0017880108a03e45
|
||||
abstraction | 2025-11-18 10:23:59,959 - __main__ - INFO - Subscribed to abstract SET: home/light/standlampe_esszimmer/set
|
||||
abstraction | 2025-11-18 10:23:59,979 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xbc33acfffe21f547
|
||||
ui | UI using API_BASE: http://172.19.1.11:8001
|
||||
ui | UI using BASE_PATH: /
|
||||
ui | INFO: Started server process [1]
|
||||
ui | INFO: Waiting for application startup.
|
||||
ui | INFO: Application startup complete.
|
||||
abstraction | 2025-11-18 10:23:59,999 - __main__ - INFO - Subscribed to abstract SET: home/light/haustuer/set
|
||||
ui | INFO: Uvicorn running on http://0.0.0.0:8002 (Press CTRL+C to quit)
|
||||
abstraction | 2025-11-18 10:24:00,016 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xec1bbdfffea6a3da
|
||||
abstraction | 2025-11-18 10:24:00,034 - __main__ - INFO - Subscribed to abstract SET: home/light/deckenlampe_flur_oben/set
|
||||
abstraction | 2025-11-18 10:24:00,053 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010d2123a7
|
||||
abstraction | 2025-11-18 10:24:00,072 - __main__ - INFO - Subscribed to abstract SET: home/light/kueche_deckenlampe/set
|
||||
abstraction | 2025-11-18 10:24:00,090 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x001788010d2c40c4
|
||||
abstraction | 2025-11-18 10:24:00,108 - __main__ - INFO - Subscribed to abstract SET: home/light/sportlicht_tisch/set
|
||||
abstraction | 2025-11-18 10:24:00,127 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b8be2409f31b
|
||||
abstraction | 2025-11-18 10:24:00,145 - __main__ - INFO - Subscribed to abstract SET: home/light/sportlicht_regal/set
|
||||
abstraction | 2025-11-18 10:24:00,163 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b8be2409f569
|
||||
abstraction | 2025-11-18 10:24:00,183 - __main__ - INFO - Subscribed to abstract SET: home/light/licht_flur_oben_am_spiegel/set
|
||||
abstraction | 2025-11-18 10:24:00,201 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x842e14fffefe4ba4
|
||||
abstraction | 2025-11-18 10:24:00,218 - __main__ - INFO - Subscribed to abstract SET: home/light/experimentlabtest/set
|
||||
abstraction | 2025-11-18 10:24:00,237 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000195038
|
||||
abstraction | 2025-11-18 10:24:00,255 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_wolfgang/set
|
||||
abstraction | 2025-11-18 10:24:00,271 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x540f57fffe7e3cfe
|
||||
abstraction | 2025-11-18 10:24:00,292 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_kueche/set
|
||||
abstraction | 2025-11-18 10:24:00,313 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x94deb8fffe2e5c06
|
||||
abstraction | 2025-11-18 10:24:00,334 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_schlafzimmer/set
|
||||
abstraction | 2025-11-18 10:24:00,356 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/42/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 10:24:00,377 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_esszimmer/set
|
||||
abstraction | 2025-11-18 10:24:00,398 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/45/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 10:24:00,420 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_wohnzimmer/set
|
||||
abstraction | 2025-11-18 10:24:00,440 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/46/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 10:24:00,457 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_patty/set
|
||||
abstraction | 2025-11-18 10:24:00,475 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/39/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 10:24:00,493 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_bad_oben/set
|
||||
abstraction | 2025-11-18 10:24:00,509 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/41/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 10:24:00,530 - __main__ - INFO - Subscribed to abstract SET: home/thermostat/thermostat_bad_unten/set
|
||||
abstraction | 2025-11-18 10:24:00,551 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/48/1/SET_TEMPERATURE
|
||||
abstraction | 2025-11-18 10:24:00,572 - __main__ - INFO - Subscribed to abstract SET: home/relay/sterne_wohnzimmer/set
|
||||
abstraction | 2025-11-18 10:24:00,593 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0xf0d1b80000155fc2
|
||||
abstraction | 2025-11-18 10:24:00,593 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_schlafzimmer_strasse
|
||||
abstraction | 2025-11-18 10:24:00,614 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/52/1/STATE
|
||||
abstraction | 2025-11-18 10:24:00,614 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_esszimmer_strasse_rechts
|
||||
abstraction | 2025-11-18 10:24:00,630 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/26/1/STATE
|
||||
abstraction | 2025-11-18 10:24:00,630 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_esszimmer_strasse_links
|
||||
abstraction | 2025-11-18 10:24:00,647 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/27/1/STATE
|
||||
abstraction | 2025-11-18 10:24:00,647 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_wohnzimmer_garten_rechts
|
||||
abstraction | 2025-11-18 10:24:00,668 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/28/1/STATE
|
||||
abstraction | 2025-11-18 10:24:00,668 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_wohnzimmer_garten_links
|
||||
abstraction | 2025-11-18 10:24:00,691 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/29/1/STATE
|
||||
abstraction | 2025-11-18 10:24:00,691 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_kueche_garten_fenster
|
||||
abstraction | 2025-11-18 10:24:00,708 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b332785
|
||||
abstraction | 2025-11-18 10:24:00,708 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_kueche_garten_tuer
|
||||
abstraction | 2025-11-18 10:24:00,728 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b332788
|
||||
abstraction | 2025-11-18 10:24:00,728 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_kueche_strasse_rechts
|
||||
abstraction | 2025-11-18 10:24:00,747 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b151803
|
||||
abstraction | 2025-11-18 10:24:00,747 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_kueche_strasse_links
|
||||
abstraction | 2025-11-18 10:24:00,767 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b331d0b
|
||||
abstraction | 2025-11-18 10:24:00,767 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_patty_garten_rechts
|
||||
abstraction | 2025-11-18 10:24:00,784 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/18/1/STATE
|
||||
abstraction | 2025-11-18 10:24:00,784 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_patty_garten_links
|
||||
abstraction | 2025-11-18 10:24:00,802 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/22/1/STATE
|
||||
abstraction | 2025-11-18 10:24:00,802 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_patty_strasse
|
||||
abstraction | 2025-11-18 10:24:00,821 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d000af457cf
|
||||
abstraction | 2025-11-18 10:24:00,821 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_wolfgang_garten
|
||||
abstraction | 2025-11-18 10:24:00,838 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b3328da
|
||||
abstraction | 2025-11-18 10:24:00,838 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_bad_oben_strasse
|
||||
abstraction | 2025-11-18 10:24:00,855 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d008b333aec
|
||||
abstraction | 2025-11-18 10:24:00,855 - __main__ - INFO - Skipping SET subscription for read-only device: kontakt_bad_unten_strasse
|
||||
api | INFO: 172.16.3.98:51428 - "GET /realtime HTTP/1.1" 200 OK
|
||||
abstraction | 2025-11-18 10:24:00,872 - __main__ - INFO - Subscribed to vendor STATE: homegear/instance1/plain/44/1/STATE
|
||||
abstraction | 2025-11-18 10:24:00,872 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_schlafzimmer
|
||||
abstraction | 2025-11-18 10:24:00,891 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d00043292dc
|
||||
abstraction | 2025-11-18 10:24:00,891 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_wohnzimmer
|
||||
api | INFO: 172.16.3.98:51429 - "GET /realtime HTTP/1.1" 200 OK
|
||||
abstraction | 2025-11-18 10:24:00,907 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d0008975707
|
||||
abstraction | 2025-11-18 10:24:00,907 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_kueche
|
||||
abstraction | 2025-11-18 10:24:00,925 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d00083299bb
|
||||
abstraction | 2025-11-18 10:24:00,925 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_arbeitszimmer_patty
|
||||
abstraction | 2025-11-18 10:24:00,947 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d0003f052b7
|
||||
abstraction | 2025-11-18 10:24:00,947 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_arbeitszimmer_wolfgang
|
||||
abstraction | 2025-11-18 10:24:00,969 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d000543fb99
|
||||
abstraction | 2025-11-18 10:24:00,969 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_bad_oben
|
||||
abstraction | 2025-11-18 10:24:00,986 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d00093e8987
|
||||
abstraction | 2025-11-18 10:24:00,986 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_bad_unten
|
||||
abstraction | 2025-11-18 10:24:01,004 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d00093e662a
|
||||
abstraction | 2025-11-18 10:24:01,004 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_flur
|
||||
abstraction | 2025-11-18 10:24:01,022 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d000836ccc6
|
||||
abstraction | 2025-11-18 10:24:01,022 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_waschkueche
|
||||
abstraction | 2025-11-18 10:24:01,038 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d000449f3bc
|
||||
abstraction | 2025-11-18 10:24:01,038 - __main__ - INFO - Skipping SET subscription for read-only device: sensor_sportzimmer
|
||||
abstraction | 2025-11-18 10:24:01,058 - __main__ - INFO - Subscribed to vendor STATE: zigbee2mqtt/0x00158d0009421422
|
||||
abstraction | 2025-11-18 10:24:01,074 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_spuele_kueche/set
|
||||
abstraction | 2025-11-18 10:24:01,090 - __main__ - INFO - Subscribed to vendor STATE: shellies/LightKitchenSink/relay/0
|
||||
abstraction | 2025-11-18 10:24:01,107 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_schrank_esszimmer/set
|
||||
abstraction | 2025-11-18 10:24:01,122 - __main__ - INFO - Subscribed to vendor STATE: shellies/schrankesszimmer/relay/0
|
||||
abstraction | 2025-11-18 10:24:01,139 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_regal_wohnzimmer/set
|
||||
abstraction | 2025-11-18 10:24:01,155 - __main__ - INFO - Subscribed to vendor STATE: shellies/wohnzimmer-regal/relay/0
|
||||
abstraction | 2025-11-18 10:24:01,172 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_flur_schrank/set
|
||||
abstraction | 2025-11-18 10:24:01,189 - __main__ - INFO - Subscribed to vendor STATE: shellies/schrankflur/relay/0
|
||||
abstraction | 2025-11-18 10:24:01,205 - __main__ - INFO - Subscribed to abstract SET: home/relay/licht_terasse/set
|
||||
abstraction | 2025-11-18 10:24:01,222 - __main__ - INFO - Subscribed to vendor STATE: shellies/lichtterasse/relay/0
|
||||
abstraction | 2025-11-18 10:24:01,222 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=21
|
||||
abstraction | 2025-11-18 10:24:01,222 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 21.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 10:24:01,222 - __main__ - INFO - ← abstract STATE thermostat_schlafzimmer: home/thermostat/thermostat_schlafzimmer/state → {"target": 21.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 10:24:01,243 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_schlafzimmer", "payload": {"target": 21.0, "mode": "heat"}, "ts": "2025-11-18T10:24:01.243641+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,260 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=15
|
||||
abstraction | 2025-11-18 10:24:01,260 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 15.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 10:24:01,260 - __main__ - INFO - ← abstract STATE thermostat_esszimmer: home/thermostat/thermostat_esszimmer/state → {"target": 15.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 10:24:01,280 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_esszimmer", "payload": {"target": 15.0, "mode": "heat"}, "ts": "2025-11-18T10:24:01.280285+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,296 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=15
|
||||
abstraction | 2025-11-18 10:24:01,296 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 15.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 10:24:01,296 - __main__ - INFO - ← abstract STATE thermostat_wohnzimmer: home/thermostat/thermostat_wohnzimmer/state → {"target": 15.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 10:24:01,317 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_wohnzimmer", "payload": {"target": 15.0, "mode": "heat"}, "ts": "2025-11-18T10:24:01.317708+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,334 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=22
|
||||
abstraction | 2025-11-18 10:24:01,334 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 22.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 10:24:01,334 - __main__ - INFO - ← abstract STATE thermostat_patty: home/thermostat/thermostat_patty/state → {"target": 22.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 10:24:01,357 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_patty", "payload": {"target": 22.0, "mode": "heat"}, "ts": "2025-11-18T10:24:01.357082+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,373 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=20
|
||||
abstraction | 2025-11-18 10:24:01,373 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 20.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 10:24:01,373 - __main__ - INFO - ← abstract STATE thermostat_bad_oben: home/thermostat/thermostat_bad_oben/state → {"target": 20.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 10:24:01,395 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_bad_oben", "payload": {"target": 20.0, "mode": "heat"}, "ts": "2025-11-18T10:24:01.395470+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,411 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=thermostat, tech=max, payload=5
|
||||
abstraction | 2025-11-18 10:24:01,411 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=thermostat, tech=max, payload={'target': 5.0, 'mode': 'heat'}
|
||||
abstraction | 2025-11-18 10:24:01,411 - __main__ - INFO - ← abstract STATE thermostat_bad_unten: home/thermostat/thermostat_bad_unten/state → {"target": 5.0, "mode": "heat"}
|
||||
abstraction | 2025-11-18 10:24:01,431 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "thermostat_bad_unten", "payload": {"target": 5.0, "mode": "heat"}, "ts": "2025-11-18T10:24:01.431068+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,448 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 10:24:01,448 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 10:24:01,449 - __main__ - INFO - ← abstract STATE kontakt_schlafzimmer_strasse: home/contact/kontakt_schlafzimmer_strasse/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 10:24:01,472 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_schlafzimmer_strasse", "payload": {"contact": "closed"}, "ts": "2025-11-18T10:24:01.472456+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,491 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 10:24:01,491 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 10:24:01,491 - __main__ - INFO - ← abstract STATE kontakt_esszimmer_strasse_rechts: home/contact/kontakt_esszimmer_strasse_rechts/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 10:24:01,733 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_esszimmer_strasse_rechts", "payload": {"contact": "closed"}, "ts": "2025-11-18T10:24:01.733873+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,750 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 10:24:01,750 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 10:24:01,750 - __main__ - INFO - ← abstract STATE kontakt_esszimmer_strasse_links: home/contact/kontakt_esszimmer_strasse_links/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 10:24:01,771 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_esszimmer_strasse_links", "payload": {"contact": "closed"}, "ts": "2025-11-18T10:24:01.771380+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,788 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 10:24:01,788 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 10:24:01,788 - __main__ - INFO - ← abstract STATE kontakt_wohnzimmer_garten_rechts: home/contact/kontakt_wohnzimmer_garten_rechts/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 10:24:01,808 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_wohnzimmer_garten_rechts", "payload": {"contact": "closed"}, "ts": "2025-11-18T10:24:01.808516+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,825 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 10:24:01,825 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 10:24:01,825 - __main__ - INFO - ← abstract STATE kontakt_wohnzimmer_garten_links: home/contact/kontakt_wohnzimmer_garten_links/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 10:24:01,844 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_wohnzimmer_garten_links", "payload": {"contact": "closed"}, "ts": "2025-11-18T10:24:01.844046+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,860 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 10:24:01,861 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 10:24:01,861 - __main__ - INFO - ← abstract STATE kontakt_patty_garten_rechts: home/contact/kontakt_patty_garten_rechts/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 10:24:01,881 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_patty_garten_rechts", "payload": {"contact": "closed"}, "ts": "2025-11-18T10:24:01.881922+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,898 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=false
|
||||
abstraction | 2025-11-18 10:24:01,898 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'closed'}
|
||||
abstraction | 2025-11-18 10:24:01,898 - __main__ - INFO - ← abstract STATE kontakt_patty_garten_links: home/contact/kontakt_patty_garten_links/state → {"contact": "closed"}
|
||||
abstraction | 2025-11-18 10:24:01,922 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_patty_garten_links", "payload": {"contact": "closed"}, "ts": "2025-11-18T10:24:01.922254+00:00"}
|
||||
abstraction | 2025-11-18 10:24:01,940 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=contact, tech=max, payload=true
|
||||
abstraction | 2025-11-18 10:24:01,940 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=contact, tech=max, payload={'contact': 'open'}
|
||||
abstraction | 2025-11-18 10:24:01,940 - __main__ - INFO - ← abstract STATE kontakt_bad_unten_strasse: home/contact/kontakt_bad_unten_strasse/state → {"contact": "open"}
|
||||
abstraction | 2025-11-18 10:24:01,959 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "kontakt_bad_unten_strasse", "payload": {"contact": "open"}, "ts": "2025-11-18T10:24:01.959678+00:00"}
|
||||
abstraction | 2025-11-18 10:24:02,354 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:24:02,354 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:24:02,354 - __main__ - INFO - ← abstract STATE licht_terasse: home/relay/licht_terasse/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:24:02,373 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_terasse", "payload": {"power": "off"}, "ts": "2025-11-18T10:24:02.373461+00:00"}
|
||||
ui | INFO: 127.0.0.1:49192 - "GET /health HTTP/1.1" 200 OK
|
||||
api | INFO: 172.16.3.98:51450 - "GET /realtime HTTP/1.1" 200 OK
|
||||
abstraction | 2025-11-18 10:24:07,440 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:24:07,440 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:24:07,441 - __main__ - INFO - ← abstract STATE licht_regal_wohnzimmer: home/relay/licht_regal_wohnzimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:24:07,459 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_regal_wohnzimmer", "payload": {"power": "off"}, "ts": "2025-11-18T10:24:07.459082+00:00"}
|
||||
abstraction | 2025-11-18 10:24:08,817 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 100, 'humidity': 54.37, 'linkquality': 83, 'power_outage_count': 38416, 'pressure': 1004.2, 'temperature': 22.16, 'voltage': 3015}
|
||||
abstraction | 2025-11-18 10:24:08,817 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 100, 'humidity': 54.37, 'linkquality': 83, 'power_outage_count': 38416, 'pressure': 1004.2, 'temperature': 22.16, 'voltage': 3015}
|
||||
abstraction | 2025-11-18 10:24:08,817 - __main__ - INFO - ← abstract STATE sensor_arbeitszimmer_patty: home/temp_humidity/sensor_arbeitszimmer_patty/state → {"battery": 100, "humidity": 54.37, "linkquality": 83, "power_outage_count": 38416, "pressure": 1004.2, "temperature": 22.16, "voltage": 3015}
|
||||
abstraction | 2025-11-18 10:24:08,835 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "sensor_arbeitszimmer_patty", "payload": {"battery": 100, "humidity": 54.37, "linkquality": 83, "power_outage_count": 38416, "pressure": 1004.2, "temperature": 22.16, "voltage": 3015}, "ts": "2025-11-18T10:24:08.835488+00:00"}
|
||||
abstraction | 2025-11-18 10:24:08,852 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 100, 'humidity': 54.22, 'linkquality': 83, 'power_outage_count': 38416, 'pressure': 1004.2, 'temperature': 22.16, 'voltage': 3015}
|
||||
abstraction | 2025-11-18 10:24:08,852 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 100, 'humidity': 54.22, 'linkquality': 83, 'power_outage_count': 38416, 'pressure': 1004.2, 'temperature': 22.16, 'voltage': 3015}
|
||||
abstraction | 2025-11-18 10:24:08,852 - __main__ - INFO - ← abstract STATE sensor_arbeitszimmer_patty: home/temp_humidity/sensor_arbeitszimmer_patty/state → {"battery": 100, "humidity": 54.22, "linkquality": 83, "power_outage_count": 38416, "pressure": 1004.2, "temperature": 22.16, "voltage": 3015}
|
||||
abstraction | 2025-11-18 10:24:08,870 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "sensor_arbeitszimmer_patty", "payload": {"battery": 100, "humidity": 54.22, "linkquality": 83, "power_outage_count": 38416, "pressure": 1004.2, "temperature": 22.16, "voltage": 3015}, "ts": "2025-11-18T10:24:08.870674+00:00"}
|
||||
abstraction | 2025-11-18 10:24:08,887 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 100, 'humidity': 54.22, 'linkquality': 83, 'power_outage_count': 38416, 'pressure': 1003.9, 'temperature': 22.16, 'voltage': 3015}
|
||||
abstraction | 2025-11-18 10:24:08,887 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=temp_humidity_sensor, tech=zigbee2mqtt, payload={'battery': 100, 'humidity': 54.22, 'linkquality': 83, 'power_outage_count': 38416, 'pressure': 1003.9, 'temperature': 22.16, 'voltage': 3015}
|
||||
abstraction | 2025-11-18 10:24:08,887 - __main__ - INFO - ← abstract STATE sensor_arbeitszimmer_patty: home/temp_humidity/sensor_arbeitszimmer_patty/state → {"battery": 100, "humidity": 54.22, "linkquality": 83, "power_outage_count": 38416, "pressure": 1003.9, "temperature": 22.16, "voltage": 3015}
|
||||
abstraction | 2025-11-18 10:24:08,907 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "sensor_arbeitszimmer_patty", "payload": {"battery": 100, "humidity": 54.22, "linkquality": 83, "power_outage_count": 38416, "pressure": 1003.9, "temperature": 22.16, "voltage": 3015}, "ts": "2025-11-18T10:24:08.907729+00:00"}
|
||||
abstraction | 2025-11-18 10:24:10,178 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=on
|
||||
abstraction | 2025-11-18 10:24:10,178 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'on'}
|
||||
abstraction | 2025-11-18 10:24:10,178 - __main__ - INFO - ← abstract STATE licht_spuele_kueche: home/relay/licht_spuele_kueche/state → {"power": "on"}
|
||||
abstraction | 2025-11-18 10:24:10,196 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_spuele_kueche", "payload": {"power": "on"}, "ts": "2025-11-18T10:24:10.196762+00:00"}
|
||||
abstraction | 2025-11-18 10:24:17,815 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:24:17,815 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:24:17,815 - __main__ - INFO - ← abstract STATE licht_schrank_esszimmer: home/relay/licht_schrank_esszimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:24:17,834 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_schrank_esszimmer", "payload": {"power": "off"}, "ts": "2025-11-18T10:24:17.834042+00:00"}
|
||||
abstraction | 2025-11-18 10:24:32,370 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:24:32,370 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:24:32,370 - __main__ - INFO - ← abstract STATE licht_terasse: home/relay/licht_terasse/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:24:32,405 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_terasse", "payload": {"power": "off"}, "ts": "2025-11-18T10:24:32.405754+00:00"}
|
||||
ui | INFO: 127.0.0.1:39276 - "GET /health HTTP/1.1" 200 OK
|
||||
abstraction | 2025-11-18 10:24:37,447 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:24:37,447 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:24:37,447 - __main__ - INFO - ← abstract STATE licht_regal_wohnzimmer: home/relay/licht_regal_wohnzimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:24:37,465 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_regal_wohnzimmer", "payload": {"power": "off"}, "ts": "2025-11-18T10:24:37.465220+00:00"}
|
||||
abstraction | 2025-11-18 10:24:40,188 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=on
|
||||
abstraction | 2025-11-18 10:24:40,189 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'on'}
|
||||
abstraction | 2025-11-18 10:24:40,189 - __main__ - INFO - ← abstract STATE licht_spuele_kueche: home/relay/licht_spuele_kueche/state → {"power": "on"}
|
||||
abstraction | 2025-11-18 10:24:40,207 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_spuele_kueche", "payload": {"power": "on"}, "ts": "2025-11-18T10:24:40.207222+00:00"}
|
||||
abstraction | 2025-11-18 10:24:47,833 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:24:47,833 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:24:47,833 - __main__ - INFO - ← abstract STATE licht_schrank_esszimmer: home/relay/licht_schrank_esszimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:24:47,868 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_schrank_esszimmer", "payload": {"power": "off"}, "ts": "2025-11-18T10:24:47.868787+00:00"}
|
||||
abstraction | 2025-11-18 10:25:02,363 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:25:02,363 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:25:02,363 - __main__ - INFO - ← abstract STATE licht_terasse: home/relay/licht_terasse/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:25:02,381 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_terasse", "payload": {"power": "off"}, "ts": "2025-11-18T10:25:02.381792+00:00"}
|
||||
ui | INFO: 127.0.0.1:37108 - "GET /health HTTP/1.1" 200 OK
|
||||
abstraction | 2025-11-18 10:25:07,447 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:25:07,448 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:25:07,448 - __main__ - INFO - ← abstract STATE licht_regal_wohnzimmer: home/relay/licht_regal_wohnzimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:25:07,465 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_regal_wohnzimmer", "payload": {"power": "off"}, "ts": "2025-11-18T10:25:07.465566+00:00"}
|
||||
abstraction | 2025-11-18 10:25:10,185 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=on
|
||||
abstraction | 2025-11-18 10:25:10,185 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'on'}
|
||||
abstraction | 2025-11-18 10:25:10,185 - __main__ - INFO - ← abstract STATE licht_spuele_kueche: home/relay/licht_spuele_kueche/state → {"power": "on"}
|
||||
abstraction | 2025-11-18 10:25:10,202 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_spuele_kueche", "payload": {"power": "on"}, "ts": "2025-11-18T10:25:10.202372+00:00"}
|
||||
abstraction | 2025-11-18 10:25:17,820 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:25:17,820 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:25:17,820 - __main__ - INFO - ← abstract STATE licht_schrank_esszimmer: home/relay/licht_schrank_esszimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:25:17,838 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_schrank_esszimmer", "payload": {"power": "off"}, "ts": "2025-11-18T10:25:17.838140+00:00"}
|
||||
api | INFO: 172.16.3.98:51763 - "GET /docs HTTP/1.1" 200 OK
|
||||
api | /usr/local/lib/python3.14/site-packages/fastapi/openapi/utils.py:207: UserWarning: Duplicate Operation ID get_device_layout_devices__device_id__layout_get for function get_device_layout at /app/apps/api/main.py
|
||||
api | warnings.warn(message, stacklevel=1)
|
||||
api | /usr/local/lib/python3.14/site-packages/fastapi/openapi/utils.py:207: UserWarning: Duplicate Operation ID get_device_state_devices__device_id__state_get for function get_device_state at /app/apps/api/main.py
|
||||
api | warnings.warn(message, stacklevel=1)
|
||||
api | INFO: 172.16.3.98:51763 - "GET /openapi.json HTTP/1.1" 200 OK
|
||||
api | INFO: 172.16.3.98:51763 - "GET /devices/states HTTP/1.1" 200 OK
|
||||
abstraction | 2025-11-18 10:25:32,361 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:25:32,361 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:25:32,361 - __main__ - INFO - ← abstract STATE licht_terasse: home/relay/licht_terasse/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:25:32,379 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_terasse", "payload": {"power": "off"}, "ts": "2025-11-18T10:25:32.379286+00:00"}
|
||||
ui | INFO: 127.0.0.1:41510 - "GET /health HTTP/1.1" 200 OK
|
||||
abstraction | 2025-11-18 10:25:37,455 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:25:37,455 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:25:37,455 - __main__ - INFO - ← abstract STATE licht_regal_wohnzimmer: home/relay/licht_regal_wohnzimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:25:37,473 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_regal_wohnzimmer", "payload": {"power": "off"}, "ts": "2025-11-18T10:25:37.473171+00:00"}
|
||||
abstraction | 2025-11-18 10:25:40,193 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=on
|
||||
abstraction | 2025-11-18 10:25:40,194 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'on'}
|
||||
abstraction | 2025-11-18 10:25:40,194 - __main__ - INFO - ← abstract STATE licht_spuele_kueche: home/relay/licht_spuele_kueche/state → {"power": "on"}
|
||||
abstraction | 2025-11-18 10:25:40,211 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_spuele_kueche", "payload": {"power": "on"}, "ts": "2025-11-18T10:25:40.211493+00:00"}
|
||||
abstraction | 2025-11-18 10:25:47,821 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract IN: type=relay, tech=shelly, payload=off
|
||||
abstraction | 2025-11-18 10:25:47,821 - apps.abstraction.transformation - DEBUG - transform_vendor_to_abstract OUT: type=relay, tech=shelly, payload={'power': 'off'}
|
||||
abstraction | 2025-11-18 10:25:47,821 - __main__ - INFO - ← abstract STATE licht_schrank_esszimmer: home/relay/licht_schrank_esszimmer/state → {"power": "off"}
|
||||
abstraction | 2025-11-18 10:25:47,838 - __main__ - INFO - ← Redis PUBLISH ui:updates → {"type": "state", "device_id": "licht_schrank_esszimmer", "payload": {"power": "off"}, "ts": "2025-11-18T10:25:47.838508+00:00"}
|
||||
60
docker-compose.yaml
Normal file
60
docker-compose.yaml
Normal file
@@ -0,0 +1,60 @@
|
||||
x-environment: &default-env
|
||||
MQTT_BROKER: "172.23.1.102"
|
||||
MQTT_PORT: 1883
|
||||
REDIS_HOST: "172.23.1.116"
|
||||
REDIS_PORT: 6379
|
||||
REDIS_DB: 8
|
||||
|
||||
services:
|
||||
|
||||
ui:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/ui/Dockerfile
|
||||
container_name: ui
|
||||
environment:
|
||||
UI_PORT: 8002
|
||||
API_BASE: "http://172.19.1.11:8001"
|
||||
BASE_PATH: "/"
|
||||
ports:
|
||||
- "8002:8002"
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/api/Dockerfile
|
||||
container_name: api
|
||||
environment:
|
||||
<<: *default-env
|
||||
REDIS_CHANNEL: "ui:updates"
|
||||
volumes:
|
||||
- ./config:/app/config:ro
|
||||
ports:
|
||||
- "8001:8001"
|
||||
depends_on:
|
||||
- abstraction
|
||||
|
||||
abstraction:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/abstraction/Dockerfile
|
||||
container_name: abstraction
|
||||
environment:
|
||||
<<: *default-env
|
||||
volumes:
|
||||
- ./config:/app/config:ro
|
||||
|
||||
rules:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/rules/Dockerfile
|
||||
container_name: rules
|
||||
environment:
|
||||
<<: *default-env
|
||||
RULES_CONFIG: "/app/config/rules.yaml"
|
||||
volumes:
|
||||
- ./config:/app/config:ro
|
||||
depends_on:
|
||||
- abstraction
|
||||
@@ -1,23 +0,0 @@
|
||||
# Infrastructure
|
||||
|
||||
This directory contains infrastructure-related files for the home automation project.
|
||||
|
||||
## Files
|
||||
|
||||
- `docker-compose.yml`: Docker Compose configuration for running services
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Start services
|
||||
docker-compose up -d
|
||||
|
||||
# Stop services
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## TODO
|
||||
|
||||
- Add service definitions to docker-compose.yml
|
||||
- Add deployment configurations
|
||||
- Add monitoring and logging setup
|
||||
@@ -1,15 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Placeholder for future services
|
||||
# Example:
|
||||
# api:
|
||||
# build:
|
||||
# context: ..
|
||||
# dockerfile: apps/api/Dockerfile
|
||||
# ports:
|
||||
# - "8000:8000"
|
||||
|
||||
placeholder:
|
||||
image: alpine:latest
|
||||
command: echo "Docker Compose placeholder - add your services here"
|
||||
@@ -1,6 +1,63 @@
|
||||
"""Home capabilities package."""
|
||||
|
||||
from packages.home_capabilities.light import CAP_VERSION, LightState
|
||||
from packages.home_capabilities.layout import DeviceTile, Room, UiLayout, load_layout
|
||||
from packages.home_capabilities.light import CAP_VERSION as LIGHT_VERSION
|
||||
from packages.home_capabilities.light import LightState
|
||||
from packages.home_capabilities.thermostat import CAP_VERSION as THERMOSTAT_VERSION
|
||||
from packages.home_capabilities.thermostat import ThermostatState
|
||||
from packages.home_capabilities.contact_sensor import CAP_VERSION as CONTACT_SENSOR_VERSION
|
||||
from packages.home_capabilities.contact_sensor import ContactState
|
||||
from packages.home_capabilities.temp_humidity_sensor import CAP_VERSION as TEMP_HUMIDITY_SENSOR_VERSION
|
||||
from packages.home_capabilities.temp_humidity_sensor import TempHumidityState
|
||||
from packages.home_capabilities.relay import CAP_VERSION as RELAY_VERSION
|
||||
from packages.home_capabilities.relay import RelayState
|
||||
from packages.home_capabilities.three_phase_powermeter import CAP_VERSION as THREE_PHASE_POWERMETER_VERSION
|
||||
from packages.home_capabilities.three_phase_powermeter import ThreePhasePowerState
|
||||
|
||||
__all__ = ["LightState", "CAP_VERSION", "DeviceTile", "Room", "UiLayout", "load_layout"]
|
||||
from packages.home_capabilities.layout import (
|
||||
DeviceTile,
|
||||
Room,
|
||||
UiLayout,
|
||||
load_layout,
|
||||
)
|
||||
from packages.home_capabilities.groups_scenes import (
|
||||
GroupConfig,
|
||||
GroupsConfigRoot,
|
||||
GroupSelector,
|
||||
SceneConfig,
|
||||
ScenesConfigRoot,
|
||||
SceneSelector,
|
||||
SceneStep,
|
||||
get_group_by_id,
|
||||
get_scene_by_id,
|
||||
load_groups,
|
||||
load_scenes,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LightState",
|
||||
"LIGHT_VERSION",
|
||||
"ThermostatState",
|
||||
"THERMOSTAT_VERSION",
|
||||
"ContactState",
|
||||
"CONTACT_SENSOR_VERSION",
|
||||
"TempHumidityState",
|
||||
"TEMP_HUMIDITY_SENSOR_VERSION",
|
||||
"RelayState",
|
||||
"RELAY_VERSION",
|
||||
"DeviceTile",
|
||||
"Room",
|
||||
"UiLayout",
|
||||
"load_layout",
|
||||
"GroupConfig",
|
||||
"GroupsConfigRoot",
|
||||
"GroupSelector",
|
||||
"SceneConfig",
|
||||
"ScenesConfigRoot",
|
||||
"SceneSelector",
|
||||
"SceneStep",
|
||||
"get_group_by_id",
|
||||
"get_scene_by_id",
|
||||
"load_groups",
|
||||
"load_scenes",
|
||||
"ThreePhasePowerState",
|
||||
]
|
||||
|
||||
96
packages/home_capabilities/contact_sensor.py
Normal file
96
packages/home_capabilities/contact_sensor.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Contact Sensor Capability - Fensterkontakt (read-only).
|
||||
|
||||
This module defines the ContactState model for door/window contact sensors.
|
||||
These sensors report their open/closed state and are read-only devices.
|
||||
|
||||
Capability Version: contact_sensor@1.0.0
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
# Capability metadata
|
||||
CAP_VERSION = "contact_sensor@1.0.0"
|
||||
DISPLAY_NAME = "Contact Sensor"
|
||||
|
||||
|
||||
class ContactState(BaseModel):
|
||||
"""State model for contact sensors (door/window sensors).
|
||||
|
||||
Contact sensors are read-only devices that report whether a door or window
|
||||
is open or closed. They typically also report battery level and signal quality.
|
||||
|
||||
Attributes:
|
||||
contact: Current state of the contact ("open" or "closed")
|
||||
battery: Battery level percentage (0-100), optional
|
||||
linkquality: MQTT link quality indicator, optional
|
||||
device_temperature: Internal device temperature in °C, optional
|
||||
voltage: Battery voltage in mV, optional
|
||||
ts: Timestamp of the state reading, optional
|
||||
|
||||
Examples:
|
||||
>>> ContactState(contact="open")
|
||||
ContactState(contact='open', battery=None, ...)
|
||||
|
||||
>>> ContactState(contact="closed", battery=95, linkquality=87)
|
||||
ContactState(contact='closed', battery=95, linkquality=87, ...)
|
||||
"""
|
||||
|
||||
contact: Literal["open", "closed"] = Field(
|
||||
...,
|
||||
description="Contact state: 'open' for open door/window, 'closed' for closed"
|
||||
)
|
||||
|
||||
battery: Annotated[int, Field(ge=0, le=100)] | None = Field(
|
||||
None,
|
||||
description="Battery level in percent (0-100)"
|
||||
)
|
||||
|
||||
linkquality: int | None = Field(
|
||||
None,
|
||||
description="Link quality indicator (typically 0-255)"
|
||||
)
|
||||
|
||||
device_temperature: float | None = Field(
|
||||
None,
|
||||
description="Internal device temperature in degrees Celsius"
|
||||
)
|
||||
|
||||
voltage: int | None = Field(
|
||||
None,
|
||||
description="Battery voltage in millivolts"
|
||||
)
|
||||
|
||||
ts: datetime | None = Field(
|
||||
None,
|
||||
description="Timestamp of the state reading"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def normalize_bool(is_open: bool) -> "ContactState":
|
||||
"""Convert boolean to ContactState.
|
||||
|
||||
Helper method to convert a boolean value to a ContactState instance.
|
||||
Useful when integrating with systems that use True/False for contact state.
|
||||
|
||||
Args:
|
||||
is_open: True if contact is open, False if closed
|
||||
|
||||
Returns:
|
||||
ContactState instance with appropriate contact value
|
||||
|
||||
Examples:
|
||||
>>> ContactState.normalize_bool(True)
|
||||
ContactState(contact='open', ...)
|
||||
|
||||
>>> ContactState.normalize_bool(False)
|
||||
ContactState(contact='closed', ...)
|
||||
"""
|
||||
return ContactState(contact="open" if is_open else "closed")
|
||||
|
||||
|
||||
# Public API
|
||||
__all__ = ["ContactState", "CAP_VERSION", "DISPLAY_NAME"]
|
||||
229
packages/home_capabilities/groups_scenes.py
Normal file
229
packages/home_capabilities/groups_scenes.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
Configuration models and loaders for groups and scenes.
|
||||
|
||||
This module provides Pydantic models for validating groups.yaml and scenes.yaml,
|
||||
along with loader functions that parse YAML files into typed configuration objects.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GROUP MODELS
|
||||
# ============================================================================
|
||||
|
||||
class GroupSelector(BaseModel):
|
||||
"""Selector for automatically adding devices to a group."""
|
||||
type: str = Field(..., description="Device type (e.g., 'light', 'thermostat')")
|
||||
room: str | None = Field(None, description="Filter by room name")
|
||||
tags: list[str] | None = Field(None, description="Filter by device tags")
|
||||
|
||||
|
||||
class GroupConfig(BaseModel):
|
||||
"""Configuration for a device group."""
|
||||
id: str = Field(..., description="Unique group identifier")
|
||||
name: str = Field(..., description="Human-readable group name")
|
||||
selector: GroupSelector | None = Field(None, description="Auto-select devices by criteria")
|
||||
device_ids: list[str] = Field(default_factory=list, description="Explicit device IDs")
|
||||
capabilities: dict[str, bool] = Field(
|
||||
default_factory=dict,
|
||||
description="Supported capabilities (e.g., {'brightness': True})"
|
||||
)
|
||||
|
||||
|
||||
class GroupsConfigRoot(BaseModel):
|
||||
"""Root configuration for groups.yaml."""
|
||||
version: int = Field(..., description="Configuration schema version")
|
||||
groups: list[GroupConfig] = Field(default_factory=list, description="List of groups")
|
||||
|
||||
@field_validator('groups')
|
||||
@classmethod
|
||||
def validate_unique_ids(cls, groups: list[GroupConfig]) -> list[GroupConfig]:
|
||||
"""Ensure all group IDs are unique."""
|
||||
ids = [g.id for g in groups]
|
||||
duplicates = [id for id in ids if ids.count(id) > 1]
|
||||
if duplicates:
|
||||
raise ValueError(f"Duplicate group IDs found: {set(duplicates)}")
|
||||
return groups
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SCENE MODELS
|
||||
# ============================================================================
|
||||
|
||||
class SceneSelector(BaseModel):
|
||||
"""Selector for targeting devices in a scene step."""
|
||||
type: str | None = Field(None, description="Device type (e.g., 'light', 'outlet')")
|
||||
room: str | None = Field(None, description="Filter by room name")
|
||||
tags: list[str] | None = Field(None, description="Filter by device tags")
|
||||
|
||||
|
||||
class SceneStep(BaseModel):
|
||||
"""A single step in a scene execution."""
|
||||
selector: SceneSelector | None = Field(None, description="Select devices by criteria")
|
||||
group_id: str | None = Field(None, description="Target a specific group")
|
||||
action: dict[str, Any] = Field(..., description="Action to execute (type + payload)")
|
||||
delay_ms: int | None = Field(None, description="Delay before next step (milliseconds)")
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_selector_or_group(self) -> 'SceneStep':
|
||||
"""Ensure either selector OR group_id is specified, but not both."""
|
||||
has_selector = self.selector is not None
|
||||
has_group = self.group_id is not None
|
||||
|
||||
if not has_selector and not has_group:
|
||||
raise ValueError("SceneStep must have either 'selector' or 'group_id'")
|
||||
|
||||
if has_selector and has_group:
|
||||
raise ValueError("SceneStep cannot have both 'selector' and 'group_id'")
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class SceneConfig(BaseModel):
|
||||
"""Configuration for a scene."""
|
||||
id: str = Field(..., description="Unique scene identifier")
|
||||
name: str = Field(..., description="Human-readable scene name")
|
||||
steps: list[SceneStep] = Field(..., description="Ordered list of actions")
|
||||
|
||||
|
||||
class ScenesConfigRoot(BaseModel):
|
||||
"""Root configuration for scenes.yaml."""
|
||||
version: int = Field(..., description="Configuration schema version")
|
||||
scenes: list[SceneConfig] = Field(default_factory=list, description="List of scenes")
|
||||
|
||||
@field_validator('scenes')
|
||||
@classmethod
|
||||
def validate_unique_ids(cls, scenes: list[SceneConfig]) -> list[SceneConfig]:
|
||||
"""Ensure all scene IDs are unique."""
|
||||
ids = [s.id for s in scenes]
|
||||
duplicates = [id for id in ids if ids.count(id) > 1]
|
||||
if duplicates:
|
||||
raise ValueError(f"Duplicate scene IDs found: {set(duplicates)}")
|
||||
return scenes
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LOADER FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
def load_groups(path: Path | str) -> GroupsConfigRoot:
|
||||
"""
|
||||
Load and validate groups configuration from YAML file.
|
||||
|
||||
Args:
|
||||
path: Path to groups.yaml file
|
||||
|
||||
Returns:
|
||||
Validated GroupsConfigRoot object
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config file doesn't exist
|
||||
ValidationError: If configuration is invalid
|
||||
ValueError: If duplicate group IDs are found or YAML is empty
|
||||
"""
|
||||
path = Path(path)
|
||||
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Groups config file not found: {path}")
|
||||
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if data is None:
|
||||
raise ValueError(f"Groups config file is empty: {path}")
|
||||
|
||||
return GroupsConfigRoot.model_validate(data)
|
||||
|
||||
|
||||
def load_scenes(path: Path | str) -> ScenesConfigRoot:
|
||||
"""
|
||||
Load and validate scenes configuration from YAML file.
|
||||
|
||||
Args:
|
||||
path: Path to scenes.yaml file
|
||||
|
||||
Returns:
|
||||
Validated ScenesConfigRoot object
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config file doesn't exist
|
||||
ValidationError: If configuration is invalid
|
||||
ValueError: If duplicate scene IDs, invalid steps, or empty YAML are found
|
||||
"""
|
||||
path = Path(path)
|
||||
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Scenes config file not found: {path}")
|
||||
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if data is None:
|
||||
raise ValueError(f"Scenes config file is empty: {path}")
|
||||
|
||||
return ScenesConfigRoot.model_validate(data)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CONVENIENCE FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
def get_group_by_id(config: GroupsConfigRoot, group_id: str) -> GroupConfig | None:
|
||||
"""Find a group by its ID."""
|
||||
for group in config.groups:
|
||||
if group.id == group_id:
|
||||
return group
|
||||
return None
|
||||
|
||||
|
||||
def get_scene_by_id(config: ScenesConfigRoot, scene_id: str) -> SceneConfig | None:
|
||||
"""Find a scene by its ID."""
|
||||
for scene in config.scenes:
|
||||
if scene.id == scene_id:
|
||||
return scene
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# EXAMPLE USAGE
|
||||
# ============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pathlib import Path
|
||||
|
||||
# Example: Load groups configuration
|
||||
try:
|
||||
groups_path = Path(__file__).parent.parent / "config" / "groups.yaml"
|
||||
groups = load_groups(groups_path)
|
||||
print(f"✓ Loaded {len(groups.groups)} groups (version {groups.version})")
|
||||
for group in groups.groups:
|
||||
print(f" - {group.id}: {group.name}")
|
||||
if group.selector:
|
||||
print(f" Selector: type={group.selector.type}, room={group.selector.room}")
|
||||
if group.device_ids:
|
||||
print(f" Devices: {', '.join(group.device_ids)}")
|
||||
except Exception as e:
|
||||
print(f"✗ Error loading groups: {e}")
|
||||
|
||||
print()
|
||||
|
||||
# Example: Load scenes configuration
|
||||
try:
|
||||
scenes_path = Path(__file__).parent.parent / "config" / "scenes.yaml"
|
||||
scenes = load_scenes(scenes_path)
|
||||
print(f"✓ Loaded {len(scenes.scenes)} scenes (version {scenes.version})")
|
||||
for scene in scenes.scenes:
|
||||
print(f" - {scene.id}: {scene.name} ({len(scene.steps)} steps)")
|
||||
for i, step in enumerate(scene.steps, 1):
|
||||
if step.selector:
|
||||
print(f" Step {i}: selector type={step.selector.type}")
|
||||
elif step.group_id:
|
||||
print(f" Step {i}: group_id={step.group_id}")
|
||||
print(f" Action: {step.action}")
|
||||
except Exception as e:
|
||||
print(f"✗ Error loading scenes: {e}")
|
||||
21
packages/home_capabilities/relay.py
Normal file
21
packages/home_capabilities/relay.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Relay capability model.
|
||||
A relay is essentially a simple on/off switch, like a light with only power control.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Literal
|
||||
|
||||
# Capability version
|
||||
CAP_VERSION = "relay@1.0.0"
|
||||
DISPLAY_NAME = "Relay"
|
||||
|
||||
|
||||
class RelayState(BaseModel):
|
||||
"""State model for relay devices (on/off only)"""
|
||||
power: Literal["on", "off"] = Field(..., description="Power state: on or off")
|
||||
|
||||
|
||||
class RelaySetPayload(BaseModel):
|
||||
"""Payload for setting relay state"""
|
||||
power: Literal["on", "off"] = Field(..., description="Desired power state: on or off")
|
||||
37
packages/home_capabilities/temp_humidity_sensor.py
Normal file
37
packages/home_capabilities/temp_humidity_sensor.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Temperature & Humidity Sensor Capability - temp_humidity_sensor@1.0.0
|
||||
|
||||
Read-only sensor for temperature and humidity measurements.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TempHumidityState(BaseModel):
|
||||
"""
|
||||
State model for temperature & humidity sensors.
|
||||
|
||||
Required fields:
|
||||
- temperature: Temperature in degrees Celsius
|
||||
- humidity: Relative humidity in percent
|
||||
|
||||
Optional fields:
|
||||
- battery: Battery level 0-100%
|
||||
- linkquality: Signal quality indicator
|
||||
- voltage: Battery voltage in mV
|
||||
- ts: Timestamp of measurement
|
||||
"""
|
||||
temperature: float = Field(..., description="Temperature in degrees Celsius")
|
||||
humidity: float = Field(..., description="Relative humidity in percent (0-100)")
|
||||
battery: Annotated[int, Field(ge=0, le=100)] | None = None
|
||||
linkquality: int | None = None
|
||||
voltage: int | None = None
|
||||
ts: datetime | None = None
|
||||
|
||||
|
||||
# Capability metadata
|
||||
CAP_VERSION = "temp_humidity_sensor@1.0.0"
|
||||
DISPLAY_NAME = "Temperature & Humidity Sensor"
|
||||
77
packages/home_capabilities/thermostat.py
Normal file
77
packages/home_capabilities/thermostat.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Thermostat Capability Model
|
||||
Pydantic v2 model for thermostat device state and commands.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
CAP_VERSION = "thermostat@2.0.0"
|
||||
|
||||
|
||||
class ThermostatState(BaseModel):
|
||||
"""
|
||||
Thermostat state model with validation.
|
||||
|
||||
Attributes:
|
||||
mode: Operating mode (off, heat, auto) - optional for SET commands
|
||||
target: Target temperature in °C [5.0..30.0]
|
||||
current: Current temperature in °C (optional in SET, required in STATE)
|
||||
battery: Battery level 0-100% (optional)
|
||||
window_open: Window open detection (optional)
|
||||
"""
|
||||
|
||||
mode: Literal["off", "heat", "auto"] | None = Field(
|
||||
None,
|
||||
description="Operating mode of the thermostat (optional for SET commands)"
|
||||
)
|
||||
|
||||
target: float | Decimal = Field(
|
||||
...,
|
||||
ge=5.0,
|
||||
le=30.0,
|
||||
description="Target temperature in degrees Celsius"
|
||||
)
|
||||
|
||||
current: float | Decimal | None = Field(
|
||||
None,
|
||||
ge=0.0,
|
||||
description="Current measured temperature in degrees Celsius"
|
||||
)
|
||||
|
||||
battery: int | None = Field(
|
||||
None,
|
||||
ge=0,
|
||||
le=100,
|
||||
description="Battery level percentage"
|
||||
)
|
||||
|
||||
window_open: bool | None = Field(
|
||||
None,
|
||||
description="Window open detection status"
|
||||
)
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"examples": [
|
||||
{
|
||||
"mode": "heat",
|
||||
"target": 21.0,
|
||||
"current": 20.2,
|
||||
"battery": 85,
|
||||
"window_open": False
|
||||
},
|
||||
{
|
||||
"mode": "auto",
|
||||
"target": 22.5
|
||||
},
|
||||
{
|
||||
"mode": "off",
|
||||
"target": 5.0,
|
||||
"current": 18.0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
29
packages/home_capabilities/three_phase_powermeter.py
Normal file
29
packages/home_capabilities/three_phase_powermeter.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class ThreePhasePowerState(BaseModel):
|
||||
"""
|
||||
State model for a three-phase power meter.
|
||||
|
||||
Required fields:
|
||||
- energy: Total energy in kWh
|
||||
- total_power: Total power in W
|
||||
- phase1_power, phase2_power, phase3_power: Power per phase in W
|
||||
- phase1_voltage, phase2_voltage, phase3_voltage: Voltage per phase in V
|
||||
- phase1_current, phase2_current, phase3_current: Current per phase in A
|
||||
"""
|
||||
energy: float = Field(..., description="Total energy in kWh")
|
||||
total_power: float = Field(..., description="Total power in W")
|
||||
phase1_power: float = Field(..., description="Power for phase 1 in W")
|
||||
phase2_power: float = Field(..., description="Power for phase 2 in W")
|
||||
phase3_power: float = Field(..., description="Power for phase 3 in W")
|
||||
phase1_voltage: float = Field(..., description="Voltage for phase 1 in V")
|
||||
phase2_voltage: float = Field(..., description="Voltage for phase 2 in V")
|
||||
phase3_voltage: float = Field(..., description="Voltage for phase 3 in V")
|
||||
phase1_current: float = Field(..., description="Current for phase 1 in A")
|
||||
phase2_current: float = Field(..., description="Current for phase 2 in A")
|
||||
phase3_current: float = Field(..., description="Current for phase 3 in A")
|
||||
|
||||
|
||||
# Capability metadata
|
||||
CAP_VERSION = "three_phase_powermeter@1.0.0"
|
||||
DISPLAY_NAME = "Three-Phase Power Meter"
|
||||
190
tools/README_device_simulator.md
Normal file
190
tools/README_device_simulator.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# Device Simulator
|
||||
|
||||
Unified MQTT device simulator für das Home Automation System.
|
||||
|
||||
## Übersicht
|
||||
|
||||
Dieser Simulator ersetzt die einzelnen Simulatoren (`sim_test_lampe.py`, `sim_thermo.py`) und vereint alle Device-Typen in einer einzigen Anwendung.
|
||||
|
||||
## Unterstützte Geräte
|
||||
|
||||
### Lampen (3 Geräte)
|
||||
- `test_lampe_1` - Mit Power und Brightness
|
||||
- `test_lampe_2` - Mit Power und Brightness
|
||||
- `test_lampe_3` - Mit Power und Brightness
|
||||
|
||||
**Features:**
|
||||
- `power`: "on" oder "off"
|
||||
- `brightness`: 0-100
|
||||
|
||||
### Thermostaten (1 Gerät)
|
||||
- `test_thermo_1` - Vollständiger Thermostat mit Temperatur-Simulation
|
||||
|
||||
**Features:**
|
||||
- `mode`: "off", "heat", oder "auto"
|
||||
- `target`: Soll-Temperatur (5.0-30.0°C)
|
||||
- `current`: Ist-Temperatur (wird simuliert)
|
||||
- `battery`: Batteriestand (90%)
|
||||
- `window_open`: Fensterstatus (false)
|
||||
|
||||
**Temperatur-Simulation:**
|
||||
- Alle 5 Sekunden wird die Ist-Temperatur angepasst
|
||||
- **HEAT/AUTO Mode**: Drift zu `target` (+0.2°C pro Intervall)
|
||||
- **OFF Mode**: Drift zu Ambient-Temperatur 18°C (-0.2°C pro Intervall)
|
||||
|
||||
## MQTT-Konfiguration
|
||||
|
||||
- **Broker**: 172.16.2.16:1883 (konfigurierbar via ENV)
|
||||
- **QoS**: 1 für alle Publishes
|
||||
- **Retained**: Ja für alle State-Messages
|
||||
- **Client ID**: device_simulator
|
||||
|
||||
### Topics
|
||||
|
||||
Für jedes Gerät:
|
||||
- Subscribe: `vendor/{device_id}/set` (QoS 1)
|
||||
- Publish: `vendor/{device_id}/state` (QoS 1, retained)
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Starten
|
||||
|
||||
```bash
|
||||
poetry run python tools/device_simulator.py
|
||||
```
|
||||
|
||||
Oder im Hintergrund:
|
||||
|
||||
```bash
|
||||
poetry run python tools/device_simulator.py > /tmp/simulator.log 2>&1 &
|
||||
```
|
||||
|
||||
### Umgebungsvariablen
|
||||
|
||||
```bash
|
||||
export MQTT_BROKER="172.16.2.16" # MQTT Broker Host
|
||||
export MQTT_PORT="1883" # MQTT Broker Port
|
||||
```
|
||||
|
||||
## Testen
|
||||
|
||||
Ein umfassendes Test-Skript ist verfügbar:
|
||||
|
||||
```bash
|
||||
./tools/test_device_simulator.sh
|
||||
```
|
||||
|
||||
Das Test-Skript:
|
||||
1. Stoppt alle laufenden Services
|
||||
2. Startet Abstraction Layer, API und Simulator
|
||||
3. Testet alle Lampen-Operationen
|
||||
4. Testet alle Thermostat-Operationen
|
||||
5. Verifiziert MQTT State Messages
|
||||
6. Zeigt Simulator-Logs
|
||||
|
||||
## Beispiele
|
||||
|
||||
### Lampe einschalten
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8001/devices/test_lampe_1/set \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"light","payload":{"power":"on"}}'
|
||||
```
|
||||
|
||||
### Helligkeit setzen
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8001/devices/test_lampe_1/set \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"light","payload":{"brightness":75}}'
|
||||
```
|
||||
|
||||
### Thermostat Mode setzen
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8001/devices/test_thermo_1/set \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"thermostat","payload":{"mode":"heat","target":22.5}}'
|
||||
```
|
||||
|
||||
### State abfragen via MQTT
|
||||
|
||||
```bash
|
||||
# Lampe
|
||||
mosquitto_sub -h 172.16.2.16 -t 'vendor/test_lampe_1/state' -C 1
|
||||
|
||||
# Thermostat
|
||||
mosquitto_sub -h 172.16.2.16 -t 'vendor/test_thermo_1/state' -C 1
|
||||
```
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
Browser/API
|
||||
↓ POST /devices/{id}/set
|
||||
API Server (Port 8001)
|
||||
↓ MQTT: home/{type}/{id}/set
|
||||
Abstraction Layer
|
||||
↓ MQTT: vendor/{id}/set
|
||||
Device Simulator
|
||||
↓ MQTT: vendor/{id}/state (retained)
|
||||
Abstraction Layer
|
||||
↓ MQTT: home/{type}/{id}/state (retained)
|
||||
↓ Redis Pub/Sub: ui:updates
|
||||
UI / Dashboard
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
Der Simulator loggt alle Aktivitäten:
|
||||
- Startup und MQTT-Verbindung
|
||||
- Empfangene SET-Commands
|
||||
- State-Änderungen
|
||||
- Temperature-Drift (Thermostaten)
|
||||
- Publizierte State-Messages
|
||||
|
||||
Log-Level: INFO
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Simulator startet nicht
|
||||
|
||||
```bash
|
||||
# Prüfe ob Port bereits belegt
|
||||
lsof -ti:1883
|
||||
|
||||
# Prüfe MQTT Broker
|
||||
mosquitto_sub -h 172.16.2.16 -t '#' -C 1
|
||||
```
|
||||
|
||||
### Keine State-Updates
|
||||
|
||||
```bash
|
||||
# Prüfe Simulator-Log
|
||||
tail -f /tmp/simulator.log
|
||||
|
||||
# Prüfe MQTT Topics
|
||||
mosquitto_sub -h 172.16.2.16 -t 'vendor/#' -v
|
||||
```
|
||||
|
||||
### API antwortet nicht
|
||||
|
||||
```bash
|
||||
# Prüfe ob API läuft
|
||||
curl http://localhost:8001/devices
|
||||
|
||||
# Prüfe API-Log
|
||||
tail -f /tmp/api.log
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
Der Simulator integriert sich nahtlos in das Home Automation System:
|
||||
|
||||
1. **Abstraction Layer** empfängt Commands und sendet sie an Simulator
|
||||
2. **Simulator** reagiert und publiziert neuen State
|
||||
3. **Abstraction Layer** empfängt State und publiziert zu Redis
|
||||
4. **UI** empfängt Updates via SSE und aktualisiert Dashboard
|
||||
|
||||
Alle Komponenten arbeiten vollständig asynchron über MQTT.
|
||||
291
tools/device_simulator.py
Executable file
291
tools/device_simulator.py
Executable file
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unified Device Simulator for Home Automation.
|
||||
|
||||
Simulates multiple device types:
|
||||
- Lights (test_lampe_1, test_lampe_2, test_lampe_3)
|
||||
- Thermostats (test_thermo_1)
|
||||
|
||||
Each device:
|
||||
- Subscribes to vendor/{device_id}/set
|
||||
- Maintains local state
|
||||
- Publishes state changes to vendor/{device_id}/state (retained, QoS 1)
|
||||
- Thermostats simulate temperature drift every 5 seconds
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
|
||||
from aiomqtt import Client, MqttError
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration
|
||||
BROKER_HOST = os.getenv("MQTT_BROKER", "172.16.2.16")
|
||||
BROKER_PORT = int(os.getenv("MQTT_PORT", "1883"))
|
||||
DRIFT_INTERVAL = 5 # seconds for thermostat temperature drift
|
||||
|
||||
# Device configurations
|
||||
LIGHT_DEVICES = ["test_lampe_1", "test_lampe_2", "test_lampe_3"]
|
||||
THERMOSTAT_DEVICES = ["test_thermo_1"]
|
||||
|
||||
|
||||
class DeviceSimulator:
|
||||
"""Unified simulator for lights and thermostats."""
|
||||
|
||||
def __init__(self):
|
||||
# Light states
|
||||
self.light_states: Dict[str, Dict[str, Any]] = {
|
||||
"test_lampe_1": {"power": "off", "brightness": 50},
|
||||
"test_lampe_2": {"power": "off", "brightness": 50},
|
||||
"test_lampe_3": {"power": "off", "brightness": 50}
|
||||
}
|
||||
|
||||
# Thermostat states
|
||||
self.thermostat_states: Dict[str, Dict[str, Any]] = {
|
||||
"test_thermo_1": {
|
||||
"mode": "auto",
|
||||
"target": 21.0,
|
||||
"current": 20.5,
|
||||
"battery": 90,
|
||||
"window_open": False
|
||||
}
|
||||
}
|
||||
|
||||
self.client = None
|
||||
self.running = True
|
||||
self.drift_task = None
|
||||
|
||||
async def publish_state(self, device_id: str, device_type: str):
|
||||
"""Publish device state to MQTT (retained, QoS 1)."""
|
||||
if not self.client:
|
||||
return
|
||||
|
||||
if device_type == "light":
|
||||
state = self.light_states.get(device_id)
|
||||
elif device_type == "thermostat":
|
||||
state = self.thermostat_states.get(device_id)
|
||||
else:
|
||||
logger.warning(f"Unknown device type: {device_type}")
|
||||
return
|
||||
|
||||
if not state:
|
||||
logger.warning(f"Unknown device: {device_id}")
|
||||
return
|
||||
|
||||
state_topic = f"vendor/{device_id}/state"
|
||||
payload = json.dumps(state)
|
||||
|
||||
await self.client.publish(
|
||||
state_topic,
|
||||
payload=payload,
|
||||
qos=1,
|
||||
retain=True
|
||||
)
|
||||
logger.info(f"[{device_id}] Published state: {payload}")
|
||||
|
||||
async def handle_light_set(self, device_id: str, payload: dict):
|
||||
"""Handle SET command for light device."""
|
||||
if device_id not in self.light_states:
|
||||
logger.warning(f"Unknown light device: {device_id}")
|
||||
return
|
||||
|
||||
state = self.light_states[device_id]
|
||||
updated = False
|
||||
|
||||
if "power" in payload:
|
||||
old_power = state["power"]
|
||||
state["power"] = payload["power"]
|
||||
if old_power != state["power"]:
|
||||
updated = True
|
||||
logger.info(f"[{device_id}] Power: {old_power} -> {state['power']}")
|
||||
|
||||
if "brightness" in payload:
|
||||
old_brightness = state["brightness"]
|
||||
state["brightness"] = int(payload["brightness"])
|
||||
if old_brightness != state["brightness"]:
|
||||
updated = True
|
||||
logger.info(f"[{device_id}] Brightness: {old_brightness} -> {state['brightness']}")
|
||||
|
||||
if updated:
|
||||
await self.publish_state(device_id, "light")
|
||||
|
||||
async def handle_thermostat_set(self, device_id: str, payload: dict):
|
||||
"""Handle SET command for thermostat device."""
|
||||
if device_id not in self.thermostat_states:
|
||||
logger.warning(f"Unknown thermostat device: {device_id}")
|
||||
return
|
||||
|
||||
state = self.thermostat_states[device_id]
|
||||
updated = False
|
||||
|
||||
if "mode" in payload:
|
||||
new_mode = payload["mode"]
|
||||
if new_mode in ["off", "heat", "auto"]:
|
||||
old_mode = state["mode"]
|
||||
state["mode"] = new_mode
|
||||
if old_mode != new_mode:
|
||||
updated = True
|
||||
logger.info(f"[{device_id}] Mode: {old_mode} -> {new_mode}")
|
||||
else:
|
||||
logger.warning(f"[{device_id}] Invalid mode: {new_mode}")
|
||||
|
||||
if "target" in payload:
|
||||
try:
|
||||
new_target = float(payload["target"])
|
||||
if 5.0 <= new_target <= 30.0:
|
||||
old_target = state["target"]
|
||||
state["target"] = new_target
|
||||
if old_target != new_target:
|
||||
updated = True
|
||||
logger.info(f"[{device_id}] Target: {old_target}°C -> {new_target}°C")
|
||||
else:
|
||||
logger.warning(f"[{device_id}] Target out of range: {new_target}")
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(f"[{device_id}] Invalid target value: {payload['target']}")
|
||||
|
||||
if updated:
|
||||
await self.publish_state(device_id, "thermostat")
|
||||
|
||||
def apply_temperature_drift(self, device_id: str):
|
||||
"""
|
||||
Simulate temperature drift for thermostat.
|
||||
Max change: ±0.2°C per interval.
|
||||
"""
|
||||
if device_id not in self.thermostat_states:
|
||||
return
|
||||
|
||||
state = self.thermostat_states[device_id]
|
||||
|
||||
if state["mode"] == "off":
|
||||
# Drift towards ambient (18°C)
|
||||
ambient = 18.0
|
||||
diff = ambient - state["current"]
|
||||
else:
|
||||
# Drift towards target
|
||||
diff = state["target"] - state["current"]
|
||||
|
||||
# Apply max ±0.2°C drift
|
||||
if abs(diff) < 0.1:
|
||||
state["current"] = round(state["current"] + diff, 1)
|
||||
elif diff > 0:
|
||||
state["current"] = round(state["current"] + 0.2, 1)
|
||||
else:
|
||||
state["current"] = round(state["current"] - 0.2, 1)
|
||||
|
||||
logger.info(f"[{device_id}] Temperature drift: current={state['current']}°C (target={state['target']}°C, mode={state['mode']})")
|
||||
|
||||
async def thermostat_drift_loop(self):
|
||||
"""Background loop for thermostat temperature drift."""
|
||||
while self.running:
|
||||
await asyncio.sleep(DRIFT_INTERVAL)
|
||||
|
||||
for device_id in THERMOSTAT_DEVICES:
|
||||
self.apply_temperature_drift(device_id)
|
||||
await self.publish_state(device_id, "thermostat")
|
||||
|
||||
async def handle_message(self, message):
|
||||
"""Handle incoming MQTT message."""
|
||||
try:
|
||||
# Extract device_id from topic (vendor/{device_id}/set)
|
||||
topic_parts = message.topic.value.split('/')
|
||||
if len(topic_parts) != 3 or topic_parts[0] != "vendor" or topic_parts[2] != "set":
|
||||
logger.warning(f"Unexpected topic format: {message.topic}")
|
||||
return
|
||||
|
||||
device_id = topic_parts[1]
|
||||
payload = json.loads(message.payload.decode())
|
||||
|
||||
logger.info(f"[{device_id}] Received SET: {payload}")
|
||||
|
||||
# Determine device type and handle accordingly
|
||||
if device_id in self.light_states:
|
||||
await self.handle_light_set(device_id, payload)
|
||||
elif device_id in self.thermostat_states:
|
||||
await self.handle_thermostat_set(device_id, payload)
|
||||
else:
|
||||
logger.warning(f"Unknown device: {device_id}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Invalid JSON: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling message: {e}")
|
||||
|
||||
async def run(self):
|
||||
"""Main simulator loop."""
|
||||
# Generate unique client ID to avoid collisions
|
||||
base_client_id = "device_simulator"
|
||||
client_suffix = os.environ.get("MQTT_CLIENT_ID_SUFFIX") or uuid.uuid4().hex[:6]
|
||||
unique_client_id = f"{base_client_id}-{client_suffix}"
|
||||
|
||||
try:
|
||||
async with Client(
|
||||
hostname=BROKER_HOST,
|
||||
port=BROKER_PORT,
|
||||
identifier=unique_client_id
|
||||
) as client:
|
||||
self.client = client
|
||||
logger.info(f"✅ Connected to MQTT broker {BROKER_HOST}:{BROKER_PORT}")
|
||||
|
||||
# Publish initial states
|
||||
for device_id in LIGHT_DEVICES:
|
||||
await self.publish_state(device_id, "light")
|
||||
logger.info(f"💡 Light simulator started: {device_id}")
|
||||
|
||||
for device_id in THERMOSTAT_DEVICES:
|
||||
await self.publish_state(device_id, "thermostat")
|
||||
logger.info(f"🌡️ Thermostat simulator started: {device_id}")
|
||||
|
||||
# Subscribe to all SET topics
|
||||
all_devices = LIGHT_DEVICES + THERMOSTAT_DEVICES
|
||||
for device_id in all_devices:
|
||||
set_topic = f"vendor/{device_id}/set"
|
||||
await client.subscribe(set_topic, qos=1)
|
||||
logger.info(f"👂 Subscribed to {set_topic}")
|
||||
|
||||
# Start thermostat drift loop
|
||||
self.drift_task = asyncio.create_task(self.thermostat_drift_loop())
|
||||
|
||||
# Listen for messages
|
||||
async for message in client.messages:
|
||||
await self.handle_message(message)
|
||||
|
||||
# Cancel drift loop on disconnect
|
||||
if self.drift_task:
|
||||
self.drift_task.cancel()
|
||||
|
||||
except MqttError as e:
|
||||
logger.error(f"❌ MQTT Error: {e}")
|
||||
except KeyboardInterrupt:
|
||||
logger.info("⚠️ Interrupted by user")
|
||||
finally:
|
||||
self.running = False
|
||||
if self.drift_task:
|
||||
self.drift_task.cancel()
|
||||
logger.info("👋 Simulator stopped")
|
||||
|
||||
|
||||
async def main():
|
||||
"""Entry point."""
|
||||
simulator = DeviceSimulator()
|
||||
await simulator.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\n👋 Simulator terminated")
|
||||
sys.exit(0)
|
||||
@@ -1,215 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MQTT Simulator for multiple test_lampe devices.
|
||||
|
||||
This simulator acts as virtual light devices that:
|
||||
- Subscribe to vendor/test_lampe_*/set
|
||||
- Maintain local state for each device
|
||||
- Publish state changes to vendor/test_lampe_*/state (retained)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration
|
||||
BROKER_HOST = os.environ.get("MQTT_HOST", "172.16.2.16")
|
||||
BROKER_PORT = int(os.environ.get("MQTT_PORT", "1883"))
|
||||
|
||||
# Devices to simulate
|
||||
DEVICES = ["test_lampe_1", "test_lampe_2", "test_lampe_3"]
|
||||
|
||||
# Device states (one per device)
|
||||
device_states = {
|
||||
"test_lampe_1": {
|
||||
"power": "off",
|
||||
"brightness": 50
|
||||
},
|
||||
"test_lampe_2": {
|
||||
"power": "off",
|
||||
"brightness": 50
|
||||
},
|
||||
"test_lampe_3": {
|
||||
"power": "off",
|
||||
"brightness": 50
|
||||
}
|
||||
}
|
||||
|
||||
# Global client for signal handler
|
||||
client_global = None
|
||||
|
||||
|
||||
def on_connect(client, userdata, flags, rc, properties=None):
|
||||
"""Callback when connected to MQTT broker.
|
||||
|
||||
Args:
|
||||
client: MQTT client instance
|
||||
userdata: User data
|
||||
flags: Connection flags
|
||||
rc: Connection result code
|
||||
properties: Connection properties (MQTT v5)
|
||||
"""
|
||||
if rc == 0:
|
||||
logger.info(f"Connected to MQTT broker {BROKER_HOST}:{BROKER_PORT}")
|
||||
|
||||
# Subscribe to SET topics for all devices
|
||||
for device_id in DEVICES:
|
||||
set_topic = f"vendor/{device_id}/set"
|
||||
client.subscribe(set_topic, qos=1)
|
||||
logger.info(f"Subscribed to {set_topic}")
|
||||
|
||||
# Publish initial states (retained)
|
||||
for device_id in DEVICES:
|
||||
publish_state(client, device_id)
|
||||
logger.info(f"Simulator started for {device_id}, initial state: {device_states[device_id]}")
|
||||
else:
|
||||
logger.error(f"Connection failed with code {rc}")
|
||||
|
||||
|
||||
def on_message(client, userdata, msg):
|
||||
"""Callback when message received on subscribed topic.
|
||||
|
||||
Args:
|
||||
client: MQTT client instance
|
||||
userdata: User data
|
||||
msg: MQTT message
|
||||
"""
|
||||
# Extract device_id from topic (vendor/test_lampe_X/set)
|
||||
topic_parts = msg.topic.split('/')
|
||||
if len(topic_parts) != 3 or topic_parts[0] != "vendor" or topic_parts[2] != "set":
|
||||
logger.warning(f"Unexpected topic format: {msg.topic}")
|
||||
return
|
||||
|
||||
device_id = topic_parts[1]
|
||||
|
||||
if device_id not in device_states:
|
||||
logger.warning(f"Unknown device: {device_id}")
|
||||
return
|
||||
|
||||
try:
|
||||
payload = json.loads(msg.payload.decode())
|
||||
logger.info(f"[{device_id}] Received SET command: {payload}")
|
||||
|
||||
# Update device state
|
||||
updated = False
|
||||
device_state = device_states[device_id]
|
||||
|
||||
if "power" in payload:
|
||||
old_power = device_state["power"]
|
||||
device_state["power"] = payload["power"]
|
||||
if old_power != device_state["power"]:
|
||||
updated = True
|
||||
logger.info(f"[{device_id}] Power changed: {old_power} -> {device_state['power']}")
|
||||
|
||||
if "brightness" in payload:
|
||||
old_brightness = device_state["brightness"]
|
||||
device_state["brightness"] = int(payload["brightness"])
|
||||
if old_brightness != device_state["brightness"]:
|
||||
updated = True
|
||||
logger.info(f"[{device_id}] Brightness changed: {old_brightness} -> {device_state['brightness']}")
|
||||
|
||||
# Publish updated state if changed
|
||||
if updated:
|
||||
publish_state(client, device_id)
|
||||
logger.info(f"[{device_id}] Published new state: {device_state}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"[{device_id}] Invalid JSON in message: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"[{device_id}] Error processing message: {e}")
|
||||
|
||||
|
||||
def publish_state(client, device_id):
|
||||
"""Publish current device state to STATE topic.
|
||||
|
||||
Args:
|
||||
client: MQTT client instance
|
||||
device_id: Device identifier
|
||||
"""
|
||||
device_state = device_states[device_id]
|
||||
state_topic = f"vendor/{device_id}/state"
|
||||
state_json = json.dumps(device_state)
|
||||
result = client.publish(state_topic, state_json, qos=1, retain=True)
|
||||
|
||||
if result.rc == mqtt.MQTT_ERR_SUCCESS:
|
||||
logger.debug(f"[{device_id}] Published state to {state_topic}: {state_json}")
|
||||
else:
|
||||
logger.error(f"[{device_id}] Failed to publish state: {result.rc}")
|
||||
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
"""Handle shutdown signals gracefully.
|
||||
|
||||
Args:
|
||||
sig: Signal number
|
||||
frame: Current stack frame
|
||||
"""
|
||||
logger.info(f"Received signal {sig}, shutting down...")
|
||||
|
||||
if client_global:
|
||||
# Publish offline state for all devices before disconnecting
|
||||
for device_id in DEVICES:
|
||||
offline_state = device_states[device_id].copy()
|
||||
offline_state["power"] = "off"
|
||||
state_topic = f"vendor/{device_id}/state"
|
||||
client_global.publish(state_topic, json.dumps(offline_state), qos=1, retain=True)
|
||||
logger.info(f"[{device_id}] Published offline state")
|
||||
|
||||
client_global.disconnect()
|
||||
client_global.loop_stop()
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the simulator."""
|
||||
global client_global
|
||||
|
||||
# Setup signal handlers
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
# Create MQTT client
|
||||
client = mqtt.Client(
|
||||
client_id="simulator-test-lampes",
|
||||
protocol=mqtt.MQTTv5,
|
||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2
|
||||
)
|
||||
client_global = client
|
||||
|
||||
# Set callbacks
|
||||
client.on_connect = on_connect
|
||||
client.on_message = on_message
|
||||
|
||||
# Connect to broker
|
||||
logger.info(f"Connecting to MQTT broker {BROKER_HOST}:{BROKER_PORT}...")
|
||||
|
||||
try:
|
||||
client.connect(BROKER_HOST, BROKER_PORT, keepalive=60)
|
||||
|
||||
# Start network loop
|
||||
client.loop_forever()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Interrupted by user")
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
finally:
|
||||
if client.is_connected():
|
||||
client.disconnect()
|
||||
client.loop_stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
154
tools/test_device_simulator.sh
Executable file
154
tools/test_device_simulator.sh
Executable file
@@ -0,0 +1,154 @@
|
||||
#!/bin/bash
|
||||
# Test script for device_simulator.py
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
echo "=== Device Simulator Test Suite ==="
|
||||
echo ""
|
||||
|
||||
# 1. Stop all running services
|
||||
echo "1. Stoppe alle laufenden Services..."
|
||||
pkill -f "device_simulator" 2>/dev/null || true
|
||||
pkill -f "uvicorn apps" 2>/dev/null || true
|
||||
pkill -f "apps.abstraction" 2>/dev/null || true
|
||||
sleep 2
|
||||
echo " ✓ Services gestoppt"
|
||||
echo ""
|
||||
|
||||
# 2. Start services
|
||||
echo "2. Starte Services..."
|
||||
poetry run python -m apps.abstraction.main > /tmp/abstraction.log 2>&1 &
|
||||
ABSTRACTION_PID=$!
|
||||
echo " Abstraction Layer gestartet (PID: $ABSTRACTION_PID)"
|
||||
sleep 2
|
||||
|
||||
poetry run uvicorn apps.api.main:app --host 0.0.0.0 --port 8001 > /tmp/api.log 2>&1 &
|
||||
API_PID=$!
|
||||
echo " API Server gestartet (PID: $API_PID)"
|
||||
sleep 2
|
||||
|
||||
poetry run python tools/device_simulator.py > /tmp/simulator.log 2>&1 &
|
||||
SIM_PID=$!
|
||||
echo " Device Simulator gestartet (PID: $SIM_PID)"
|
||||
sleep 2
|
||||
|
||||
echo " ✓ Alle Services laufen"
|
||||
echo ""
|
||||
|
||||
# 3. Test API reachability
|
||||
echo "3. Teste API Erreichbarkeit..."
|
||||
if timeout 3 curl -s http://localhost:8001/devices > /dev/null; then
|
||||
echo " ✓ API antwortet"
|
||||
else
|
||||
echo " ✗ API antwortet nicht!"
|
||||
echo " API Log:"
|
||||
tail -10 /tmp/api.log
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 4. Test Light Operations
|
||||
echo "4. Teste Lampen-Operationen..."
|
||||
|
||||
# 4.1 Power On
|
||||
echo " 4.1 Lampe einschalten (test_lampe_1)..."
|
||||
RESPONSE=$(timeout 3 curl -s -X POST http://localhost:8001/devices/test_lampe_1/set \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"light","payload":{"power":"on"}}')
|
||||
echo " Response: $RESPONSE"
|
||||
sleep 1
|
||||
|
||||
# 4.2 Check state via MQTT
|
||||
echo " 4.2 Prüfe State via MQTT..."
|
||||
STATE=$(timeout 2 mosquitto_sub -h 172.16.2.16 -t 'vendor/test_lampe_1/state' -C 1)
|
||||
echo " State: $STATE"
|
||||
if echo "$STATE" | grep -q '"power": "on"'; then
|
||||
echo " ✓ Power ist ON"
|
||||
else
|
||||
echo " ✗ Power nicht ON!"
|
||||
fi
|
||||
|
||||
# 4.3 Brightness
|
||||
echo " 4.3 Helligkeit setzen (75%)..."
|
||||
RESPONSE=$(timeout 3 curl -s -X POST http://localhost:8001/devices/test_lampe_1/set \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"light","payload":{"brightness":75}}')
|
||||
echo " Response: $RESPONSE"
|
||||
sleep 1
|
||||
|
||||
STATE=$(timeout 2 mosquitto_sub -h 172.16.2.16 -t 'vendor/test_lampe_1/state' -C 1)
|
||||
echo " State: $STATE"
|
||||
if echo "$STATE" | grep -q '"brightness": 75'; then
|
||||
echo " ✓ Brightness ist 75"
|
||||
else
|
||||
echo " ✗ Brightness nicht 75!"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 5. Test Thermostat Operations
|
||||
echo "5. Teste Thermostat-Operationen..."
|
||||
|
||||
# 5.1 Set mode and target
|
||||
echo " 5.1 Setze Mode HEAT und Target 22.5°C..."
|
||||
RESPONSE=$(timeout 3 curl -s -X POST http://localhost:8001/devices/test_thermo_1/set \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"thermostat","payload":{"mode":"heat","target":22.5}}')
|
||||
echo " Response: $RESPONSE"
|
||||
sleep 1
|
||||
|
||||
STATE=$(timeout 2 mosquitto_sub -h 172.16.2.16 -t 'vendor/test_thermo_1/state' -C 1)
|
||||
echo " State: $STATE"
|
||||
if echo "$STATE" | grep -q '"mode": "heat"' && echo "$STATE" | grep -q '"target": 22.5'; then
|
||||
echo " ✓ Mode ist HEAT, Target ist 22.5"
|
||||
else
|
||||
echo " ✗ Mode oder Target nicht korrekt!"
|
||||
fi
|
||||
|
||||
# 5.2 Wait for temperature drift
|
||||
echo " 5.2 Warte 6 Sekunden auf Temperature Drift..."
|
||||
sleep 6
|
||||
|
||||
STATE=$(timeout 2 mosquitto_sub -h 172.16.2.16 -t 'vendor/test_thermo_1/state' -C 1)
|
||||
echo " State: $STATE"
|
||||
CURRENT=$(echo "$STATE" | grep -o '"current": [0-9.]*' | grep -o '[0-9.]*$')
|
||||
echo " Current Temperature: ${CURRENT}°C"
|
||||
if [ -n "$CURRENT" ]; then
|
||||
echo " ✓ Temperature drift funktioniert"
|
||||
else
|
||||
echo " ✗ Temperature drift nicht sichtbar!"
|
||||
fi
|
||||
|
||||
# 5.3 Set mode OFF
|
||||
echo " 5.3 Setze Mode OFF..."
|
||||
RESPONSE=$(timeout 3 curl -s -X POST http://localhost:8001/devices/test_thermo_1/set \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"thermostat","payload":{"mode":"off","target":22.5}}')
|
||||
echo " Response: $RESPONSE"
|
||||
sleep 1
|
||||
|
||||
STATE=$(timeout 2 mosquitto_sub -h 172.16.2.16 -t 'vendor/test_thermo_1/state' -C 1)
|
||||
if echo "$STATE" | grep -q '"mode": "off"'; then
|
||||
echo " ✓ Mode ist OFF"
|
||||
else
|
||||
echo " ✗ Mode nicht OFF!"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 6. Check simulator log
|
||||
echo "6. Simulator Log (letzte 20 Zeilen)..."
|
||||
tail -20 /tmp/simulator.log
|
||||
echo ""
|
||||
|
||||
# 7. Summary
|
||||
echo "=== Test Summary ==="
|
||||
echo "✓ Alle Tests abgeschlossen"
|
||||
echo ""
|
||||
echo "Laufende Prozesse:"
|
||||
echo " Abstraction: PID $ABSTRACTION_PID"
|
||||
echo " API: PID $API_PID"
|
||||
echo " Simulator: PID $SIM_PID"
|
||||
echo ""
|
||||
echo "Logs verfügbar in:"
|
||||
echo " /tmp/abstraction.log"
|
||||
echo " /tmp/api.log"
|
||||
echo " /tmp/simulator.log"
|
||||
Reference in New Issue
Block a user