Compare commits
259 Commits
homekit_in
...
add_homeki
| Author | SHA1 | Date | |
|---|---|---|---|
|
a48d189f85
|
|||
|
40c3faa128
|
|||
|
5cca44638c
|
|||
|
fb2eef2a42
|
|||
|
0a2007ee65
|
|||
|
bdb25e3550
|
|||
|
6c284fa1f6
|
|||
|
5346d1b72c
|
|||
|
d8780b1790
|
|||
|
3d5010b4a1
|
|||
|
b471ab5edc
|
|||
|
3e0a1b49ab
|
|||
|
befdc8a46c
|
|||
|
da16c59238
|
|||
|
5f3185894d
|
|||
|
fb828c9a2c
|
|||
|
064ee6bbed
|
|||
|
d39bcfce26
|
|||
|
1fd275186a
|
|||
|
da370c9050
|
|||
|
08294ca294
|
|||
|
e5eb368dca
|
|||
|
169d0505cb
|
|||
|
02a2be92d5
|
|||
|
bcfc967460
|
|||
|
bd1f3bc8c9
|
|||
|
f9df70cf68
|
|||
|
5364b855aa
|
|||
|
3a1841a8a9
|
|||
|
9629850ebb
|
|||
|
000d32b78f
|
|||
|
24b2f70caf
|
|||
|
d3c1ec404a
|
|||
|
9ba478c34d
|
|||
|
15e132b187
|
|||
|
f40887ec37
|
|||
|
507f6f3854
|
|||
|
f163bb09bf
|
|||
|
54fdcc12e1
|
|||
|
9f725c4c70
|
|||
|
f1dbd9344d
|
|||
|
5a67d7b330
|
|||
|
cc342245f8
|
|||
|
50253d536d
|
|||
|
e0aa50c9d2
|
|||
|
dc20d9f4b2
|
|||
|
ffb35928b4
|
|||
|
ac84ff7103
|
|||
|
c185494da3
|
|||
|
ec4a37a268
|
|||
|
d4b1d27b81
|
|||
|
ad07bc79e2
|
|||
|
ab41e79cb2
|
|||
|
fe92d336b1
|
|||
|
0ca59896ad
|
|||
|
7858996d0f
|
|||
|
a0f7cc7bd9
|
|||
|
a98802437c
|
|||
|
708e287016
|
|||
|
d11eab8474
|
|||
|
eccffbbd55
|
|||
|
2b963a33ef
|
|||
|
1311f7a59b
|
|||
|
a226fa9268
|
|||
|
3bd8d293a2
|
|||
|
be30ad3a3c
|
|||
|
500384b1cd
|
|||
|
6b4c247413
|
|||
|
04a1807306
|
|||
|
db5e4589d0
|
|||
|
5399f044a1
|
|||
|
16fa5143dd
|
|||
|
cff154c247
|
|||
|
038664ec94
|
|||
|
2bbf825cf7
|
|||
|
5e0159047c
|
|||
|
b23b624a86
|
|||
|
9c099e44af
|
|||
|
9c17a73605
|
|||
|
a389edcd87
|
|||
|
17c9bca8d1
|
|||
|
c4fc21d760
|
|||
|
e902d221ea
|
|||
|
e19bffc90c
|
|||
|
5a13183123
|
|||
|
deb26c4945
|
|||
|
c0e3ac1fe0
|
|||
|
370c16eb42
|
|||
|
fd1d5c4f31
|
|||
|
51072424ed
|
|||
|
722f4f0a8c
|
|||
|
0acabc737e
|
|||
|
34b0cdef69
|
|||
|
68ca51a242
|
|||
|
6d0f38965d
|
|||
|
1078e4cd53
|
|||
|
0c2f3f2e83
|
|||
|
418f813e80
|
|||
|
2b2fd92923
|
|||
|
8fa81be750
|
|||
|
205baa7e01
|
|||
|
f3f9238d5f
|
|||
|
5decf79bee
|
|||
|
be2654ac98
|
|||
|
bb27296310
|
|||
|
63857671f9
|
|||
|
d008c9fd5a
|
|||
|
1eb0f84659
|
|||
|
51df63d9f2
|
|||
|
cdaa5deb58
|
|||
|
91ef285a6c
|
|||
|
9afa68a111
|
|||
|
1119bb529f
|
|||
|
26286ce194
|
|||
|
7913a0044d
|
|||
|
871d0dc890
|
|||
|
7409995780
|
|||
|
9d4f3ac560
|
|||
|
bbbd01fbac
|
|||
|
61134f8bfa
|
|||
|
b12bbc1eb0
|
|||
|
8425dda177
|
|||
|
eddcd20d19
|
|||
|
28bbff16aa
|
|||
|
02fe11754c
|
|||
|
59b2c566ad
|
|||
|
42d7aae10c
|
|||
|
83ab36884b
|
|||
|
4d6e1a9ffe
|
|||
|
1ad7df5c73
|
|||
|
927d13191d
|
|||
|
0a0edd2b5b
|
|||
|
5ddf9bbc53
|
|||
|
5a8fa5ff46
|
|||
|
d7d06718ec
|
|||
|
a92ee40224
|
|||
|
8226fb5aca
|
|||
|
426f63124b
|
|||
|
9010e9587f
|
|||
|
69b2742f2a
|
|||
|
e409e5fdd1
|
|||
|
5c97bb3c1e
|
|||
|
b4e0fc8ddd
|
|||
|
86409b26f0
|
|||
|
d9139e2693
|
|||
|
740ac6c9ad
|
|||
|
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
|
4
.gitignore
vendored
@@ -64,3 +64,7 @@ poetry.lock
|
|||||||
|
|
||||||
apps/homekit/homekit.state
|
apps/homekit/homekit.state
|
||||||
|
|
||||||
|
tools/ca/
|
||||||
|
tools/clients/
|
||||||
|
tools/certificates/
|
||||||
|
tools/certificates.tgz
|
||||||
|
|||||||
29
.woodpecker/build.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
when:
|
||||||
|
event: [tag]
|
||||||
|
ref:
|
||||||
|
exclude:
|
||||||
|
- refs/tags/*-configchange
|
||||||
|
|
||||||
|
matrix:
|
||||||
|
APP:
|
||||||
|
- ui
|
||||||
|
- api
|
||||||
|
- abstraction
|
||||||
|
- rules
|
||||||
|
- static
|
||||||
|
- pulsegen
|
||||||
|
- homekit
|
||||||
|
|
||||||
|
steps:
|
||||||
|
build-${APP}:
|
||||||
|
image: plugins/kaniko
|
||||||
|
settings:
|
||||||
|
registry:
|
||||||
|
from_secret: local_registry
|
||||||
|
username:
|
||||||
|
from_secret: local_username
|
||||||
|
password:
|
||||||
|
from_secret: local_password
|
||||||
|
repo: ${FORGE_NAME}/${CI_REPO}/${APP}
|
||||||
|
auto_tag: true
|
||||||
|
dockerfile: apps/${APP}/Dockerfile
|
||||||
26
.woodpecker/config.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
when:
|
||||||
|
event: [tag]
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
- namespace
|
||||||
|
|
||||||
|
steps:
|
||||||
|
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.yaml=config/devices.yaml
|
||||||
|
--from-file=groups.yaml=config/groups.yaml
|
||||||
|
--from-file=layout.yaml=config/layout.yaml
|
||||||
|
--from-file=rules.yaml=config/rules.yaml
|
||||||
|
--from-file=scenes.yaml=config/scenes.yaml
|
||||||
|
--namespace=$NAMESPACE
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
- kubectl apply -f deployment/configmap.yaml -n $NAMESPACE
|
||||||
|
|
||||||
35
.woodpecker/deploy.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
when:
|
||||||
|
event: [tag]
|
||||||
|
ref:
|
||||||
|
exclude:
|
||||||
|
- refs/tags/*-configchange
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
- build
|
||||||
|
- namespace
|
||||||
|
- config
|
||||||
|
|
||||||
|
matrix:
|
||||||
|
APP:
|
||||||
|
- ui
|
||||||
|
- api
|
||||||
|
- abstraction
|
||||||
|
- rules
|
||||||
|
- static
|
||||||
|
- pulsegen
|
||||||
|
|
||||||
|
steps:
|
||||||
|
deploy-${APP}:
|
||||||
|
image: quay.io/wollud1969/k8s-admin-helper:0.3.4
|
||||||
|
environment:
|
||||||
|
KUBE_CONFIG_CONTENT:
|
||||||
|
from_secret: kube_config
|
||||||
|
NAMESPACE: "homea2"
|
||||||
|
IMAGE: "${FORGE_NAME}/${CI_REPO}/${APP}:${CI_COMMIT_TAG}"
|
||||||
|
commands:
|
||||||
|
- printf "$KUBE_CONFIG_CONTENT" > /tmp/kubeconfig
|
||||||
|
- export KUBECONFIG=/tmp/kubeconfig
|
||||||
|
- echo "Deploying application ${APP} ($IMAGE) to namespace $NAMESPACE"
|
||||||
|
- cat deployment/${APP}-deployment.yaml | sed "s,%IMAGE%,$IMAGE,g" | kubectl apply -n $NAMESPACE -f -
|
||||||
|
|
||||||
|
|
||||||
21
.woodpecker/ingress.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
when:
|
||||||
|
event: [tag]
|
||||||
|
ref:
|
||||||
|
exclude:
|
||||||
|
- refs/tags/*-configchange
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
steps:
|
||||||
|
apply_ingress:
|
||||||
|
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 apply -f deployment/ingress.yaml -n $NAMESPACE
|
||||||
|
|
||||||
15
.woodpecker/namespace.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
when:
|
||||||
|
event: [tag]
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
Schlafzimmer:
|
|
||||||
- Bettlicht Patty | 0x0017880108158b32
|
|
||||||
- Bettlicht Wolfgang | 0x00178801081570bf
|
|
||||||
- Deckenlampe Schlafzimmer | 0x0017880108a406a7
|
|
||||||
- Medusa-Lampe Schlafzimmer | 0xf0d1b80000154c7c
|
|
||||||
|
|
||||||
Esszimmer:
|
|
||||||
- Deckenlampe Esszimmer | 0x0017880108a03e45
|
|
||||||
- Leselampe Esszimmer | 0xec1bbdfffe7b84f2
|
|
||||||
- Standlampe Esszimmer | 0xbc33acfffe21f547
|
|
||||||
- kleine Lampe links Esszimmer | 0xf0d1b80000153099
|
|
||||||
- kleine Lampe rechts Esszimmer | 0xf0d1b80000156645
|
|
||||||
|
|
||||||
Wohnzimmer:
|
|
||||||
- Lampe Naehtischchen Wohnzimmer | 0x842e14fffee560ee
|
|
||||||
- Lampe Semeniere Wohnzimmer | 0xf0d1b8000015480b
|
|
||||||
- Sterne Wohnzimmer | 0xf0d1b80000155fc2
|
|
||||||
- grosse Lampe Wohnzimmer | 0xf0d1b80000151aca
|
|
||||||
|
|
||||||
Küche:
|
|
||||||
- Küche Deckenlampe | 0x001788010d2c40c4
|
|
||||||
- Kueche | 0x94deb8fffe2e5c06
|
|
||||||
|
|
||||||
Arbeitszimmer Patty:
|
|
||||||
- Leselampe Patty | 0x001788010600ec9d
|
|
||||||
- Schranklicht hinten Patty | 0x0017880106e29571
|
|
||||||
- Schranklicht vorne Patty | 0xf0d1b80000154cf5
|
|
||||||
|
|
||||||
Arbeitszimmer Wolfgang:
|
|
||||||
- Wolfgang | 0x540f57fffe7e3cfe
|
|
||||||
- ExperimentLabTest | 0xf0d1b80000195038
|
|
||||||
|
|
||||||
Flur:
|
|
||||||
- Deckenlampe Flur oben | 0x001788010d2123a7
|
|
||||||
- Haustür | 0xec1bbdfffea6a3da
|
|
||||||
- Licht Flur oben am Spiegel | 0x842e14fffefe4ba4
|
|
||||||
|
|
||||||
Sportzimmer:
|
|
||||||
- Sportlicht Regal | 0xf0d1b8be2409f569
|
|
||||||
- Sportlicht Tisch | 0xf0d1b8be2409f31b
|
|
||||||
- Sportlicht am Fernseher, Studierzimmer | 0x842e14fffe76a23a
|
|
||||||
230
DOCKER_GUIDE.md
@@ -1,230 +0,0 @@
|
|||||||
# Docker Guide für Home Automation
|
|
||||||
|
|
||||||
Vollständige Anleitung zum Ausführen aller Services mit Docker/finch.
|
|
||||||
|
|
||||||
## Quick Start - Alle Services starten
|
|
||||||
|
|
||||||
### Linux Server (empfohlen - mit Docker Network)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Images bauen
|
|
||||||
docker build -t api:dev -f apps/api/Dockerfile .
|
|
||||||
docker build -t ui:dev -f apps/ui/Dockerfile .
|
|
||||||
docker build -t abstraction:dev -f apps/abstraction/Dockerfile .
|
|
||||||
docker build -t simulator:dev -f apps/simulator/Dockerfile .
|
|
||||||
|
|
||||||
# 2. Netzwerk erstellen
|
|
||||||
docker network create home-automation
|
|
||||||
|
|
||||||
# 3. Abstraction Layer (MQTT Worker)
|
|
||||||
docker run -d --name abstraction \
|
|
||||||
--network home-automation \
|
|
||||||
-v $(pwd)/config:/app/config:ro \
|
|
||||||
-e MQTT_BROKER=172.16.2.16 \
|
|
||||||
-e REDIS_HOST=172.23.1.116 \
|
|
||||||
-e REDIS_DB=8 \
|
|
||||||
abstraction:dev
|
|
||||||
|
|
||||||
# 4. API Server
|
|
||||||
docker run -d --name api \
|
|
||||||
--network home-automation \
|
|
||||||
-p 8001:8001 \
|
|
||||||
-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
|
|
||||||
|
|
||||||
# 5. Web UI
|
|
||||||
docker run -d --name ui \
|
|
||||||
--network home-automation \
|
|
||||||
-p 8002:8002 \
|
|
||||||
-e API_BASE=http://api:8001 \
|
|
||||||
ui:dev
|
|
||||||
|
|
||||||
# 6. Device Simulator (optional)
|
|
||||||
docker run -d --name simulator \
|
|
||||||
--network home-automation \
|
|
||||||
-p 8010:8010 \
|
|
||||||
-e MQTT_BROKER=172.16.2.16 \
|
|
||||||
simulator:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### macOS mit finch/nerdctl (Alternative)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Images bauen (wie oben)
|
|
||||||
|
|
||||||
# Abstraction Layer
|
|
||||||
docker run -d --name abstraction \
|
|
||||||
-v $(pwd)/config:/app/config:ro \
|
|
||||||
-e MQTT_BROKER=172.16.2.16 \
|
|
||||||
-e REDIS_HOST=172.23.1.116 \
|
|
||||||
-e REDIS_DB=8 \
|
|
||||||
abstraction:dev
|
|
||||||
|
|
||||||
# API Server
|
|
||||||
docker run -d --name api \
|
|
||||||
-p 8001:8001 \
|
|
||||||
-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
|
|
||||||
|
|
||||||
# Web UI (mit host.docker.internal für macOS)
|
|
||||||
docker run -d --name ui \
|
|
||||||
--add-host=host.docker.internal:host-gateway \
|
|
||||||
-p 8002:8002 \
|
|
||||||
-e API_BASE=http://host.docker.internal:8001 \
|
|
||||||
ui:dev
|
|
||||||
|
|
||||||
# Device Simulator
|
|
||||||
docker run -d --name simulator \
|
|
||||||
-p 8010:8010 \
|
|
||||||
-e MQTT_BROKER=172.16.2.16 \
|
|
||||||
simulator:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Zugriff
|
|
||||||
|
|
||||||
- **Web UI**: http://<server-ip>:8002
|
|
||||||
- **API Docs**: http://<server-ip>:8001/docs
|
|
||||||
- **Simulator**: http://<server-ip>:8010
|
|
||||||
|
|
||||||
Auf localhost: `127.0.0.1` oder `localhost`
|
|
||||||
|
|
||||||
## finch/nerdctl Besonderheiten
|
|
||||||
|
|
||||||
### Port-Binding Verhalten (nur macOS/Windows)
|
|
||||||
|
|
||||||
**Standard Docker auf Linux:**
|
|
||||||
- `-p 8001:8001` → bindet auf `0.0.0.0:8001` (von überall erreichbar)
|
|
||||||
|
|
||||||
**finch/nerdctl auf macOS:**
|
|
||||||
- `-p 8001:8001` → bindet auf `127.0.0.1:8001` (nur localhost)
|
|
||||||
- Dies ist ein **Security-Feature** von nerdctl
|
|
||||||
- **Auf Linux-Servern ist das KEIN Problem!**
|
|
||||||
|
|
||||||
### Container-to-Container Kommunikation
|
|
||||||
|
|
||||||
**Linux (empfohlen):**
|
|
||||||
```bash
|
|
||||||
# Docker Network verwenden - Container sprechen sich mit Namen an
|
|
||||||
docker network create home-automation
|
|
||||||
docker run --network home-automation --name api ...
|
|
||||||
docker run --network home-automation -e API_BASE=http://api:8001 ui ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**macOS mit finch:**
|
|
||||||
```bash
|
|
||||||
# host.docker.internal verwenden
|
|
||||||
docker run --add-host=host.docker.internal:host-gateway \
|
|
||||||
-e API_BASE=http://host.docker.internal:8001 ui ...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Container verwalten
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Alle Container anzeigen
|
|
||||||
docker ps
|
|
||||||
|
|
||||||
# Logs anschauen
|
|
||||||
docker logs api
|
|
||||||
docker logs ui -f # Follow mode
|
|
||||||
|
|
||||||
# Container stoppen
|
|
||||||
docker stop api ui abstraction simulator
|
|
||||||
|
|
||||||
# Container entfernen
|
|
||||||
docker rm api ui abstraction simulator
|
|
||||||
|
|
||||||
# Alles neu starten
|
|
||||||
docker stop api ui abstraction simulator && \
|
|
||||||
docker rm api ui abstraction simulator && \
|
|
||||||
# ... dann Quick Start Befehle von oben
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### UI zeigt "Keine Räume oder Geräte konfiguriert"
|
|
||||||
|
|
||||||
**Problem:** UI kann API nicht erreichen
|
|
||||||
|
|
||||||
**Linux - Lösung:**
|
|
||||||
```bash
|
|
||||||
# Verwende Docker Network
|
|
||||||
docker network create home-automation
|
|
||||||
docker stop ui && docker rm ui
|
|
||||||
docker run -d --name ui \
|
|
||||||
--network home-automation \
|
|
||||||
-p 8002:8002 \
|
|
||||||
-e API_BASE=http://api:8001 \
|
|
||||||
ui:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
**macOS/finch - Lösung:**
|
|
||||||
```bash
|
|
||||||
docker stop ui && docker rm ui
|
|
||||||
docker run -d --name ui \
|
|
||||||
--add-host=host.docker.internal:host-gateway \
|
|
||||||
-p 8002:8002 \
|
|
||||||
-e API_BASE=http://host.docker.internal:8001 \
|
|
||||||
ui:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### "Connection refused" in Logs
|
|
||||||
|
|
||||||
**Check 1:** Ist die API gestartet?
|
|
||||||
```bash
|
|
||||||
docker ps | grep api
|
|
||||||
curl http://127.0.0.1:8001/health
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check 2:** Hat UI die richtige API_BASE?
|
|
||||||
```bash
|
|
||||||
docker inspect ui | grep API_BASE
|
|
||||||
```
|
|
||||||
|
|
||||||
### Port bereits belegt
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Prüfe welcher Prozess Port 8001 nutzt
|
|
||||||
lsof -i :8001
|
|
||||||
|
|
||||||
# Oder mit netstat
|
|
||||||
netstat -an | grep 8001
|
|
||||||
|
|
||||||
# Alte Container aufräumen
|
|
||||||
docker ps -a | grep -E "api|ui|abstraction|simulator"
|
|
||||||
docker rm -f <container-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Produktiv-Deployment
|
|
||||||
|
|
||||||
Für Produktion auf **Linux-Servern** empfohlen:
|
|
||||||
|
|
||||||
1. **Docker Compose** (siehe `infra/docker-compose.yml`)
|
|
||||||
2. **Docker Network** für Service Discovery (siehe Linux Quick Start oben)
|
|
||||||
3. **Volume Mounts** für Persistenz
|
|
||||||
4. **Health Checks** in Kubernetes/Compose (nicht im Dockerfile)
|
|
||||||
|
|
||||||
### Beispiel mit Docker Network (Linux)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Netzwerk erstellen
|
|
||||||
docker network create home-automation
|
|
||||||
|
|
||||||
# Services starten (alle im gleichen Netzwerk)
|
|
||||||
docker run -d --name api --network home-automation \
|
|
||||||
-p 8001:8001 \
|
|
||||||
-v $(pwd)/config:/app/config:ro \
|
|
||||||
api:dev
|
|
||||||
|
|
||||||
docker run -d --name ui --network home-automation \
|
|
||||||
-p 8002:8002 \
|
|
||||||
-e API_BASE=http://api:8001 \
|
|
||||||
ui:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
**Vorteil:** Service Discovery über Container-Namen, keine `--add-host` Tricks nötig.
|
|
||||||
@@ -1,553 +0,0 @@
|
|||||||
# HomeKit-Bridge API-Modell: Analyse der bestehenden Implementierung
|
|
||||||
|
|
||||||
**Analysedatum:** 17. November 2025
|
|
||||||
**Analysierte Dateien:**
|
|
||||||
- `apps/api/main.py`
|
|
||||||
- `apps/api/routes/groups_scenes.py`
|
|
||||||
- `config/devices.yaml`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Zusammenfassung
|
|
||||||
|
|
||||||
Die bestehende API-Implementierung erfüllt **~60%** der Anforderungen des HomeKit-Bridge Ziel-Modells. Die meisten Kernfunktionalitäten sind vorhanden, aber es fehlen wichtige Metadaten-Felder und ein dedizierter State-Endpoint.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. GET /devices
|
|
||||||
|
|
||||||
### Status: ✅ **VORHANDEN** mit Abweichungen
|
|
||||||
|
|
||||||
### Implementierung (apps/api/main.py:325-343)
|
|
||||||
```python
|
|
||||||
@app.get("/devices")
|
|
||||||
async def get_devices() -> list[DeviceInfo]:
|
|
||||||
devices = load_devices()
|
|
||||||
return [
|
|
||||||
DeviceInfo(
|
|
||||||
device_id=device["device_id"],
|
|
||||||
type=device["type"],
|
|
||||||
name=device.get("name", device["device_id"]),
|
|
||||||
features=device.get("features", {})
|
|
||||||
)
|
|
||||||
for device in devices
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Response-Modell (DeviceInfo)
|
|
||||||
```python
|
|
||||||
class DeviceInfo(BaseModel):
|
|
||||||
device_id: str
|
|
||||||
type: str
|
|
||||||
name: str
|
|
||||||
features: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Abweichungen vom Ziel-Modell
|
|
||||||
|
|
||||||
| Feld | Ziel-Modell | Ist-Zustand | Status |
|
|
||||||
|------|-------------|-------------|---------|
|
|
||||||
| `device_id` | ✅ Erforderlich | ✅ Vorhanden | OK |
|
|
||||||
| `type` | ✅ Erforderlich | ✅ Vorhanden | OK |
|
|
||||||
| `cap_version` | ✅ Erforderlich | ❌ **FEHLT** | FEHLT |
|
|
||||||
| `room` | ✅ Erforderlich | ❌ **FEHLT** | FEHLT |
|
|
||||||
| `friendly_name` | ✅ Erforderlich | ⚠️ Heißt `name` | UMBENENNUNG |
|
|
||||||
| `technology` | ✅ Erforderlich | ❌ **FEHLT** | FEHLT |
|
|
||||||
| `features` | ✅ Erforderlich | ✅ Vorhanden | OK |
|
|
||||||
| `read_only` | ✅ Erforderlich | ❌ **FEHLT** | FEHLT |
|
|
||||||
| `tags` | Optional | ❌ **FEHLT** | FEHLT |
|
|
||||||
|
|
||||||
### Details zu fehlenden Feldern
|
|
||||||
|
|
||||||
#### ❌ `cap_version`
|
|
||||||
- **Vorhanden in devices.yaml:** Ja, als `cap_version` (z.B. `"light@1.2.0"`)
|
|
||||||
- **Problem:** Wird von `load_devices()` geladen, aber nicht in `DeviceInfo` exponiert
|
|
||||||
- **Lösung:** Feld zu `DeviceInfo` hinzufügen und aus `device["cap_version"]` befüllen
|
|
||||||
|
|
||||||
#### ❌ `room`
|
|
||||||
- **Vorhanden in layout.yaml:** Ja, indirekt über Raum-Zuordnung
|
|
||||||
- **Problem:** Aktuell nur über separaten Endpoint `/devices/{device_id}/room` verfügbar
|
|
||||||
- **Lösung:** Room-Mapping in `/devices` integrieren (Resolver bereits vorhanden in `apps/api/resolvers.py`)
|
|
||||||
|
|
||||||
#### ⚠️ `friendly_name` vs. `name`
|
|
||||||
- **Vorhanden in devices.yaml:** Ja, als `metadata.friendly_name`
|
|
||||||
- **Problem:** Aktuell wird `device.get("name", device["device_id"])` verwendet, nicht `metadata.friendly_name`
|
|
||||||
- **Lösung:** Priorisierung: `metadata.friendly_name` > `name` > `device_id`
|
|
||||||
|
|
||||||
#### ❌ `technology`
|
|
||||||
- **Vorhanden in devices.yaml:** Ja, als `technology` (z.B. `"zigbee2mqtt"`)
|
|
||||||
- **Problem:** Wird nicht in Response exponiert
|
|
||||||
- **Lösung:** Feld zu `DeviceInfo` hinzufügen
|
|
||||||
|
|
||||||
#### ❌ `read_only`
|
|
||||||
- **Implizit vorhanden:** Ja, über `topics.set` (wenn fehlt → read-only)
|
|
||||||
- **Problem:** Muss berechnet werden
|
|
||||||
- **Lösung:** `read_only = "set" not in device.get("topics", {})`
|
|
||||||
|
|
||||||
#### ❌ `tags`
|
|
||||||
- **Vorhanden in devices.yaml:** Nein
|
|
||||||
- **Status:** Nicht kritisch, kann später ergänzt werden
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. GET /devices/{device_id}
|
|
||||||
|
|
||||||
### Status: ❌ **FEHLT KOMPLETT**
|
|
||||||
|
|
||||||
### Aktuell vorhanden
|
|
||||||
- `/devices/{device_id}/room` (liefert nur `{"device_id": str, "room": str | None}`)
|
|
||||||
|
|
||||||
### Erforderlich
|
|
||||||
Ein Endpoint, der das gleiche Schema wie ein Eintrag aus `/devices` zurückgibt:
|
|
||||||
```python
|
|
||||||
@app.get("/devices/{device_id}")
|
|
||||||
async def get_device(device_id: str) -> DeviceInfo:
|
|
||||||
# Load device, enrich with room, return DeviceInfo
|
|
||||||
```
|
|
||||||
|
|
||||||
### Implementierung
|
|
||||||
- Device aus `load_devices()` filtern
|
|
||||||
- Mit `get_room(device_id)` anreichern
|
|
||||||
- Als `DeviceInfo` zurückgeben
|
|
||||||
- 404 bei nicht gefunden
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. GET /devices/{device_id}/state
|
|
||||||
|
|
||||||
### Status: ❌ **FEHLT KOMPLETT**
|
|
||||||
|
|
||||||
### Aktuell vorhanden
|
|
||||||
- `/devices/states` (liefert **alle** Device-States als Dict)
|
|
||||||
```python
|
|
||||||
@app.get("/devices/states")
|
|
||||||
async def get_device_states() -> dict[str, dict[str, Any]]:
|
|
||||||
return device_states # In-memory cache
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ziel-Format
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"device_id": "thermostat_wolfgang",
|
|
||||||
"type": "thermostat",
|
|
||||||
"room": "Schlafzimmer",
|
|
||||||
"payload": {
|
|
||||||
"current": 19.5,
|
|
||||||
"target": 21.0,
|
|
||||||
"mode": "heat"
|
|
||||||
},
|
|
||||||
"ts": "2025-11-17T14:23:45.123Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Erforderlich
|
|
||||||
```python
|
|
||||||
@app.get("/devices/{device_id}/state")
|
|
||||||
async def get_device_state(device_id: str) -> DeviceStateResponse:
|
|
||||||
# Get from device_states cache
|
|
||||||
# Enrich with metadata (type, room)
|
|
||||||
# Add timestamp
|
|
||||||
# Return structured response
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problem
|
|
||||||
- Aktuell wird nur `payload` im Cache gespeichert
|
|
||||||
- Timestamp fehlt im Cache (müsste bei SSE-Updates mitgespeichert werden)
|
|
||||||
- Metadaten (type, room) müssen aus devices.yaml/layout.yaml ergänzt werden
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. SSE-Endpoint /realtime
|
|
||||||
|
|
||||||
### Status: ✅ **VORHANDEN** mit kleineren Abweichungen
|
|
||||||
|
|
||||||
### Implementierung (apps/api/main.py:608-637)
|
|
||||||
```python
|
|
||||||
@app.get("/realtime")
|
|
||||||
async def realtime_events(request: Request) -> StreamingResponse:
|
|
||||||
return StreamingResponse(
|
|
||||||
event_generator(request),
|
|
||||||
media_type="text/event-stream",
|
|
||||||
# ... headers
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Aktuelles Event-Format (aus Redis)
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "state",
|
|
||||||
"device_id": "thermostat_wolfgang",
|
|
||||||
"payload": {
|
|
||||||
"current": 19.5,
|
|
||||||
"target": 21.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ziel-Format
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "state",
|
|
||||||
"device_id": "thermostat_wolfgang",
|
|
||||||
"device_type": "thermostat", // ← FEHLT
|
|
||||||
"room": "Schlafzimmer", // ← FEHLT
|
|
||||||
"payload": {
|
|
||||||
"current": 19.5,
|
|
||||||
"target": 21.0
|
|
||||||
},
|
|
||||||
"ts": "2025-11-17T14:23:45.123Z", // ← FEHLT
|
|
||||||
"source": "zigbee2mqtt" // ← FEHLT (optional)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Abweichungen
|
|
||||||
|
|
||||||
| Feld | Ziel-Modell | Ist-Zustand | Status |
|
|
||||||
|------|-------------|-------------|---------|
|
|
||||||
| `type` | ✅ | ✅ | OK |
|
|
||||||
| `device_id` | ✅ | ✅ | OK |
|
|
||||||
| `device_type` | ✅ | ❌ **FEHLT** | FEHLT |
|
|
||||||
| `room` | ✅ | ❌ **FEHLT** | FEHLT |
|
|
||||||
| `payload` | ✅ | ✅ | OK |
|
|
||||||
| `ts` | ✅ | ❌ **FEHLT** | FEHLT |
|
|
||||||
| `source` | Optional | ❌ **FEHLT** | FEHLT |
|
|
||||||
|
|
||||||
### Problem
|
|
||||||
Events werden direkt aus Redis weitergeleitet ohne Enrichment.
|
|
||||||
|
|
||||||
### Lösungsansätze
|
|
||||||
|
|
||||||
**Option A: Enrichment im SSE-Generator**
|
|
||||||
```python
|
|
||||||
# Im event_generator() nach JSON-Parse:
|
|
||||||
state_data = json.loads(data)
|
|
||||||
if state_data.get("type") == "state":
|
|
||||||
# Enrich with metadata
|
|
||||||
device_id = state_data["device_id"]
|
|
||||||
device = get_device_from_cache(device_id)
|
|
||||||
state_data["device_type"] = device["type"]
|
|
||||||
state_data["room"] = get_room(device_id)
|
|
||||||
if "ts" not in state_data:
|
|
||||||
state_data["ts"] = datetime.utcnow().isoformat()
|
|
||||||
data = json.dumps(state_data)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option B: Enrichment im Publisher (apps/abstraction)**
|
|
||||||
- Besser: Events bereits vollständig beim Publizieren
|
|
||||||
- Würde auch `/devices/{id}/state` helfen
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. POST /devices/{device_id}/set
|
|
||||||
|
|
||||||
### Status: ✅ **VORHANDEN** mit kleinen Abweichungen
|
|
||||||
|
|
||||||
### Implementierung (apps/api/main.py:406-504)
|
|
||||||
```python
|
|
||||||
@app.post("/devices/{device_id}/set", status_code=status.HTTP_202_ACCEPTED)
|
|
||||||
async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str]:
|
|
||||||
# Validierung, MQTT publish
|
|
||||||
```
|
|
||||||
|
|
||||||
### Request-Modell
|
|
||||||
```python
|
|
||||||
class SetDeviceRequest(BaseModel):
|
|
||||||
type: str
|
|
||||||
payload: dict[str, Any]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Vergleich mit Ziel-Modell
|
|
||||||
|
|
||||||
| Aspekt | Ziel-Modell | Ist-Zustand | Status |
|
|
||||||
|--------|-------------|-------------|---------|
|
|
||||||
| Body-Format | `{type, payload}` | `{type, payload}` | ✅ OK |
|
|
||||||
| Type-Validierung | ✅ Erforderlich | ✅ Vorhanden | OK |
|
|
||||||
| Payload-Validierung | ✅ Per Device-Type | ✅ Vorhanden | OK |
|
|
||||||
| Read-Only Check | ✅ → 405 | ✅ → 405 | OK |
|
|
||||||
| Response Code | 200/202 | 202 | OK |
|
|
||||||
|
|
||||||
### Validierungs-Details
|
|
||||||
|
|
||||||
**✅ Gut implementiert:**
|
|
||||||
- Type-spezifische Pydantic-Validierung (LightState, ThermostatState, etc.)
|
|
||||||
- Whitelist für erlaubte Felder bei Thermostaten
|
|
||||||
- Read-only Detection über `topics.set`
|
|
||||||
- Proper HTTP Status Codes (404, 405, 422)
|
|
||||||
|
|
||||||
**⚠️ Kleine Abweichung:**
|
|
||||||
- Thermostat-Validierung erlaubt nur `{mode, target}` beim SET
|
|
||||||
- Ziel-Modell erwähnt dies nicht explizit
|
|
||||||
- **Bewertung:** Ist sinnvolle Einschränkung, kein Problem
|
|
||||||
|
|
||||||
### MQTT-Publishing
|
|
||||||
```python
|
|
||||||
topic = f"home/{request.type}/{device_id}/set"
|
|
||||||
mqtt_payload = {
|
|
||||||
"type": request.type,
|
|
||||||
"payload": request.payload
|
|
||||||
}
|
|
||||||
await publish_mqtt(topic, mqtt_payload)
|
|
||||||
```
|
|
||||||
✅ Korrekt implementiert
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Zusätzliche Endpoints (nicht im Ziel-Modell)
|
|
||||||
|
|
||||||
### ℹ️ Vorhanden, aber nicht gefordert
|
|
||||||
|
|
||||||
- **GET /spec** - Capability-Versionen
|
|
||||||
- **GET /devices/states** - Alle States (könnte nützlich für Bridge sein)
|
|
||||||
- **GET /layout** - UI-spezifisch
|
|
||||||
- **GET /devices/{device_id}/room** - Wird obsolet wenn `/devices` `room` hat
|
|
||||||
- **GET /groups**, **POST /groups/{id}/set** - Gruppen-Feature
|
|
||||||
- **GET /scenes**, **POST /scenes/{id}/run** - Szenen-Feature
|
|
||||||
|
|
||||||
**Bewertung:** Nicht störend, können bleiben. Bridge muss diese nicht nutzen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Datenquellen-Analyse
|
|
||||||
|
|
||||||
### devices.yaml
|
|
||||||
**✅ Enthält alle benötigten Felder:**
|
|
||||||
```yaml
|
|
||||||
- device_id: leselampe_esszimmer
|
|
||||||
type: light
|
|
||||||
cap_version: "light@1.2.0" # ← Vorhanden
|
|
||||||
technology: zigbee2mqtt # ← Vorhanden
|
|
||||||
features:
|
|
||||||
power: true
|
|
||||||
brightness: true
|
|
||||||
topics:
|
|
||||||
state: "..."
|
|
||||||
set: "..." # ← Für read_only Detection
|
|
||||||
metadata:
|
|
||||||
friendly_name: "Leselampe Esszimmer" # ← Vorhanden
|
|
||||||
ieee_address: "..."
|
|
||||||
model: "LED1842G3"
|
|
||||||
vendor: "IKEA"
|
|
||||||
```
|
|
||||||
|
|
||||||
### layout.yaml
|
|
||||||
**✅ Enthält Room-Mapping:**
|
|
||||||
```yaml
|
|
||||||
rooms:
|
|
||||||
- name: "Schlafzimmer"
|
|
||||||
devices:
|
|
||||||
- device_id: thermostat_wolfgang
|
|
||||||
```
|
|
||||||
|
|
||||||
**✅ Resolver bereits vorhanden:** `apps/api/resolvers.py::get_room(device_id)`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Priorisierte To-Do-Liste
|
|
||||||
|
|
||||||
### 🔴 **Kritisch** (Bridge funktioniert nicht ohne)
|
|
||||||
|
|
||||||
1. **GET /devices: Fehlende Felder ergänzen**
|
|
||||||
- `cap_version` aus devices.yaml
|
|
||||||
- `room` via `get_room()`
|
|
||||||
- `friendly_name` aus `metadata.friendly_name`
|
|
||||||
- `technology` aus devices.yaml
|
|
||||||
- `read_only` berechnen
|
|
||||||
|
|
||||||
2. **GET /devices/{device_id}/state implementieren**
|
|
||||||
- Neuer Endpoint
|
|
||||||
- State aus Cache + Metadaten
|
|
||||||
- Timestamp hinzufügen
|
|
||||||
|
|
||||||
### 🟡 **Wichtig** (Bridge funktioniert, aber eingeschränkt)
|
|
||||||
|
|
||||||
3. **SSE /realtime: Events enrichen**
|
|
||||||
- `device_type` hinzufügen
|
|
||||||
- `room` hinzufügen
|
|
||||||
- `ts` sicherstellen
|
|
||||||
|
|
||||||
4. **GET /devices/{device_id} implementieren**
|
|
||||||
- Einzelgerät-Abfrage
|
|
||||||
- Gleiche Struktur wie `/devices`-Eintrag
|
|
||||||
|
|
||||||
### 🟢 **Nice-to-have**
|
|
||||||
|
|
||||||
5. **State-Cache mit Timestamps erweitern**
|
|
||||||
- Aktuell: `device_states[id] = payload`
|
|
||||||
- Ziel: `device_states[id] = {payload, ts}`
|
|
||||||
|
|
||||||
6. **SSE: source-Feld hinzufügen**
|
|
||||||
- Aus `device["technology"]` ableiten
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Implementierungs-Reihenfolge
|
|
||||||
|
|
||||||
### Phase 1: GET /devices erweitern
|
|
||||||
**Dateien:**
|
|
||||||
- `apps/api/main.py` (DeviceInfo-Modell, get_devices())
|
|
||||||
|
|
||||||
**Änderungen:**
|
|
||||||
```python
|
|
||||||
class DeviceInfo(BaseModel):
|
|
||||||
device_id: str
|
|
||||||
type: str
|
|
||||||
cap_version: str
|
|
||||||
room: str | None
|
|
||||||
friendly_name: str
|
|
||||||
technology: str
|
|
||||||
features: dict[str, Any]
|
|
||||||
read_only: bool
|
|
||||||
tags: list[str] | None = None
|
|
||||||
|
|
||||||
@app.get("/devices")
|
|
||||||
async def get_devices() -> list[DeviceInfo]:
|
|
||||||
devices = load_devices()
|
|
||||||
return [
|
|
||||||
DeviceInfo(
|
|
||||||
device_id=device["device_id"],
|
|
||||||
type=device["type"],
|
|
||||||
cap_version=device["cap_version"],
|
|
||||||
room=get_room(device["device_id"]),
|
|
||||||
friendly_name=device.get("metadata", {}).get("friendly_name", device["device_id"]),
|
|
||||||
technology=device["technology"],
|
|
||||||
features=device.get("features", {}),
|
|
||||||
read_only="set" not in device.get("topics", {}),
|
|
||||||
tags=device.get("tags")
|
|
||||||
)
|
|
||||||
for device in devices
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 2: GET /devices/{device_id}/state
|
|
||||||
**Dateien:**
|
|
||||||
- `apps/api/main.py`
|
|
||||||
|
|
||||||
**Neues Modell:**
|
|
||||||
```python
|
|
||||||
class DeviceStateResponse(BaseModel):
|
|
||||||
device_id: str
|
|
||||||
type: str
|
|
||||||
room: str | None
|
|
||||||
payload: dict[str, Any]
|
|
||||||
ts: str
|
|
||||||
|
|
||||||
@app.get("/devices/{device_id}/state")
|
|
||||||
async def get_device_state(device_id: str) -> DeviceStateResponse:
|
|
||||||
if device_id not in device_states:
|
|
||||||
raise HTTPException(404, f"No state for {device_id}")
|
|
||||||
|
|
||||||
devices = load_devices()
|
|
||||||
device = next((d for d in devices if d["device_id"] == device_id), None)
|
|
||||||
if not device:
|
|
||||||
raise HTTPException(404, f"Device {device_id} not found")
|
|
||||||
|
|
||||||
return DeviceStateResponse(
|
|
||||||
device_id=device_id,
|
|
||||||
type=device["type"],
|
|
||||||
room=get_room(device_id),
|
|
||||||
payload=device_states[device_id],
|
|
||||||
ts=datetime.utcnow().isoformat() + "Z"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 3: SSE Enrichment
|
|
||||||
**Dateien:**
|
|
||||||
- `apps/api/main.py` (event_generator())
|
|
||||||
|
|
||||||
**Im event_generator() nach JSON-Parse:**
|
|
||||||
```python
|
|
||||||
if message and message["type"] == "message":
|
|
||||||
data = message["data"]
|
|
||||||
state_data = json.loads(data)
|
|
||||||
|
|
||||||
# Enrich events
|
|
||||||
if state_data.get("type") == "state" and state_data.get("device_id"):
|
|
||||||
device_id = state_data["device_id"]
|
|
||||||
devices = load_devices()
|
|
||||||
device = next((d for d in devices if d["device_id"] == device_id), None)
|
|
||||||
|
|
||||||
if device:
|
|
||||||
state_data["device_type"] = device["type"]
|
|
||||||
state_data["room"] = get_room(device_id)
|
|
||||||
if "ts" not in state_data:
|
|
||||||
state_data["ts"] = datetime.utcnow().isoformat() + "Z"
|
|
||||||
state_data["source"] = device.get("technology")
|
|
||||||
|
|
||||||
data = json.dumps(state_data)
|
|
||||||
|
|
||||||
yield f"event: message\ndata: {data}\n\n"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 4: GET /devices/{device_id}
|
|
||||||
**Dateien:**
|
|
||||||
- `apps/api/main.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
@app.get("/devices/{device_id}")
|
|
||||||
async def get_device(device_id: str) -> DeviceInfo:
|
|
||||||
devices = load_devices()
|
|
||||||
device = next((d for d in devices if d["device_id"] == device_id), None)
|
|
||||||
|
|
||||||
if not device:
|
|
||||||
raise HTTPException(404, f"Device {device_id} not found")
|
|
||||||
|
|
||||||
return DeviceInfo(
|
|
||||||
device_id=device["device_id"],
|
|
||||||
type=device["type"],
|
|
||||||
cap_version=device["cap_version"],
|
|
||||||
room=get_room(device["device_id"]),
|
|
||||||
friendly_name=device.get("metadata", {}).get("friendly_name", device["device_id"]),
|
|
||||||
technology=device["technology"],
|
|
||||||
features=device.get("features", {}),
|
|
||||||
read_only="set" not in device.get("topics", {}),
|
|
||||||
tags=device.get("tags")
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Zusammenfassung der Abweichungen
|
|
||||||
|
|
||||||
### ✅ Bereits konform (40%)
|
|
||||||
- POST /devices/{id}/set - Vollständig implementiert
|
|
||||||
- SSE /realtime - Grundfunktion vorhanden
|
|
||||||
- GET /devices - Grundstruktur vorhanden
|
|
||||||
|
|
||||||
### ⚠️ Teilweise konform (40%)
|
|
||||||
- GET /devices - Fehlen wichtige Felder (cap_version, room, friendly_name, technology, read_only)
|
|
||||||
- SSE /realtime - Events ohne device_type, room, ts
|
|
||||||
|
|
||||||
### ❌ Nicht vorhanden (20%)
|
|
||||||
- GET /devices/{device_id}/state - Komplett fehlend
|
|
||||||
- GET /devices/{device_id} - Komplett fehlend
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Risiko-Bewertung
|
|
||||||
|
|
||||||
### 🟢 **Geringes Risiko**
|
|
||||||
- Alle Daten sind in devices.yaml/layout.yaml vorhanden
|
|
||||||
- Resolver-Funktionen existieren bereits
|
|
||||||
- Pydantic-Modelle sind etabliert
|
|
||||||
- Keine Breaking Changes an bestehenden Endpoints nötig
|
|
||||||
|
|
||||||
### 🟡 **Mittleres Risiko**
|
|
||||||
- SSE-Enrichment könnte Performance beeinflussen (load_devices() bei jedem Event)
|
|
||||||
- **Mitigation:** Device-Lookup cachen
|
|
||||||
- Timestamp-Handling muss konsistent sein
|
|
||||||
- **Mitigation:** UTC + ISO8601 + "Z" Suffix
|
|
||||||
|
|
||||||
### 🔴 **Kein hohes Risiko identifiziert**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Nächste Schritte
|
|
||||||
|
|
||||||
1. **Freigabe einholen:** Sollen wir mit Phase 1 (GET /devices erweitern) starten?
|
|
||||||
2. **Testing-Strategie:** Sollen Tests für die neuen Endpoints geschrieben werden?
|
|
||||||
3. **Backward Compatibility:** GET /devices ändert Response-Struktur - ist das OK? (Vermutlich ja, da UI diese Felder ignorieren kann)
|
|
||||||
4. **Performance:** Device-Lookup-Cache implementieren vor SSE-Enrichment?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Ende der Analyse**
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
# MAX! (eQ-3) Thermostat Integration
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document describes the integration of MAX! (eQ-3) thermostats via Homegear into the home automation system.
|
|
||||||
|
|
||||||
## Protocol Characteristics
|
|
||||||
|
|
||||||
MAX! thermostats use a **simple integer-based protocol** (not JSON):
|
|
||||||
|
|
||||||
- **SET messages**: Plain integer temperature value (e.g., `22`)
|
|
||||||
- **STATE messages**: Plain integer temperature value (e.g., `22`)
|
|
||||||
- **Topics**: Homegear MQTT format
|
|
||||||
|
|
||||||
### MQTT Topics
|
|
||||||
|
|
||||||
**SET Command:**
|
|
||||||
```
|
|
||||||
homegear/instance1/set/<peerId>/<channel>/SET_TEMPERATURE
|
|
||||||
Payload: "22" (plain integer as string)
|
|
||||||
```
|
|
||||||
|
|
||||||
**STATE Update:**
|
|
||||||
```
|
|
||||||
homegear/instance1/plain/<peerId>/<channel>/SET_TEMPERATURE
|
|
||||||
Payload: "22" (plain integer as string)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Transformation Layer
|
|
||||||
|
|
||||||
The abstraction layer provides automatic transformation between the abstract home protocol and MAX! format.
|
|
||||||
|
|
||||||
### Abstract → MAX! (SET)
|
|
||||||
|
|
||||||
**Input (Abstract):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mode": "heat",
|
|
||||||
"target": 22.5
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output (MAX!):**
|
|
||||||
```
|
|
||||||
22
|
|
||||||
```
|
|
||||||
|
|
||||||
**Transformation Rules:**
|
|
||||||
- Extract `target` temperature
|
|
||||||
- Convert float → integer (round to nearest)
|
|
||||||
- Return as plain string (no JSON)
|
|
||||||
- Ignore `mode` field (MAX! always in heating mode)
|
|
||||||
|
|
||||||
### MAX! → Abstract (STATE)
|
|
||||||
|
|
||||||
**Input (MAX!):**
|
|
||||||
```
|
|
||||||
22
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output (Abstract):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"target": 22.0,
|
|
||||||
"mode": "heat"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Transformation Rules:**
|
|
||||||
- Parse plain string/integer value
|
|
||||||
- Convert to float
|
|
||||||
- Add default `mode: "heat"` (MAX! always heating)
|
|
||||||
- Wrap in abstract payload structure
|
|
||||||
|
|
||||||
## Device Configuration
|
|
||||||
|
|
||||||
### Example devices.yaml Entry
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- device_id: "thermostat_wolfgang"
|
|
||||||
type: "thermostat"
|
|
||||||
cap_version: "thermostat@1.0.0"
|
|
||||||
technology: "max"
|
|
||||||
features:
|
|
||||||
mode: true
|
|
||||||
target: true
|
|
||||||
current: false # SET_TEMPERATURE doesn't report current temp
|
|
||||||
topics:
|
|
||||||
set: "homegear/instance1/set/39/1/SET_TEMPERATURE"
|
|
||||||
state: "homegear/instance1/plain/39/1/SET_TEMPERATURE"
|
|
||||||
metadata:
|
|
||||||
friendly_name: "Thermostat Wolfgang"
|
|
||||||
location: "Arbeitszimmer Wolfgang"
|
|
||||||
vendor: "eQ-3"
|
|
||||||
model: "MAX! Thermostat"
|
|
||||||
peer_id: "39"
|
|
||||||
channel: "1"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration Notes
|
|
||||||
|
|
||||||
1. **technology**: Must be set to `"max"` to activate MAX! transformations
|
|
||||||
2. **topics.set**: Use Homegear's `/set/` path with `/SET_TEMPERATURE` parameter
|
|
||||||
3. **topics.state**: Use Homegear's `/plain/` path with `/SET_TEMPERATURE` parameter
|
|
||||||
4. **features.current**: Set to `false` - SET_TEMPERATURE topic doesn't provide current temperature
|
|
||||||
5. **metadata**: Include `peer_id` and `channel` for reference
|
|
||||||
|
|
||||||
## Temperature Rounding
|
|
||||||
|
|
||||||
MAX! only supports **integer temperatures**. The system uses standard rounding:
|
|
||||||
|
|
||||||
| Abstract Input | MAX! Output |
|
|
||||||
|----------------|-------------|
|
|
||||||
| 20.4°C | 20 |
|
|
||||||
| 20.5°C | 20 |
|
|
||||||
| 20.6°C | 21 |
|
|
||||||
| 21.5°C | 22 |
|
|
||||||
| 22.5°C | 22 |
|
|
||||||
|
|
||||||
Python's `round()` function uses "banker's rounding" (round half to even).
|
|
||||||
|
|
||||||
## Limitations
|
|
||||||
|
|
||||||
1. **No current temperature**: SET_TEMPERATURE topic only reports target, not actual temperature
|
|
||||||
2. **No mode control**: MAX! thermostats are always in heating mode
|
|
||||||
3. **Integer only**: Temperature precision limited to 1°C steps
|
|
||||||
4. **No battery status**: Not available via SET_TEMPERATURE topic
|
|
||||||
5. **No window detection**: Not available via SET_TEMPERATURE topic
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Test the transformation functions:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
poetry run python /tmp/test_max_transform.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected output:
|
|
||||||
```
|
|
||||||
✅ PASS: Float 22.5 -> Integer string
|
|
||||||
✅ PASS: Integer string -> Abstract dict
|
|
||||||
✅ PASS: Integer -> Abstract dict
|
|
||||||
✅ PASS: Rounding works correctly
|
|
||||||
🎉 All MAX! transformation tests passed!
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Details
|
|
||||||
|
|
||||||
### Files Modified
|
|
||||||
|
|
||||||
1. **apps/abstraction/transformation.py**
|
|
||||||
- Added `_transform_thermostat_max_to_vendor()` - converts abstract → plain integer
|
|
||||||
- Added `_transform_thermostat_max_to_abstract()` - converts plain integer → abstract
|
|
||||||
- Registered handlers in `TRANSFORM_HANDLERS` registry
|
|
||||||
|
|
||||||
2. **apps/abstraction/main.py**
|
|
||||||
- Modified `handle_abstract_set()` to send plain string for MAX! devices (not JSON)
|
|
||||||
- Modified message processing to handle plain text payloads from MAX! STATE topics
|
|
||||||
|
|
||||||
### Transformation Functions
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _transform_thermostat_max_to_vendor(payload: dict[str, Any]) -> str:
|
|
||||||
"""Convert {"target": 22.5} → "22" """
|
|
||||||
target_temp = payload.get("target", 21.0)
|
|
||||||
return str(int(round(target_temp)))
|
|
||||||
|
|
||||||
def _transform_thermostat_max_to_abstract(payload: str | int | float) -> dict[str, Any]:
|
|
||||||
"""Convert "22" → {"target": 22.0, "mode": "heat"} """
|
|
||||||
target_temp = float(payload)
|
|
||||||
return {"target": target_temp, "mode": "heat"}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage Example
|
|
||||||
|
|
||||||
### Setting Temperature via API
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8001/devices/thermostat_wolfgang/set \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"type": "thermostat",
|
|
||||||
"payload": {
|
|
||||||
"mode": "heat",
|
|
||||||
"target": 22.5
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Flow:**
|
|
||||||
1. API receives abstract payload: `{"mode": "heat", "target": 22.5}`
|
|
||||||
2. Abstraction transforms to MAX!: `"22"`
|
|
||||||
3. Publishes to: `homegear/instance1/set/39/1/SET_TEMPERATURE` with payload `22`
|
|
||||||
|
|
||||||
### Receiving State Updates
|
|
||||||
|
|
||||||
**Homegear publishes:**
|
|
||||||
```
|
|
||||||
Topic: homegear/instance1/plain/39/1/SET_TEMPERATURE
|
|
||||||
Payload: 22
|
|
||||||
```
|
|
||||||
|
|
||||||
**Flow:**
|
|
||||||
1. Abstraction receives plain text: `"22"`
|
|
||||||
2. Transforms to abstract: `{"target": 22.0, "mode": "heat"}`
|
|
||||||
3. Publishes to: `home/thermostat/thermostat_wolfgang/state`
|
|
||||||
4. Publishes to Redis: `ui:updates` channel for real-time UI updates
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
Potential improvements for better MAX! integration:
|
|
||||||
|
|
||||||
1. **Current Temperature**: Subscribe to separate Homegear topic for actual temperature
|
|
||||||
2. **Battery Status**: Subscribe to LOWBAT or battery level topics
|
|
||||||
3. **Valve Position**: Monitor actual valve opening percentage
|
|
||||||
4. **Window Detection**: Subscribe to window open detection status
|
|
||||||
5. **Mode Control**: Support comfort/eco temperature presets
|
|
||||||
|
|
||||||
## Related Documentation
|
|
||||||
|
|
||||||
- [Homegear MAX! Documentation](https://doc.homegear.eu/data/homegear-max/)
|
|
||||||
- [Abstract Protocol Specification](docs/PROTOCOL.md)
|
|
||||||
- [Transformation Layer Design](apps/abstraction/README.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
|
|
||||||
```
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
# 🌡️ Thermostat UI - Quick Reference
|
|
||||||
|
|
||||||
## ✅ Implementation Complete
|
|
||||||
|
|
||||||
### Features Implemented
|
|
||||||
|
|
||||||
| Feature | Status | Details |
|
|
||||||
|---------|--------|---------|
|
|
||||||
| Temperature Display | ✅ | Ist (current) & Soll (target) in °C |
|
|
||||||
| Mode Display | ✅ | Shows OFF/HEAT/AUTO |
|
|
||||||
| +0.5 Button | ✅ | Increases target temperature |
|
|
||||||
| -0.5 Button | ✅ | Decreases target temperature |
|
|
||||||
| Mode Buttons | ✅ | OFF, HEAT, AUTO switches |
|
|
||||||
| Real-time Updates | ✅ | SSE-based live updates |
|
|
||||||
| Temperature Drift | ✅ | ±0.2°C every 5 seconds |
|
|
||||||
| Touch-Friendly | ✅ | 44px minimum button height |
|
|
||||||
| Responsive Grid | ✅ | Adapts to screen size |
|
|
||||||
| Event Logging | ✅ | All actions logged |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Acceptance Criteria Status
|
|
||||||
|
|
||||||
- ✅ Click +0.5 → increases target & sends POST
|
|
||||||
- ✅ Click -0.5 → decreases target & sends POST
|
|
||||||
- ✅ Mode buttons send POST requests
|
|
||||||
- ✅ No JavaScript console errors
|
|
||||||
- ✅ SSE updates current/target/mode without reload
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
### 1. Start All Services
|
|
||||||
```bash
|
|
||||||
# Abstraction Layer
|
|
||||||
poetry run python -m apps.abstraction.main > /tmp/abstraction.log 2>&1 &
|
|
||||||
|
|
||||||
# API Server
|
|
||||||
poetry run uvicorn apps.api.main:app --host 0.0.0.0 --port 8001 > /tmp/api.log 2>&1 &
|
|
||||||
|
|
||||||
# UI Server
|
|
||||||
poetry run uvicorn apps.ui.main:app --host 0.0.0.0 --port 8002 > /tmp/ui.log 2>&1 &
|
|
||||||
|
|
||||||
# Device Simulator
|
|
||||||
poetry run python tools/device_simulator.py > /tmp/simulator.log 2>&1 &
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Access UI
|
|
||||||
```
|
|
||||||
http://localhost:8002
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Monitor Logs
|
|
||||||
```bash
|
|
||||||
# Real-time log monitoring
|
|
||||||
tail -f /tmp/abstraction.log # MQTT & Redis activity
|
|
||||||
tail -f /tmp/simulator.log # Device simulation
|
|
||||||
tail -f /tmp/api.log # API requests
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Testing
|
|
||||||
|
|
||||||
### Quick Test
|
|
||||||
```bash
|
|
||||||
# Adjust temperature
|
|
||||||
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}}'
|
|
||||||
|
|
||||||
# Check simulator response
|
|
||||||
tail -3 /tmp/simulator.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### Full Test Suite
|
|
||||||
```bash
|
|
||||||
/tmp/test_thermostat_ui.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Current State
|
|
||||||
|
|
||||||
**Device ID:** `test_thermo_1`
|
|
||||||
|
|
||||||
**Live State:**
|
|
||||||
- Mode: AUTO
|
|
||||||
- Target: 23.0°C
|
|
||||||
- Current: ~23.1°C (drifting)
|
|
||||||
- Battery: 90%
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 API Reference
|
|
||||||
|
|
||||||
### Set Thermostat
|
|
||||||
```http
|
|
||||||
POST /devices/{device_id}/set
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"type": "thermostat",
|
|
||||||
"payload": {
|
|
||||||
"mode": "heat", // Required: "off" | "heat" | "auto"
|
|
||||||
"target": 22.5 // Required: 5.0 - 30.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Response
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Command sent to test_thermo_1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 UI Components
|
|
||||||
|
|
||||||
### Thermostat Card Structure
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ 🌡️ Living Room Thermostat │
|
|
||||||
│ test_thermo_1 │
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ Ist: 23.1°C Soll: 23.0°C │
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ Modus: AUTO │
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ [ -0.5 ] [ +0.5 ] │
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ [ OFF ] [ HEAT* ] [ AUTO ] │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### JavaScript Functions
|
|
||||||
```javascript
|
|
||||||
adjustTarget(deviceId, delta) // ±0.5°C
|
|
||||||
setMode(deviceId, mode) // "off"|"heat"|"auto"
|
|
||||||
updateThermostatUI(...) // Auto-called by SSE
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📱 Responsive Breakpoints
|
|
||||||
|
|
||||||
| Screen Width | Columns | Card Width |
|
|
||||||
|--------------|---------|------------|
|
|
||||||
| < 600px | 1 | 100% |
|
|
||||||
| 600-900px | 2 | ~300px |
|
|
||||||
| 900-1200px | 3 | ~300px |
|
|
||||||
| > 1200px | 4 | ~300px |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Troubleshooting
|
|
||||||
|
|
||||||
### UI not updating?
|
|
||||||
```bash
|
|
||||||
# Check SSE connection
|
|
||||||
curl -N http://localhost:8001/realtime
|
|
||||||
|
|
||||||
# Check Redis publishes
|
|
||||||
tail -f /tmp/abstraction.log | grep "Redis PUBLISH"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Buttons not working?
|
|
||||||
```bash
|
|
||||||
# Check browser console (F12)
|
|
||||||
# Check API logs
|
|
||||||
tail -f /tmp/api.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### Temperature not drifting?
|
|
||||||
```bash
|
|
||||||
# Check simulator
|
|
||||||
tail -f /tmp/simulator.log | grep drift
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Files Modified
|
|
||||||
|
|
||||||
- `apps/ui/templates/dashboard.html` (3 changes)
|
|
||||||
- Added `thermostatModes` state tracking
|
|
||||||
- Updated `adjustTarget()` to include mode
|
|
||||||
- Updated `updateThermostatUI()` to track mode
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ Key Features
|
|
||||||
|
|
||||||
1. **Real-time Updates**: SSE-based, no polling
|
|
||||||
2. **Touch-Optimized**: 44px buttons for mobile
|
|
||||||
3. **Visual Feedback**: Active mode highlighting
|
|
||||||
4. **Event Logging**: All actions logged for debugging
|
|
||||||
5. **Error Handling**: Graceful degradation on failures
|
|
||||||
6. **Accessibility**: WCAG 2.1 compliant
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Status:** ✅ Production Ready
|
|
||||||
**Last Updated:** 2025-11-06
|
|
||||||
**Test Coverage:** 78% automated + 100% manual verification
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
# Thermostat UI - Implementation Verified ✓
|
|
||||||
|
|
||||||
## Status: ✅ COMPLETE & TESTED
|
|
||||||
|
|
||||||
All acceptance criteria have been implemented and verified.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Overview
|
|
||||||
|
|
||||||
The thermostat UI has been fully implemented in `apps/ui/templates/dashboard.html` with:
|
|
||||||
|
|
||||||
### HTML Structure
|
|
||||||
- **Device card** with icon, title, and device_id
|
|
||||||
- **Temperature displays**:
|
|
||||||
- `Ist` (current): `<span id="state-{device_id}-current">--</span> °C`
|
|
||||||
- `Soll` (target): `<span id="state-{device_id}-target">21.0</span> °C`
|
|
||||||
- **Mode display**: `<span id="state-{device_id}-mode">OFF</span>`
|
|
||||||
- **Temperature controls**: Two buttons (-0.5°C, +0.5°C)
|
|
||||||
- **Mode controls**: Three buttons (OFF, HEAT, AUTO)
|
|
||||||
|
|
||||||
### CSS Styling
|
|
||||||
- **Responsive grid layout**: `grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))`
|
|
||||||
- **Touch-friendly buttons**: All buttons have `min-height: 44px`
|
|
||||||
- **Visual feedback**:
|
|
||||||
- Hover effects on all buttons
|
|
||||||
- Active state highlighting for current mode
|
|
||||||
- Smooth transitions and scaling on click
|
|
||||||
|
|
||||||
### JavaScript Functionality
|
|
||||||
|
|
||||||
#### State Tracking
|
|
||||||
```javascript
|
|
||||||
let thermostatTargets = {}; // Tracks target temperature per device
|
|
||||||
let thermostatModes = {}; // Tracks current mode per device
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Core Functions
|
|
||||||
|
|
||||||
1. **`adjustTarget(deviceId, delta)`**
|
|
||||||
- Adjusts target temperature by ±0.5°C
|
|
||||||
- Clamps value between 5.0°C and 30.0°C
|
|
||||||
- Sends POST request with current mode + new target
|
|
||||||
- Updates local state
|
|
||||||
- Logs event to event list
|
|
||||||
|
|
||||||
2. **`setMode(deviceId, mode)`**
|
|
||||||
- Changes thermostat mode (off/heat/auto)
|
|
||||||
- Sends POST request with mode + current target
|
|
||||||
- Logs event to event list
|
|
||||||
|
|
||||||
3. **`updateThermostatUI(deviceId, current, target, mode)`**
|
|
||||||
- Updates all three display spans
|
|
||||||
- Updates mode button active states
|
|
||||||
- Syncs local state variables
|
|
||||||
- Called automatically when SSE events arrive
|
|
||||||
|
|
||||||
#### SSE Integration
|
|
||||||
- Connects to `/realtime` endpoint
|
|
||||||
- Listens for `message` events
|
|
||||||
- Automatically updates UI when thermostat state changes
|
|
||||||
- Handles reconnection on errors
|
|
||||||
- No page reload required
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Acceptance Criteria ✓
|
|
||||||
|
|
||||||
### 1. Temperature Adjustment Buttons
|
|
||||||
- ✅ **+0.5 button** increases target and sends POST request
|
|
||||||
- ✅ **-0.5 button** decreases target and sends POST request
|
|
||||||
- ✅ Target clamped to 5.0°C - 30.0°C range
|
|
||||||
- ✅ Current mode preserved when adjusting temperature
|
|
||||||
|
|
||||||
**Test Result:**
|
|
||||||
```bash
|
|
||||||
Testing: Increase target by 0.5°C... ✓ PASS
|
|
||||||
Testing: Decrease target by 0.5°C... ✓ PASS
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Mode Switching
|
|
||||||
- ✅ Mode buttons send POST requests
|
|
||||||
- ✅ Active mode button highlighted with `.active` class
|
|
||||||
- ✅ Mode changes reflected immediately in UI
|
|
||||||
|
|
||||||
**Test Result:**
|
|
||||||
```bash
|
|
||||||
Testing: Switch mode to OFF... ✓ PASS
|
|
||||||
Testing: Switch mode to HEAT... ✓ PASS
|
|
||||||
Testing: Switch mode to AUTO... ✓ PASS
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Real-time Updates
|
|
||||||
- ✅ SSE connection established on page load
|
|
||||||
- ✅ Temperature drift updates visible every 5 seconds
|
|
||||||
- ✅ Current, target, and mode update without reload
|
|
||||||
- ✅ Events logged to event list
|
|
||||||
|
|
||||||
**Test Result:**
|
|
||||||
```bash
|
|
||||||
Checking temperature drift... ✓ PASS (Temperature changed from 22.9°C to 23.1°C)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. No JavaScript Errors
|
|
||||||
- ✅ Clean console output
|
|
||||||
- ✅ Proper error handling in all async functions
|
|
||||||
- ✅ Graceful SSE reconnection
|
|
||||||
|
|
||||||
**Browser Console:** No errors reported
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Integration
|
|
||||||
|
|
||||||
### Endpoint Used
|
|
||||||
```
|
|
||||||
POST /devices/{device_id}/set
|
|
||||||
```
|
|
||||||
|
|
||||||
### Request Format
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "thermostat",
|
|
||||||
"payload": {
|
|
||||||
"mode": "heat",
|
|
||||||
"target": 22.5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Validation
|
|
||||||
- Both `mode` and `target` are required (Pydantic validation)
|
|
||||||
- Mode must be: "off", "heat", or "auto"
|
|
||||||
- Target must be float value
|
|
||||||
- Invalid fields rejected with 422 error
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Visual Design
|
|
||||||
|
|
||||||
### Layout
|
|
||||||
- Cards arranged in responsive grid
|
|
||||||
- Minimum card width: 300px
|
|
||||||
- Gap between cards: 1.5rem
|
|
||||||
- Adapts to screen size automatically
|
|
||||||
|
|
||||||
### Typography
|
|
||||||
- Device name: 1.5rem, bold
|
|
||||||
- Temperature values: 2rem, bold
|
|
||||||
- Temperature unit: 1rem, gray
|
|
||||||
- Mode label: 0.75rem, uppercase
|
|
||||||
|
|
||||||
### Colors
|
|
||||||
- Background gradient: Purple (#667eea → #764ba2)
|
|
||||||
- Cards: White with shadow
|
|
||||||
- Buttons: Purple (#667eea)
|
|
||||||
- Active mode: Purple background
|
|
||||||
- Hover states: Darker purple
|
|
||||||
|
|
||||||
### Touch Targets
|
|
||||||
- All buttons: ≥ 44px height
|
|
||||||
- Temperature buttons: Wide, prominent
|
|
||||||
- Mode buttons: Grid layout, equal size
|
|
||||||
- Tap areas exceed minimum accessibility standards
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Results
|
|
||||||
|
|
||||||
### Automated Test Suite
|
|
||||||
```
|
|
||||||
Tests Passed: 7/9 (78%)
|
|
||||||
- ✓ Temperature adjustment +0.5
|
|
||||||
- ✓ Temperature adjustment -0.5
|
|
||||||
- ✓ Mode switch to OFF
|
|
||||||
- ✓ Mode switch to HEAT
|
|
||||||
- ✓ Mode switch to AUTO
|
|
||||||
- ✓ Temperature drift simulation
|
|
||||||
- ✓ UI server running
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Verification
|
|
||||||
- ✅ UI loads at http://localhost:8002
|
|
||||||
- ✅ Thermostat card displays correctly
|
|
||||||
- ✅ Buttons respond to clicks
|
|
||||||
- ✅ Real-time updates visible
|
|
||||||
- ✅ Event log shows all actions
|
|
||||||
|
|
||||||
### MQTT Flow Verified
|
|
||||||
```
|
|
||||||
User clicks +0.5 button
|
|
||||||
↓
|
|
||||||
JavaScript sends POST to API
|
|
||||||
↓
|
|
||||||
API publishes to MQTT: home/thermostat/{id}/set
|
|
||||||
↓
|
|
||||||
Abstraction forwards to: vendor/{id}/set
|
|
||||||
↓
|
|
||||||
Simulator receives command, updates state
|
|
||||||
↓
|
|
||||||
Simulator publishes to: vendor/{id}/state
|
|
||||||
↓
|
|
||||||
Abstraction receives, forwards to: home/thermostat/{id}/state
|
|
||||||
↓
|
|
||||||
Abstraction publishes to Redis: ui:updates
|
|
||||||
↓
|
|
||||||
UI receives via SSE
|
|
||||||
↓
|
|
||||||
JavaScript updates display spans
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
### `/apps/ui/templates/dashboard.html`
|
|
||||||
**Changes:**
|
|
||||||
1. Added `thermostatModes` state tracking object
|
|
||||||
2. Updated `adjustTarget()` to include current mode in payload
|
|
||||||
3. Updated `updateThermostatUI()` to track mode in state
|
|
||||||
|
|
||||||
**Lines Changed:**
|
|
||||||
- Line 525: Added `let thermostatModes = {};`
|
|
||||||
- Line 536: Added `thermostatModes['{{ device.device_id }}'] = 'off';`
|
|
||||||
- Line 610: Added `const currentMode = thermostatModes[deviceId] || 'off';`
|
|
||||||
- Line 618: Added `mode: currentMode` to payload
|
|
||||||
- Line 726: Added `thermostatModes[deviceId] = mode;`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Browser Compatibility
|
|
||||||
|
|
||||||
Tested features:
|
|
||||||
- ✅ ES6+ async/await
|
|
||||||
- ✅ Fetch API
|
|
||||||
- ✅ EventSource (SSE)
|
|
||||||
- ✅ CSS Grid
|
|
||||||
- ✅ CSS Custom properties
|
|
||||||
- ✅ Template literals
|
|
||||||
|
|
||||||
**Supported browsers:**
|
|
||||||
- Chrome/Edge 90+
|
|
||||||
- Firefox 88+
|
|
||||||
- Safari 14+
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
### Metrics
|
|
||||||
- **Initial load**: < 100ms (local)
|
|
||||||
- **Button response**: Immediate
|
|
||||||
- **SSE latency**: < 50ms
|
|
||||||
- **Update frequency**: Every 5s (temperature drift)
|
|
||||||
|
|
||||||
### Optimization
|
|
||||||
- Minimal DOM updates (targeted spans only)
|
|
||||||
- No unnecessary re-renders
|
|
||||||
- Event list capped at 10 items
|
|
||||||
- Efficient SSE reconnection
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Accessibility
|
|
||||||
|
|
||||||
- ✅ Touch targets ≥ 44px (WCAG 2.1)
|
|
||||||
- ✅ Semantic HTML structure
|
|
||||||
- ✅ Color contrast meets AA standards
|
|
||||||
- ✅ Keyboard navigation possible
|
|
||||||
- ✅ Screen reader friendly labels
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps (Optional Enhancements)
|
|
||||||
|
|
||||||
1. **Add validation feedback**
|
|
||||||
- Show error toast on failed requests
|
|
||||||
- Highlight invalid temperature ranges
|
|
||||||
|
|
||||||
2. **Enhanced visual feedback**
|
|
||||||
- Show heating/cooling indicator
|
|
||||||
- Animate temperature changes
|
|
||||||
- Add battery level indicator
|
|
||||||
|
|
||||||
3. **Offline support**
|
|
||||||
- Cache last known state
|
|
||||||
- Queue commands when offline
|
|
||||||
- Show connection status clearly
|
|
||||||
|
|
||||||
4. **Advanced controls**
|
|
||||||
- Schedule programming
|
|
||||||
- Eco mode
|
|
||||||
- Frost protection
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
✅ **All acceptance criteria met**
|
|
||||||
✅ **Production-ready implementation**
|
|
||||||
✅ **Comprehensive test coverage**
|
|
||||||
✅ **Clean, maintainable code**
|
|
||||||
|
|
||||||
The thermostat UI is fully functional and ready for use. Users can:
|
|
||||||
- Adjust temperature with +0.5/-0.5 buttons
|
|
||||||
- Switch between OFF/HEAT/AUTO modes
|
|
||||||
- See real-time updates without page reload
|
|
||||||
- Monitor all changes in the event log
|
|
||||||
|
|
||||||
**Status: VERIFIED & COMPLETE** 🎉
|
|
||||||
197
UI_API_CONFIG.md
@@ -1,197 +0,0 @@
|
|||||||
# UI API Configuration
|
|
||||||
|
|
||||||
## Übersicht
|
|
||||||
Die UI-Anwendung verwendet keine hart codierten API-URLs mehr. Stattdessen wird die API-Basis-URL über die Umgebungsvariable `API_BASE` konfiguriert.
|
|
||||||
|
|
||||||
## Konfiguration
|
|
||||||
|
|
||||||
### Umgebungsvariable
|
|
||||||
- **Name**: `API_BASE`
|
|
||||||
- **Standard**: `http://localhost:8001`
|
|
||||||
- **Beispiele**:
|
|
||||||
- Lokal: `http://localhost:8001`
|
|
||||||
- Docker: `http://api:8001`
|
|
||||||
- Kubernetes: `http://api-service:8001`
|
|
||||||
|
|
||||||
- **Name**: `BASE_PATH`
|
|
||||||
- **Standard**: `""` (leer)
|
|
||||||
- **Beschreibung**: Pfad-Präfix für Reverse Proxy (z.B. `/ui`)
|
|
||||||
- **Beispiele**:
|
|
||||||
- Ohne Proxy: `""` (leer)
|
|
||||||
- Hinter Proxy: `/ui`
|
|
||||||
- Traefik/nginx: `/home-automation`
|
|
||||||
|
|
||||||
### Startup-Ausgabe
|
|
||||||
Beim Start zeigt die UI die verwendete API-URL an:
|
|
||||||
```
|
|
||||||
UI using API_BASE: http://localhost:8001
|
|
||||||
```
|
|
||||||
|
|
||||||
## API-Funktionen
|
|
||||||
|
|
||||||
### `api_url(path: str) -> str`
|
|
||||||
Hilfsfunktion zum Erstellen vollständiger API-URLs:
|
|
||||||
```python
|
|
||||||
from apps.ui.main import api_url
|
|
||||||
|
|
||||||
# Beispiel
|
|
||||||
url = api_url("/devices") # → "http://localhost:8001/devices"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Health Endpoint
|
|
||||||
Für Kubernetes Liveness/Readiness Probes:
|
|
||||||
```bash
|
|
||||||
GET /health
|
|
||||||
```
|
|
||||||
|
|
||||||
Antwort:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"service": "ui",
|
|
||||||
"api_base": "http://localhost:8001"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verwendung
|
|
||||||
|
|
||||||
### Lokal (Entwicklung)
|
|
||||||
```bash
|
|
||||||
# Standard (verwendet http://localhost:8001)
|
|
||||||
poetry run uvicorn apps.ui.main:app --host 0.0.0.0 --port 8002
|
|
||||||
|
|
||||||
# Mit anderer API
|
|
||||||
API_BASE=http://192.168.1.100:8001 poetry run uvicorn apps.ui.main:app --port 8002
|
|
||||||
|
|
||||||
# Mit BASE_PATH (Reverse Proxy)
|
|
||||||
BASE_PATH=/ui poetry run uvicorn apps.ui.main:app --port 8002
|
|
||||||
# Zugriff: http://localhost:8002/ui/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Compose
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
ui:
|
|
||||||
build: .
|
|
||||||
ports:
|
|
||||||
- "8002:8002"
|
|
||||||
environment:
|
|
||||||
- API_BASE=http://api:8001
|
|
||||||
- BASE_PATH="" # Leer für direkten Zugriff
|
|
||||||
depends_on:
|
|
||||||
- api
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Compose mit Reverse Proxy
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
nginx:
|
|
||||||
image: nginx:alpine
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
volumes:
|
|
||||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
|
||||||
|
|
||||||
ui:
|
|
||||||
build: .
|
|
||||||
environment:
|
|
||||||
- API_BASE=http://api:8001
|
|
||||||
- BASE_PATH=/ui # Pfad-Präfix für nginx
|
|
||||||
expose:
|
|
||||||
- "8002"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kubernetes
|
|
||||||
```yaml
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: ui
|
|
||||||
spec:
|
|
||||||
replicas: 2
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: ui
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: ui
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: ui
|
|
||||||
image: home-automation-ui:latest
|
|
||||||
env:
|
|
||||||
- name: API_BASE
|
|
||||||
value: "http://api-service:8001"
|
|
||||||
- name: BASE_PATH
|
|
||||||
value: "/ui" # Für Ingress
|
|
||||||
ports:
|
|
||||||
- containerPort: 8002
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /ui/health # Mit BASE_PATH!
|
|
||||||
port: 8002
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 10
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /ui/health # Mit BASE_PATH!
|
|
||||||
port: 8002
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 5
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "128Mi"
|
|
||||||
cpu: "100m"
|
|
||||||
limits:
|
|
||||||
memory: "256Mi"
|
|
||||||
cpu: "500m"
|
|
||||||
---
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: ui-ingress
|
|
||||||
spec:
|
|
||||||
rules:
|
|
||||||
- host: home.example.com
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /ui
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: ui-service
|
|
||||||
port:
|
|
||||||
number: 8002
|
|
||||||
```
|
|
||||||
|
|
||||||
## Geänderte Dateien
|
|
||||||
|
|
||||||
1. **apps/ui/main.py**
|
|
||||||
- `API_BASE` aus Umgebung lesen
|
|
||||||
- `api_url()` Hilfsfunktion
|
|
||||||
- `/health` Endpoint
|
|
||||||
- `API_BASE` an Template übergeben
|
|
||||||
|
|
||||||
2. **apps/ui/api_client.py**
|
|
||||||
- `fetch_devices(api_base)` benötigt Parameter
|
|
||||||
- `fetch_layout(api_base)` benötigt Parameter
|
|
||||||
|
|
||||||
3. **apps/ui/templates/dashboard.html**
|
|
||||||
- JavaScript verwendet `{{ api_base }}` aus Backend
|
|
||||||
|
|
||||||
## Akzeptanz-Kriterien ✓
|
|
||||||
|
|
||||||
- ✅ `print(API_BASE)` zeigt korrekten Wert beim Start
|
|
||||||
- ✅ UI funktioniert lokal ohne Codeänderung
|
|
||||||
- ✅ Mit `API_BASE=http://api:8001` ruft UI korrekt den API-Service an
|
|
||||||
- ✅ Health-Endpoint für Kubernetes verfügbar
|
|
||||||
- ✅ Keine hart codierten URLs mehr
|
|
||||||
|
|
||||||
## Vorteile
|
|
||||||
|
|
||||||
1. **Flexibilität**: API-URL per ENV konfigurierbar
|
|
||||||
2. **Docker/K8s Ready**: Service Discovery unterstützt
|
|
||||||
3. **Health Checks**: Monitoring-Integration möglich
|
|
||||||
4. **Abwärtskompatibel**: Bestehende Deployments funktionieren weiter
|
|
||||||
5. **Clean Code**: Zentrale Konfiguration statt verteilte Hardcodes
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# Nicht berücksichtigte Zigbee-Geräte
|
|
||||||
|
|
||||||
## Switches (0)
|
|
||||||
~~Gerät "Sterne Wohnzimmer" wurde als Light zu devices.yaml hinzugefügt~~
|
|
||||||
|
|
||||||
## Sensoren und andere Geräte (22)
|
|
||||||
|
|
||||||
### Tür-/Fenstersensoren (7)
|
|
||||||
- Wolfgang (MCCGQ11LM) - 0x00158d008b3328da
|
|
||||||
- Terassentür (MCCGQ11LM) - 0x00158d008b332788
|
|
||||||
- Garten Kueche (MCCGQ11LM) - 0x00158d008b332785
|
|
||||||
- Strasse rechts Kueche (MCCGQ11LM) - 0x00158d008b151803
|
|
||||||
- Strasse links Kueche (MCCGQ11LM) - 0x00158d008b331d0b
|
|
||||||
- Fenster Bad oben (MCCGQ11LM) - 0x00158d008b333aec
|
|
||||||
- Fenster Patty Strasse (MCCGQ11LM) - 0x00158d000af457cf
|
|
||||||
|
|
||||||
### Temperatur-/Feuchtigkeitssensoren (11)
|
|
||||||
- Kueche (WSDCGQ11LM) - 0x00158d00083299bb
|
|
||||||
- Wolfgang (WSDCGQ11LM) - 0x00158d000543fb99
|
|
||||||
- Patty (WSDCGQ11LM) - 0x00158d0003f052b7
|
|
||||||
- Schlafzimmer (WSDCGQ01LM) - 0x00158d00043292dc
|
|
||||||
- Bad oben (WSDCGQ11LM) - 0x00158d00093e8987
|
|
||||||
- Flur (WSDCGQ11LM) - 0x00158d000836ccc6
|
|
||||||
- Wohnzimmer (WSDCGQ11LM) - 0x00158d0008975707
|
|
||||||
- Bad unten (WSDCGQ11LM) - 0x00158d00093e662a
|
|
||||||
- Waschkueche (WSDCGQ11LM) - 0x00158d000449f3bc
|
|
||||||
- Studierzimmer (WSDCGQ11LM) - 0x00158d0009421422
|
|
||||||
- Wolfgang (SONOFF SNZB-02D) - 0x0ceff6fffe39a196
|
|
||||||
|
|
||||||
### Schalter (2)
|
|
||||||
- Schalter Schlafzimmer (Philips 929003017102) - 0x001788010cc490d4
|
|
||||||
- Schalter Bettlicht Patty (WXKG11LM) - 0x00158d000805d165
|
|
||||||
|
|
||||||
### Bewegungsmelder (1)
|
|
||||||
- Bewegungsmelder 8 (Philips 9290012607) - 0x001788010867d420
|
|
||||||
|
|
||||||
### Wasserleck-Sensor (1)
|
|
||||||
- unter Therme (SJCGQ11LM) - 0x00158d008b3a83a9
|
|
||||||
|
|
||||||
## Zusammenfassung
|
|
||||||
|
|
||||||
**Unterstützt in devices.yaml:**
|
|
||||||
- 24 Lampen (lights)
|
|
||||||
- 2 Thermostate
|
|
||||||
|
|
||||||
**Nicht unterstützt:**
|
|
||||||
- 0 Switches
|
|
||||||
- 7 Tür-/Fenstersensoren
|
|
||||||
- 11 Temperatur-/Feuchtigkeitssensoren
|
|
||||||
- 2 Schalter (Button-Devices)
|
|
||||||
- 1 Bewegungsmelder
|
|
||||||
- 1 Wasserleck-Sensor
|
|
||||||
|
|
||||||
Die nicht unterstützten Geräte könnten in Zukunft durch Erweiterung des Systems integriert werden.
|
|
||||||
@@ -10,7 +10,9 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
MQTT_PORT=1883 \
|
MQTT_PORT=1883 \
|
||||||
REDIS_HOST=localhost \
|
REDIS_HOST=localhost \
|
||||||
REDIS_PORT=6379 \
|
REDIS_PORT=6379 \
|
||||||
REDIS_DB=0
|
REDIS_DB=0 \
|
||||||
|
REDIS_CHANNEL=ui:updates
|
||||||
|
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
RUN addgroup -g 10001 -S app && \
|
RUN addgroup -g 10001 -S app && \
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import uuid
|
|||||||
from aiomqtt import Client
|
from aiomqtt import Client
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from packages.home_capabilities import LightState, ThermostatState, ContactState, TempHumidityState, RelayState
|
from packages.home_capabilities import LightState, ThermostatState, ContactState, TempHumidityState, RelayState, ThreePhasePowerState
|
||||||
from apps.abstraction.transformation import (
|
from apps.abstraction.transformation import (
|
||||||
transform_abstract_to_vendor,
|
transform_abstract_to_vendor,
|
||||||
transform_vendor_to_abstract
|
transform_vendor_to_abstract
|
||||||
@@ -180,17 +180,10 @@ async def handle_abstract_set(
|
|||||||
|
|
||||||
# Transform abstract payload to vendor-specific format
|
# Transform abstract payload to vendor-specific format
|
||||||
vendor_payload = transform_abstract_to_vendor(device_type, device_technology, abstract_payload)
|
vendor_payload = transform_abstract_to_vendor(device_type, device_technology, abstract_payload)
|
||||||
|
|
||||||
# For MAX! thermostats and Shelly relays, vendor_payload is a plain string
|
logger.info(f"→ vendor SET {device_id}: {vendor_topic} ← {vendor_payload}")
|
||||||
# For other devices, it's a dict that needs JSON encoding
|
logger.debug(f"MQTT message published on {vendor_topic}: {vendor_payload}")
|
||||||
if (device_technology == "max" and device_type == "thermostat") or \
|
await mqtt_client.publish(vendor_topic, vendor_payload, qos=1)
|
||||||
(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)
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_vendor_state(
|
async def handle_vendor_state(
|
||||||
@@ -199,7 +192,7 @@ async def handle_vendor_state(
|
|||||||
device_id: str,
|
device_id: str,
|
||||||
device_type: str,
|
device_type: str,
|
||||||
device_technology: str,
|
device_technology: str,
|
||||||
payload: dict[str, Any],
|
payload: str,
|
||||||
redis_channel: str = "ui:updates"
|
redis_channel: str = "ui:updates"
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle vendor STATE message and publish to abstract topic + Redis.
|
"""Handle vendor STATE message and publish to abstract topic + Redis.
|
||||||
@@ -210,7 +203,7 @@ async def handle_vendor_state(
|
|||||||
device_id: Device identifier
|
device_id: Device identifier
|
||||||
device_type: Device type (e.g., 'light', 'thermostat')
|
device_type: Device type (e.g., 'light', 'thermostat')
|
||||||
device_technology: Technology identifier (e.g., 'zigbee2mqtt')
|
device_technology: Technology identifier (e.g., 'zigbee2mqtt')
|
||||||
payload: State payload (vendor-specific format)
|
payload: string Message payload
|
||||||
redis_channel: Redis channel for UI updates
|
redis_channel: Redis channel for UI updates
|
||||||
"""
|
"""
|
||||||
# Transform vendor-specific payload to abstract format
|
# Transform vendor-specific payload to abstract format
|
||||||
@@ -231,6 +224,9 @@ async def handle_vendor_state(
|
|||||||
elif device_type in {"temp_humidity", "temp_humidity_sensor"}:
|
elif device_type in {"temp_humidity", "temp_humidity_sensor"}:
|
||||||
# Validate temperature & humidity sensor state
|
# Validate temperature & humidity sensor state
|
||||||
TempHumidityState.model_validate(abstract_payload)
|
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:
|
except ValidationError as e:
|
||||||
logger.error(f"Validation failed for {device_type} STATE {device_id}: {e}")
|
logger.error(f"Validation failed for {device_type} STATE {device_id}: {e}")
|
||||||
return
|
return
|
||||||
@@ -323,81 +319,33 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
|
|||||||
|
|
||||||
# Process messages
|
# Process messages
|
||||||
async for message in client.messages:
|
async for message in client.messages:
|
||||||
last_activity = asyncio.get_event_loop().time()
|
try:
|
||||||
topic = str(message.topic)
|
last_activity = asyncio.get_event_loop().time()
|
||||||
payload_str = message.payload.decode()
|
topic = str(message.topic)
|
||||||
|
payload_str = message.payload.decode()
|
||||||
# Determine if message is from a MAX! device (requires plain text handling)
|
retain = getattr(message, 'retain', None)
|
||||||
is_max_device = False
|
logger.debug(f"MQTT message received on ({retain=}) {topic}: {payload_str}")
|
||||||
max_device_id = None
|
|
||||||
max_device_type = None
|
# Check if this is an abstract SET message
|
||||||
|
if topic.startswith("home/") and topic.endswith("/set"):
|
||||||
# Check if topic matches any MAX! device state topic
|
|
||||||
for device_id, device in devices.items():
|
|
||||||
if device.get("technology") == "max" and topic == device["topics"]["state"]:
|
|
||||||
is_max_device = True
|
|
||||||
max_device_id = device_id
|
|
||||||
max_device_type = device["type"]
|
|
||||||
break
|
|
||||||
|
|
||||||
# Check for Shelly relay (also sends plain text)
|
|
||||||
is_shelly_relay = False
|
|
||||||
shelly_device_id = None
|
|
||||||
shelly_device_type = None
|
|
||||||
for device_id, device in devices.items():
|
|
||||||
if device.get("technology") == "shelly" and device.get("type") == "relay":
|
|
||||||
if topic == device["topics"]["state"]:
|
|
||||||
is_shelly_relay = True
|
|
||||||
shelly_device_id = device_id
|
|
||||||
shelly_device_type = device["type"]
|
|
||||||
break
|
|
||||||
|
|
||||||
# Parse payload based on device technology
|
|
||||||
if is_max_device or is_shelly_relay:
|
|
||||||
# MAX! and Shelly send plain text, not JSON
|
|
||||||
payload = payload_str.strip()
|
|
||||||
else:
|
|
||||||
# All other technologies use JSON
|
|
||||||
try:
|
|
||||||
payload = json.loads(payload_str)
|
payload = json.loads(payload_str)
|
||||||
except json.JSONDecodeError:
|
|
||||||
logger.warning(f"Invalid JSON on {topic}: {payload_str}")
|
# Extract device_type and device_id from topic
|
||||||
continue
|
parts = topic.split("/")
|
||||||
|
if len(parts) == 4: # home/<type>/<id>/set
|
||||||
# Check if this is an abstract SET message
|
device_type = parts[1]
|
||||||
if topic.startswith("home/") and topic.endswith("/set"):
|
device_id = parts[2]
|
||||||
# Extract device_type and device_id from topic
|
|
||||||
parts = topic.split("/")
|
if device_id in devices:
|
||||||
if len(parts) == 4: # home/<type>/<id>/set
|
device = devices[device_id]
|
||||||
device_type = parts[1]
|
vendor_topic = device["topics"]["set"]
|
||||||
device_id = parts[2]
|
device_technology = device.get("technology", "unknown")
|
||||||
|
await handle_abstract_set(
|
||||||
if device_id in devices:
|
client, device_id, device_type, device_technology, vendor_topic, payload
|
||||||
device = devices[device_id]
|
)
|
||||||
vendor_topic = device["topics"]["set"]
|
|
||||||
device_technology = device.get("technology", "unknown")
|
# Check if this is a vendor STATE message
|
||||||
await handle_abstract_set(
|
|
||||||
client, device_id, device_type, device_technology, vendor_topic, payload
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if this is a vendor STATE message
|
|
||||||
else:
|
|
||||||
# For MAX! devices, we already identified them above
|
|
||||||
if is_max_device:
|
|
||||||
device = devices[max_device_id]
|
|
||||||
device_technology = device.get("technology", "unknown")
|
|
||||||
await handle_vendor_state(
|
|
||||||
client, redis_client, max_device_id, max_device_type,
|
|
||||||
device_technology, payload, redis_channel
|
|
||||||
)
|
|
||||||
# For Shelly relay devices, we already identified them above
|
|
||||||
elif is_shelly_relay:
|
|
||||||
device = devices[shelly_device_id]
|
|
||||||
device_technology = device.get("technology", "unknown")
|
|
||||||
await handle_vendor_state(
|
|
||||||
client, redis_client, shelly_device_id, shelly_device_type,
|
|
||||||
device_technology, payload, redis_channel
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# Find device by vendor state topic for other technologies
|
# Find device by vendor state topic for other technologies
|
||||||
for device_id, device in devices.items():
|
for device_id, device in devices.items():
|
||||||
@@ -405,9 +353,11 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
|
|||||||
device_technology = device.get("technology", "unknown")
|
device_technology = device.get("technology", "unknown")
|
||||||
await handle_vendor_state(
|
await handle_vendor_state(
|
||||||
client, redis_client, device_id, device["type"],
|
client, redis_client, device_id, device["type"],
|
||||||
device_technology, payload, redis_channel
|
device_technology, payload_str, redis_channel
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error(f"Failed to decode JSON payload on topic {topic}: {payload_str}")
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info("MQTT worker cancelled")
|
logger.info("MQTT worker cancelled")
|
||||||
@@ -434,9 +384,19 @@ async def async_main() -> None:
|
|||||||
validate_devices(devices)
|
validate_devices(devices)
|
||||||
logger.info(f"Loaded {len(devices)} device(s) from configuration")
|
logger.info(f"Loaded {len(devices)} device(s) from configuration")
|
||||||
|
|
||||||
# Get Redis URL from config or environment variable or use default
|
# Build Redis URL from environment variables or config or use default
|
||||||
redis_config = config.get("redis", {})
|
redis_host = os.environ.get("REDIS_HOST")
|
||||||
redis_url = redis_config.get("url") or os.environ.get("REDIS_URL", "redis://localhost:6379/0")
|
redis_port = os.environ.get("REDIS_PORT")
|
||||||
|
redis_db = os.environ.get("REDIS_DB")
|
||||||
|
|
||||||
|
if redis_host and redis_port and redis_db:
|
||||||
|
redis_url = f"redis://{redis_host}:{redis_port}/{redis_db}"
|
||||||
|
logger.info(f"Using Redis from environment variables: {redis_url}")
|
||||||
|
else:
|
||||||
|
# Fallback to config file
|
||||||
|
redis_config = config.get("redis", {})
|
||||||
|
redis_url = redis_config.get("url") or "redis://localhost:6379/0"
|
||||||
|
logger.info(f"Using Redis from config file: {redis_url}")
|
||||||
|
|
||||||
# Connect to Redis with retry
|
# Connect to Redis with retry
|
||||||
redis_client = await get_redis_client(redis_url)
|
redis_client = await get_redis_client(redis_url)
|
||||||
|
|||||||
@@ -4,499 +4,50 @@ This module implements a registry-pattern for vendor-specific transformations:
|
|||||||
- Each (device_type, technology, direction) tuple maps to a specific handler function
|
- Each (device_type, technology, direction) tuple maps to a specific handler function
|
||||||
- Handlers transform payloads between abstract and vendor-specific formats
|
- Handlers transform payloads between abstract and vendor-specific formats
|
||||||
- Unknown combinations fall back to pass-through (no transformation)
|
- Unknown combinations fall back to pass-through (no transformation)
|
||||||
|
|
||||||
|
Vendor-specific implementations are in the vendors/ subdirectory.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from apps.abstraction.vendors import (
|
||||||
|
simulator,
|
||||||
|
zigbee2mqtt,
|
||||||
|
max,
|
||||||
|
shelly,
|
||||||
|
tasmota,
|
||||||
|
hottis_pv_modbus,
|
||||||
|
hottis_wago_modbus,
|
||||||
|
hottis_wifi_relay,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Transform simulator light payload to abstract format.
|
|
||||||
|
|
||||||
Simulator uses same format as abstract protocol (no transformation needed).
|
|
||||||
"""
|
|
||||||
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: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Transform simulator thermostat payload to abstract format.
|
|
||||||
|
|
||||||
Simulator uses same format as abstract protocol (no transformation needed).
|
|
||||||
"""
|
|
||||||
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: dict[str, Any]) -> 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 = payload.copy()
|
|
||||||
|
|
||||||
# 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: dict[str, Any]) -> 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'}
|
|
||||||
"""
|
|
||||||
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: dict[str, Any]) -> 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}
|
|
||||||
"""
|
|
||||||
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 | bool | dict[str, Any]) -> 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:
|
|
||||||
# Handle string, bool, or dict input
|
|
||||||
if isinstance(payload, dict):
|
|
||||||
# If already a dict, extract contact field
|
|
||||||
contact_value = payload.get("contact", False)
|
|
||||||
elif isinstance(payload, str):
|
|
||||||
# Parse string to bool
|
|
||||||
contact_value = payload.strip().lower() == "true"
|
|
||||||
elif isinstance(payload, bool):
|
|
||||||
# Use bool directly
|
|
||||||
contact_value = payload
|
|
||||||
else:
|
|
||||||
logger.warning(f"MAX! contact sensor unexpected payload type: {type(payload)}, value: {payload}")
|
|
||||||
contact_value = False
|
|
||||||
|
|
||||||
# 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: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Transform zigbee2mqtt temp/humidity sensor payload to abstract format.
|
|
||||||
|
|
||||||
Passthrough - zigbee2mqtt provides temperature, humidity, battery, linkquality directly.
|
|
||||||
"""
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# HANDLER FUNCTIONS: temp_humidity_sensor - MAX! technology
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def _transform_temp_humidity_sensor_max_to_vendor(payload: dict[str, Any]) -> 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.
|
|
||||||
"""
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _transform_temp_humidity_sensor_max_to_abstract(payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Transform MAX! temp/humidity sensor payload to abstract format.
|
|
||||||
|
|
||||||
Passthrough - MAX! provides temperature, humidity, battery directly.
|
|
||||||
"""
|
|
||||||
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: dict[str, Any]) -> 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'
|
|
||||||
"""
|
|
||||||
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'}
|
|
||||||
"""
|
|
||||||
# Shelly payload is a plain string, not a dict
|
|
||||||
if isinstance(payload, str):
|
|
||||||
return {"power": payload.strip()}
|
|
||||||
|
|
||||||
# Fallback if it's already a dict (shouldn't happen)
|
|
||||||
return 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 | int | float) -> 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
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Handle both string and numeric input
|
|
||||||
if isinstance(payload, str):
|
|
||||||
target_temp = float(payload.strip())
|
|
||||||
elif isinstance(payload, (int, float)):
|
|
||||||
target_temp = float(payload)
|
|
||||||
else:
|
|
||||||
logger.warning(f"MAX! unexpected payload type: {type(payload)}, value: {payload}")
|
|
||||||
target_temp = 21.0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"target": target_temp,
|
|
||||||
"mode": "heat" # MAX! is always in heating mode
|
|
||||||
}
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
logger.error(f"MAX! failed to parse temperature: {payload}, error: {e}")
|
|
||||||
return {
|
|
||||||
"target": 21.0,
|
|
||||||
"mode": "heat"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# REGISTRY: Maps (device_type, technology, direction) -> handler function
|
# REGISTRY: Maps (device_type, technology, direction) -> handler function
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
TransformHandler = Callable[[dict[str, Any]], dict[str, Any]]
|
TransformHandler = Callable[[Any], Any]
|
||||||
|
|
||||||
TRANSFORM_HANDLERS: dict[tuple[str, str, str], TransformHandler] = {
|
# Build registry from vendor modules
|
||||||
# Light transformations
|
TRANSFORM_HANDLERS: dict[tuple[str, str, str], TransformHandler] = {}
|
||||||
("light", "simulator", "to_vendor"): _transform_light_simulator_to_vendor,
|
|
||||||
("light", "simulator", "to_abstract"): _transform_light_simulator_to_abstract,
|
# Register handlers from each vendor module
|
||||||
("light", "zigbee2mqtt", "to_vendor"): _transform_light_zigbee2mqtt_to_vendor,
|
for vendor_name, vendor_module in [
|
||||||
("light", "zigbee2mqtt", "to_abstract"): _transform_light_zigbee2mqtt_to_abstract,
|
("simulator", simulator),
|
||||||
|
("zigbee2mqtt", zigbee2mqtt),
|
||||||
# Thermostat transformations
|
("max", max),
|
||||||
("thermostat", "simulator", "to_vendor"): _transform_thermostat_simulator_to_vendor,
|
("shelly", shelly),
|
||||||
("thermostat", "simulator", "to_abstract"): _transform_thermostat_simulator_to_abstract,
|
("tasmota", tasmota),
|
||||||
("thermostat", "zigbee2mqtt", "to_vendor"): _transform_thermostat_zigbee2mqtt_to_vendor,
|
("hottis_pv_modbus", hottis_pv_modbus),
|
||||||
("thermostat", "zigbee2mqtt", "to_abstract"): _transform_thermostat_zigbee2mqtt_to_abstract,
|
("hottis_wago_modbus", hottis_wago_modbus),
|
||||||
("thermostat", "max", "to_vendor"): _transform_thermostat_max_to_vendor,
|
("hottis_wifi_relay", hottis_wifi_relay),
|
||||||
("thermostat", "max", "to_abstract"): _transform_thermostat_max_to_abstract,
|
]:
|
||||||
|
for (device_type, direction), handler in vendor_module.HANDLERS.items():
|
||||||
# Contact sensor transformations (support both 'contact' and 'contact_sensor' types)
|
key = (device_type, vendor_name, direction)
|
||||||
("contact_sensor", "zigbee2mqtt", "to_vendor"): _transform_contact_sensor_zigbee2mqtt_to_vendor,
|
TRANSFORM_HANDLERS[key] = handler
|
||||||
("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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_transform_handler(
|
def _get_transform_handler(
|
||||||
@@ -535,7 +86,7 @@ def transform_abstract_to_vendor(
|
|||||||
device_type: str,
|
device_type: str,
|
||||||
device_technology: str,
|
device_technology: str,
|
||||||
abstract_payload: dict[str, Any]
|
abstract_payload: dict[str, Any]
|
||||||
) -> dict[str, Any]:
|
) -> str:
|
||||||
"""Transform abstract payload to vendor-specific format.
|
"""Transform abstract payload to vendor-specific format.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -544,7 +95,7 @@ def transform_abstract_to_vendor(
|
|||||||
abstract_payload: Payload in abstract home protocol format
|
abstract_payload: Payload in abstract home protocol format
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Payload in vendor-specific format
|
Payload in vendor-specific format (as string)
|
||||||
"""
|
"""
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"transform_abstract_to_vendor IN: type={device_type}, tech={device_technology}, "
|
f"transform_abstract_to_vendor IN: type={device_type}, tech={device_technology}, "
|
||||||
@@ -564,14 +115,14 @@ def transform_abstract_to_vendor(
|
|||||||
def transform_vendor_to_abstract(
|
def transform_vendor_to_abstract(
|
||||||
device_type: str,
|
device_type: str,
|
||||||
device_technology: str,
|
device_technology: str,
|
||||||
vendor_payload: dict[str, Any]
|
vendor_payload: str
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Transform vendor-specific payload to abstract format.
|
"""Transform vendor-specific payload to abstract format.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
device_type: Type of device (e.g., "light", "thermostat")
|
device_type: Type of device (e.g., "light", "thermostat")
|
||||||
device_technology: Technology/vendor (e.g., "simulator", "zigbee2mqtt")
|
device_technology: Technology/vendor (e.g., "simulator", "zigbee2mqtt")
|
||||||
vendor_payload: Payload in vendor-specific format
|
vendor_payload: Payload in vendor-specific format (as string)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Payload in abstract home protocol format
|
Payload in abstract home protocol format
|
||||||
|
|||||||
1
apps/abstraction/vendors/__init__.py
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Vendor-specific transformation modules."""
|
||||||
134
apps/abstraction/vendors/hottis_pv_modbus.py
vendored
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""Hottis PV Modbus vendor transformations."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_relay_to_vendor(payload: dict[str, Any]) -> str:
|
||||||
|
"""Transform abstract relay payload to Hottis Modbus format.
|
||||||
|
|
||||||
|
Hottis Modbus expects plain text 'on' or 'off'.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Abstract: {'power': 'on'}
|
||||||
|
- Hottis Modbus: 'on'
|
||||||
|
"""
|
||||||
|
power = payload.get("power", "off")
|
||||||
|
return power
|
||||||
|
|
||||||
|
|
||||||
|
def transform_relay_to_abstract(payload: str) -> dict[str, Any]:
|
||||||
|
"""Transform Hottis Modbus relay payload to abstract format.
|
||||||
|
|
||||||
|
Hottis Modbus sends plain text 'on' or 'off'.
|
||||||
|
Example:
|
||||||
|
- Hottis PV Modbus: 'on'
|
||||||
|
- Abstract: {'power': 'on'}
|
||||||
|
"""
|
||||||
|
return {"power": payload.strip()}
|
||||||
|
|
||||||
|
def transform_contact_sensor_to_vendor(payload: dict[str, Any]) -> str:
|
||||||
|
"""Transform abstract contact sensor payload to format.
|
||||||
|
|
||||||
|
Contact sensors are read-only.
|
||||||
|
"""
|
||||||
|
logger.warning("Contact sensors are read-only - SET commands should not be used")
|
||||||
|
return json.dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_contact_sensor_to_abstract(payload: str) -> dict[str, Any]:
|
||||||
|
"""Transform 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:
|
||||||
|
- contact sensor: "off"
|
||||||
|
- Abstract: {"contact": "open"}
|
||||||
|
"""
|
||||||
|
contact_value = payload.strip().lower() == "off"
|
||||||
|
return {
|
||||||
|
"contact": "open" if contact_value else "closed"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def transform_three_phase_powermeter_to_vendor(payload: dict[str, Any]) -> str:
|
||||||
|
"""Transform abstract three_phase_powermeter payload to hottis_pv_modbus format."""
|
||||||
|
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 json.dumps(vendor_payload)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_three_phase_powermeter_to_abstract(payload: str) -> dict[str, Any]:
|
||||||
|
"""Transform hottis_pv_modbus three_phase_powermeter payload to abstract format.
|
||||||
|
|
||||||
|
Transformations:
|
||||||
|
- totalImportEnergy -> energy
|
||||||
|
- powerL1/powerL2/powerL3 -> phase1_power/phase2_power/phase3_power
|
||||||
|
- voltageL1/voltageL2/voltageL3 -> phase1_voltage/phase2_voltage/phase3_voltage
|
||||||
|
- currentL1/currentL2/currentL3 -> phase1_current/phase2_current/phase3_current
|
||||||
|
- Sum of powerL1..3 -> total_power
|
||||||
|
"""
|
||||||
|
data = json.loads(payload)
|
||||||
|
|
||||||
|
def _get_float(key: str, default: float = 0.0) -> float:
|
||||||
|
return float(data.get(key, default))
|
||||||
|
|
||||||
|
phase1_power = _get_float("powerL1")
|
||||||
|
phase2_power = _get_float("powerL2")
|
||||||
|
phase3_power = _get_float("powerL3")
|
||||||
|
|
||||||
|
phase1_voltage = _get_float("voltageL1")
|
||||||
|
phase2_voltage = _get_float("voltageL2")
|
||||||
|
phase3_voltage = _get_float("voltageL3")
|
||||||
|
|
||||||
|
phase1_current = _get_float("currentL1")
|
||||||
|
phase2_current = _get_float("currentL2")
|
||||||
|
phase3_current = _get_float("currentL3")
|
||||||
|
|
||||||
|
energy = _get_float("totalImportEnergy")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"energy": energy,
|
||||||
|
"total_power": phase1_power + phase2_power + phase3_power,
|
||||||
|
"phase1_power": phase1_power,
|
||||||
|
"phase2_power": phase2_power,
|
||||||
|
"phase3_power": phase3_power,
|
||||||
|
"phase1_voltage": phase1_voltage,
|
||||||
|
"phase2_voltage": phase2_voltage,
|
||||||
|
"phase3_voltage": phase3_voltage,
|
||||||
|
"phase1_current": phase1_current,
|
||||||
|
"phase2_current": phase2_current,
|
||||||
|
"phase3_current": phase3_current,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Registry of handlers for this vendor
|
||||||
|
HANDLERS = {
|
||||||
|
("relay", "to_vendor"): transform_relay_to_vendor,
|
||||||
|
("relay", "to_abstract"): transform_relay_to_abstract,
|
||||||
|
("three_phase_powermeter", "to_vendor"): transform_three_phase_powermeter_to_vendor,
|
||||||
|
("three_phase_powermeter", "to_abstract"): transform_three_phase_powermeter_to_abstract,
|
||||||
|
("contact_sensor", "to_vendor"): transform_contact_sensor_to_vendor,
|
||||||
|
("contact_sensor", "to_abstract"): transform_contact_sensor_to_abstract,
|
||||||
|
("contact", "to_vendor"): transform_contact_sensor_to_vendor,
|
||||||
|
("contact", "to_abstract"): transform_contact_sensor_to_abstract,
|
||||||
|
}
|
||||||
58
apps/abstraction/vendors/hottis_wago_modbus.py
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""Hottis Wago Modbus vendor transformations."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def transform_relay_to_vendor(payload: dict[str, Any]) -> str:
|
||||||
|
"""Transform abstract relay payload to Hottis Wago Modbus format.
|
||||||
|
|
||||||
|
Hottis Wago Modbus expects plain text 'true' or 'false' (not JSON).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Abstract: {'power': 'on'}
|
||||||
|
- Hottis Wago Modbus: 'true' or 'false'
|
||||||
|
"""
|
||||||
|
power = payload.get("power", "off")
|
||||||
|
|
||||||
|
# Map abstract "on"/"off" to vendor "true"/"false"
|
||||||
|
if isinstance(power, str):
|
||||||
|
power_lower = power.lower()
|
||||||
|
if power_lower in {"on", "true", "1"}:
|
||||||
|
return "true"
|
||||||
|
if power_lower in {"off", "false", "0"}:
|
||||||
|
return "false"
|
||||||
|
|
||||||
|
# Fallback: any truthy value -> "true", else "false"
|
||||||
|
return "true" if power else "false"
|
||||||
|
|
||||||
|
|
||||||
|
def transform_relay_to_abstract(payload: str) -> dict[str, Any]:
|
||||||
|
"""Transform Hottis Wago Modbus relay payload to abstract format.
|
||||||
|
|
||||||
|
Hottis Wago Modbus sends plain text 'true' or 'false'.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Hottis Wago Modbus: 'true'
|
||||||
|
- Abstract: {'power': 'on'}
|
||||||
|
"""
|
||||||
|
value = payload.strip().lower()
|
||||||
|
|
||||||
|
if value == "true":
|
||||||
|
power = "on"
|
||||||
|
elif value == "false":
|
||||||
|
power = "off"
|
||||||
|
else:
|
||||||
|
# Fallback for unexpected values: keep as-is
|
||||||
|
logger.warning("Unexpected relay payload from Hottis Wago Modbus: %r", payload)
|
||||||
|
power = value
|
||||||
|
|
||||||
|
return {"power": power}
|
||||||
|
|
||||||
|
|
||||||
|
# Registry of handlers for this vendor
|
||||||
|
HANDLERS = {
|
||||||
|
("relay", "to_vendor"): transform_relay_to_vendor,
|
||||||
|
("relay", "to_abstract"): transform_relay_to_abstract,
|
||||||
|
}
|
||||||
38
apps/abstraction/vendors/hottis_wifi_relay.py
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Hottis WiFi Relay vendor transformations."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_relay_to_vendor(payload: dict[str, Any]) -> str:
|
||||||
|
"""Transform abstract relay payload to Hottis WiFi Relay format.
|
||||||
|
|
||||||
|
Hottis WiFi Relay expects plain text 'on' or 'off' (not JSON).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Abstract: {'power': 'on'}
|
||||||
|
- Hottis WiFi Relay: 'ON'
|
||||||
|
"""
|
||||||
|
power = payload.get("power", "off").upper()
|
||||||
|
return power
|
||||||
|
|
||||||
|
|
||||||
|
def transform_relay_to_abstract(payload: str) -> dict[str, Any]:
|
||||||
|
"""Transform Hottis WiFi Relay relay payload to abstract format.
|
||||||
|
|
||||||
|
Hottis WiFi Relay sends plain text 'on' or 'off'.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Hottis WiFi Relay: 'ON'
|
||||||
|
- Abstract: {'power': 'on'}
|
||||||
|
"""
|
||||||
|
return {"power": payload.strip().lower()}
|
||||||
|
|
||||||
|
|
||||||
|
# Registry of handlers for this vendor
|
||||||
|
HANDLERS = {
|
||||||
|
("relay", "to_vendor"): transform_relay_to_vendor,
|
||||||
|
("relay", "to_abstract"): transform_relay_to_abstract,
|
||||||
|
}
|
||||||
95
apps/abstraction/vendors/max.py
vendored
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""MAX! (Homegear) vendor transformations."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_contact_sensor_to_vendor(payload: dict[str, Any]) -> str:
|
||||||
|
"""Transform abstract contact sensor payload to MAX! format.
|
||||||
|
|
||||||
|
Contact sensors are read-only.
|
||||||
|
"""
|
||||||
|
logger.warning("Contact sensors are read-only - SET commands should not be used")
|
||||||
|
return json.dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_contact_sensor_to_abstract(payload: str) -> dict[str, Any]:
|
||||||
|
"""Transform MAX! 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"
|
||||||
|
- Abstract: {"contact": "open"}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
contact_value = payload.strip().lower() == "true"
|
||||||
|
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"}
|
||||||
|
|
||||||
|
|
||||||
|
def transform_thermostat_to_vendor(payload: dict[str, Any]) -> str:
|
||||||
|
"""Transform abstract thermostat payload to MAX! format.
|
||||||
|
|
||||||
|
MAX! expects only the integer temperature value (no JSON).
|
||||||
|
|
||||||
|
Transformations:
|
||||||
|
- Extract 'target' temperature from payload
|
||||||
|
- Convert float to integer
|
||||||
|
- Return as plain string value
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Abstract: {'target': 22.5}
|
||||||
|
- MAX!: "22"
|
||||||
|
"""
|
||||||
|
if "target" not in payload:
|
||||||
|
logger.warning(f"MAX! thermostat payload missing 'target': {payload}")
|
||||||
|
return "21"
|
||||||
|
|
||||||
|
target_temp = payload["target"]
|
||||||
|
|
||||||
|
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)}")
|
||||||
|
return "21"
|
||||||
|
|
||||||
|
|
||||||
|
def transform_thermostat_to_abstract(payload: str) -> dict[str, Any]:
|
||||||
|
"""Transform MAX! thermostat payload to abstract format.
|
||||||
|
|
||||||
|
MAX! sends only the integer temperature value (no JSON).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- MAX!: "22"
|
||||||
|
- Abstract: {'target': 22.0, 'mode': 'heat'}
|
||||||
|
"""
|
||||||
|
target_temp = float(payload.strip())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"target": target_temp,
|
||||||
|
"mode": "heat"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Registry of handlers for this vendor
|
||||||
|
HANDLERS = {
|
||||||
|
("contact_sensor", "to_vendor"): transform_contact_sensor_to_vendor,
|
||||||
|
("contact_sensor", "to_abstract"): transform_contact_sensor_to_abstract,
|
||||||
|
("contact", "to_vendor"): transform_contact_sensor_to_vendor,
|
||||||
|
("contact", "to_abstract"): transform_contact_sensor_to_abstract,
|
||||||
|
("thermostat", "to_vendor"): transform_thermostat_to_vendor,
|
||||||
|
("thermostat", "to_abstract"): transform_thermostat_to_abstract,
|
||||||
|
}
|
||||||
38
apps/abstraction/vendors/shelly.py
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Shelly vendor transformations."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_relay_to_vendor(payload: dict[str, Any]) -> str:
|
||||||
|
"""Transform abstract relay payload to Shelly format.
|
||||||
|
|
||||||
|
Shelly expects plain text 'on' or 'off' (not JSON).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Abstract: {'power': 'on'}
|
||||||
|
- Shelly: 'on'
|
||||||
|
"""
|
||||||
|
power = payload.get("power", "off")
|
||||||
|
return power
|
||||||
|
|
||||||
|
|
||||||
|
def transform_relay_to_abstract(payload: str) -> dict[str, Any]:
|
||||||
|
"""Transform Shelly relay payload to abstract format.
|
||||||
|
|
||||||
|
Shelly sends plain text 'on' or 'off'.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Shelly: 'on'
|
||||||
|
- Abstract: {'power': 'on'}
|
||||||
|
"""
|
||||||
|
return {"power": payload.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
# Registry of handlers for this vendor
|
||||||
|
HANDLERS = {
|
||||||
|
("relay", "to_vendor"): transform_relay_to_vendor,
|
||||||
|
("relay", "to_abstract"): transform_relay_to_abstract,
|
||||||
|
}
|
||||||
50
apps/abstraction/vendors/simulator.py
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Simulator vendor transformations."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_light_to_vendor(payload: dict[str, Any]) -> str:
|
||||||
|
"""Transform abstract light payload to simulator format.
|
||||||
|
|
||||||
|
Simulator uses same format as abstract protocol (no transformation needed).
|
||||||
|
"""
|
||||||
|
return json.dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_light_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_to_vendor(payload: dict[str, Any]) -> str:
|
||||||
|
"""Transform abstract thermostat payload to simulator format.
|
||||||
|
|
||||||
|
Simulator uses same format as abstract protocol (no transformation needed).
|
||||||
|
"""
|
||||||
|
return json.dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_thermostat_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
|
||||||
|
|
||||||
|
|
||||||
|
# Registry of handlers for this vendor
|
||||||
|
HANDLERS = {
|
||||||
|
("light", "to_vendor"): transform_light_to_vendor,
|
||||||
|
("light", "to_abstract"): transform_light_to_abstract,
|
||||||
|
("thermostat", "to_vendor"): transform_thermostat_to_vendor,
|
||||||
|
("thermostat", "to_abstract"): transform_thermostat_to_abstract,
|
||||||
|
}
|
||||||
38
apps/abstraction/vendors/tasmota.py
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Tasmota vendor transformations."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_relay_to_vendor(payload: dict[str, Any]) -> str:
|
||||||
|
"""Transform abstract relay payload to Tasmota format.
|
||||||
|
|
||||||
|
Tasmota expects plain text 'on' or 'off' (not JSON).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Abstract: {'power': 'on'}
|
||||||
|
- Tasmota: 'on'
|
||||||
|
"""
|
||||||
|
power = payload.get("power", "off")
|
||||||
|
return power
|
||||||
|
|
||||||
|
|
||||||
|
def transform_relay_to_abstract(payload: str) -> dict[str, Any]:
|
||||||
|
"""Transform Tasmota relay payload to abstract format.
|
||||||
|
|
||||||
|
Tasmota sends plain text 'ON' or 'OFF'.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Tasmota: 'ON'
|
||||||
|
- Abstract: {'power': 'on'}
|
||||||
|
"""
|
||||||
|
return {"power": payload.strip().lower()}
|
||||||
|
|
||||||
|
|
||||||
|
# Registry of handlers for this vendor
|
||||||
|
HANDLERS = {
|
||||||
|
("relay", "to_vendor"): transform_relay_to_vendor,
|
||||||
|
("relay", "to_abstract"): transform_relay_to_abstract,
|
||||||
|
}
|
||||||
209
apps/abstraction/vendors/zigbee2mqtt.py
vendored
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""Zigbee2MQTT vendor transformations."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_light_to_vendor(payload: dict[str, Any]) -> str:
|
||||||
|
"""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)):
|
||||||
|
vendor_payload["brightness"] = round(abstract_brightness * 254 / 100)
|
||||||
|
|
||||||
|
return json.dumps(vendor_payload)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_light_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)):
|
||||||
|
abstract_payload["brightness"] = round(vendor_brightness * 100 / 254)
|
||||||
|
|
||||||
|
return abstract_payload
|
||||||
|
|
||||||
|
|
||||||
|
def transform_thermostat_to_vendor(payload: dict[str, Any]) -> str:
|
||||||
|
"""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:
|
||||||
|
vendor_payload["current_heating_setpoint"] = str(payload["target"])
|
||||||
|
|
||||||
|
return json.dumps(vendor_payload)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_thermostat_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 = {}
|
||||||
|
|
||||||
|
if "current_heating_setpoint" in payload:
|
||||||
|
setpoint = payload["current_heating_setpoint"]
|
||||||
|
abstract_payload["target"] = float(setpoint)
|
||||||
|
|
||||||
|
if "local_temperature" in payload:
|
||||||
|
current = payload["local_temperature"]
|
||||||
|
abstract_payload["current"] = float(current)
|
||||||
|
|
||||||
|
if "system_mode" in payload:
|
||||||
|
abstract_payload["mode"] = payload["system_mode"]
|
||||||
|
|
||||||
|
return abstract_payload
|
||||||
|
|
||||||
|
|
||||||
|
def transform_contact_sensor_to_vendor(payload: dict[str, Any]) -> str:
|
||||||
|
"""Transform abstract contact sensor payload to zigbee2mqtt format.
|
||||||
|
|
||||||
|
Contact sensors are read-only, so this should not be called for SET commands.
|
||||||
|
"""
|
||||||
|
logger.warning("Contact sensors are read-only - SET commands should not be used")
|
||||||
|
return json.dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_contact_sensor_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!)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- zigbee2mqtt: {"contact": false, "battery": 100}
|
||||||
|
- Abstract: {"contact": "open", "battery": 100}
|
||||||
|
"""
|
||||||
|
payload = json.loads(payload)
|
||||||
|
abstract_payload = {}
|
||||||
|
|
||||||
|
if "contact" in payload:
|
||||||
|
contact_bool = payload["contact"]
|
||||||
|
abstract_payload["contact"] = "closed" if contact_bool else "open"
|
||||||
|
|
||||||
|
# Pass through optional fields
|
||||||
|
for field in ["battery", "linkquality", "device_temperature", "voltage"]:
|
||||||
|
if field in payload:
|
||||||
|
abstract_payload[field] = payload[field]
|
||||||
|
|
||||||
|
return abstract_payload
|
||||||
|
|
||||||
|
|
||||||
|
def transform_temp_humidity_sensor_to_vendor(payload: dict[str, Any]) -> str:
|
||||||
|
"""Transform abstract temp/humidity sensor payload to zigbee2mqtt format.
|
||||||
|
|
||||||
|
Temp/humidity sensors are read-only.
|
||||||
|
"""
|
||||||
|
return json.dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_temp_humidity_sensor_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
|
||||||
|
|
||||||
|
|
||||||
|
def transform_relay_to_vendor(payload: dict[str, Any]) -> str:
|
||||||
|
"""Transform abstract relay payload to zigbee2mqtt format.
|
||||||
|
|
||||||
|
- 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 json.dumps(vendor_payload)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_relay_to_abstract(payload: str) -> dict[str, Any]:
|
||||||
|
"""Transform zigbee2mqtt relay payload to abstract format.
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
|
||||||
|
# Registry of handlers for this vendor
|
||||||
|
HANDLERS = {
|
||||||
|
("light", "to_vendor"): transform_light_to_vendor,
|
||||||
|
("light", "to_abstract"): transform_light_to_abstract,
|
||||||
|
("thermostat", "to_vendor"): transform_thermostat_to_vendor,
|
||||||
|
("thermostat", "to_abstract"): transform_thermostat_to_abstract,
|
||||||
|
("contact_sensor", "to_vendor"): transform_contact_sensor_to_vendor,
|
||||||
|
("contact_sensor", "to_abstract"): transform_contact_sensor_to_abstract,
|
||||||
|
("contact", "to_vendor"): transform_contact_sensor_to_vendor,
|
||||||
|
("contact", "to_abstract"): transform_contact_sensor_to_abstract,
|
||||||
|
("temp_humidity_sensor", "to_vendor"): transform_temp_humidity_sensor_to_vendor,
|
||||||
|
("temp_humidity_sensor", "to_abstract"): transform_temp_humidity_sensor_to_abstract,
|
||||||
|
("temp_humidity", "to_vendor"): transform_temp_humidity_sensor_to_vendor,
|
||||||
|
("temp_humidity", "to_abstract"): transform_temp_humidity_sensor_to_abstract,
|
||||||
|
("relay", "to_vendor"): transform_relay_to_vendor,
|
||||||
|
("relay", "to_abstract"): transform_relay_to_abstract,
|
||||||
|
}
|
||||||
@@ -8,9 +8,9 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
MQTT_BROKER=172.16.2.16 \
|
MQTT_BROKER=172.16.2.16 \
|
||||||
MQTT_PORT=1883 \
|
MQTT_PORT=1883 \
|
||||||
REDIS_HOST=localhost \
|
REDIS_HOST=172.23.1.116 \
|
||||||
REDIS_PORT=6379 \
|
REDIS_PORT=6379 \
|
||||||
REDIS_DB=0 \
|
REDIS_DB=8 \
|
||||||
REDIS_CHANNEL=ui:updates
|
REDIS_CHANNEL=ui:updates
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
|
|||||||
146
apps/api/config.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""Configuration loading and caching for API application.
|
||||||
|
|
||||||
|
This module provides centralized configuration management for devices and layout,
|
||||||
|
with startup validation and in-memory caching for performance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from packages.home_capabilities.layout import UiLayout
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Global caches (loaded once at startup)
|
||||||
|
devices_cache: list[dict[str, Any]] = []
|
||||||
|
layout_cache: UiLayout | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def load_devices_from_file() -> list[dict[str, Any]]:
|
||||||
|
"""Load devices from configuration file and validate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of device configurations
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If devices.yaml doesn't exist
|
||||||
|
KeyError: If any device is missing required homekit_aid field
|
||||||
|
ValueError: If devices.yaml is invalid or contains duplicate homekit_aid values
|
||||||
|
"""
|
||||||
|
config_path = Path(__file__).parent.parent.parent / "config" / "devices.yaml"
|
||||||
|
|
||||||
|
if not config_path.exists():
|
||||||
|
raise FileNotFoundError(f"devices.yaml not found at {config_path}")
|
||||||
|
|
||||||
|
with open(config_path, "r") as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
|
||||||
|
if not config or "devices" not in config:
|
||||||
|
raise ValueError("devices.yaml must contain 'devices' key")
|
||||||
|
|
||||||
|
# Normalize device entries: accept both 'id' and 'device_id', use 'device_id' internally
|
||||||
|
devices = config.get("devices", [])
|
||||||
|
for device in devices:
|
||||||
|
device["device_id"] = device.pop("device_id", device.pop("id", None))
|
||||||
|
|
||||||
|
# Validate required homekit_aid field
|
||||||
|
if "homekit_aid" not in device:
|
||||||
|
raise KeyError(f"Device {device.get('device_id', 'unknown')} is missing required 'homekit_aid' field")
|
||||||
|
|
||||||
|
# Validate unique homekit_aid values
|
||||||
|
aids = [d["homekit_aid"] for d in devices]
|
||||||
|
if len(aids) != len(set(aids)):
|
||||||
|
duplicates = [aid for aid in aids if aids.count(aid) > 1]
|
||||||
|
raise ValueError(f"Duplicate homekit_aid values found: {set(duplicates)}")
|
||||||
|
|
||||||
|
logger.info(f"Loaded {len(devices)} devices with unique homekit_aid values (range: {min(aids)}-{max(aids)})")
|
||||||
|
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
def load_layout_from_file() -> UiLayout:
|
||||||
|
"""Load UI layout from configuration file and validate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UiLayout: Parsed and validated layout configuration
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If layout.yaml doesn't exist
|
||||||
|
ValueError: If layout validation fails
|
||||||
|
yaml.YAMLError: If YAML parsing fails
|
||||||
|
"""
|
||||||
|
config_path = Path(__file__).parent.parent.parent / "config" / "layout.yaml"
|
||||||
|
|
||||||
|
if not config_path.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Layout configuration not found: {config_path}. "
|
||||||
|
f"Please create a layout.yaml file with room and device definitions."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise yaml.YAMLError(f"Failed to parse YAML in {config_path}: {e}")
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
raise ValueError(f"Layout file is empty: {config_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
layout = UiLayout(**data)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Invalid layout configuration in {config_path}: {e}")
|
||||||
|
|
||||||
|
total_devices = layout.total_devices()
|
||||||
|
room_names = [room.name for room in layout.rooms]
|
||||||
|
logger.info(
|
||||||
|
f"Loaded layout: {len(layout.rooms)} rooms, "
|
||||||
|
f"{total_devices} total devices (Rooms: {', '.join(room_names)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return layout
|
||||||
|
|
||||||
|
|
||||||
|
def load_devices() -> list[dict[str, Any]]:
|
||||||
|
"""Get devices from in-memory cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of device configurations (loaded at startup)
|
||||||
|
"""
|
||||||
|
return devices_cache
|
||||||
|
|
||||||
|
|
||||||
|
def load_layout() -> UiLayout:
|
||||||
|
"""Get layout from in-memory cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UiLayout: Layout configuration (loaded at startup)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If layout cache is not initialized
|
||||||
|
"""
|
||||||
|
if layout_cache is None:
|
||||||
|
raise RuntimeError("Layout cache not initialized. Application startup may have failed.")
|
||||||
|
return layout_cache
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_config() -> None:
|
||||||
|
"""Initialize configuration by loading devices and layout.
|
||||||
|
|
||||||
|
This function should be called once during application startup.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If configuration loading or validation fails
|
||||||
|
"""
|
||||||
|
global devices_cache, layout_cache
|
||||||
|
|
||||||
|
# Load devices with validation
|
||||||
|
devices_cache = load_devices_from_file()
|
||||||
|
|
||||||
|
# Load layout with validation
|
||||||
|
layout_cache = load_layout_from_file()
|
||||||
|
|
||||||
|
logger.info("Configuration initialization complete")
|
||||||
276
apps/api/main.py
@@ -1,145 +1,3 @@
|
|||||||
"""API main entry point.
|
|
||||||
|
|
||||||
API-Analyse für HomeKit-Bridge Kompatibilität
|
|
||||||
==============================================
|
|
||||||
|
|
||||||
1) GET /devices
|
|
||||||
Status: ✅ VORHANDEN (Zeile 325-343)
|
|
||||||
|
|
||||||
Aktuelles Response-Modell (DeviceInfo, Zeile 189-194):
|
|
||||||
{
|
|
||||||
"device_id": str, ✅ OK
|
|
||||||
"type": str, ✅ OK
|
|
||||||
"name": str, ⚠️ ABWEICHUNG: Erwartet wurde "short_name" (optional)
|
|
||||||
"features": dict ✅ OK
|
|
||||||
}
|
|
||||||
|
|
||||||
Bewertung:
|
|
||||||
- ✅ Liefert device_id, type, features wie erwartet
|
|
||||||
- ⚠️ Verwendet "name" statt "short_name"
|
|
||||||
- ✅ Fallback auf device_id wenn name nicht vorhanden
|
|
||||||
- Kompatibilität: HOCH - einfach "name" als "short_name" verwenden
|
|
||||||
|
|
||||||
|
|
||||||
2) GET /layout
|
|
||||||
Status: ✅ VORHANDEN (Zeile 354-387)
|
|
||||||
|
|
||||||
Aktuelles Response-Format:
|
|
||||||
{
|
|
||||||
"rooms": [
|
|
||||||
{
|
|
||||||
"name": "Schlafzimmer",
|
|
||||||
"devices": [
|
|
||||||
{
|
|
||||||
"device_id": "thermostat_wolfgang",
|
|
||||||
"title": "Thermostat Wolfgang", ← friendly_name
|
|
||||||
"icon": "thermometer",
|
|
||||||
"rank": 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
Mapping device_id -> room, friendly_name:
|
|
||||||
- room: Durch Iteration über rooms[].devices[] ableitbar
|
|
||||||
- friendly_name: Im Feld "title" enthalten
|
|
||||||
|
|
||||||
Bewertung:
|
|
||||||
- ✅ Alle erforderlichen Informationen vorhanden
|
|
||||||
- ⚠️ ABWEICHUNG: Verschachtelte Struktur (rooms -> devices)
|
|
||||||
- ⚠️ ABWEICHUNG: friendly_name heißt "title"
|
|
||||||
- Kompatibilität: HOCH - einfache Transformation möglich:
|
|
||||||
```python
|
|
||||||
for room in layout["rooms"]:
|
|
||||||
for device in room["devices"]:
|
|
||||||
mapping[device["device_id"]] = {
|
|
||||||
"room": room["name"],
|
|
||||||
"friendly_name": device["title"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
3) POST /devices/{device_id}/set
|
|
||||||
Status: ✅ VORHANDEN (Zeile 406-504)
|
|
||||||
|
|
||||||
Aktuelles Request-Modell (SetDeviceRequest, Zeile 182-185):
|
|
||||||
{
|
|
||||||
"type": str, ✅ OK - muss zum Gerätetyp passen
|
|
||||||
"payload": dict ✅ OK - abstraktes Kommando
|
|
||||||
}
|
|
||||||
|
|
||||||
Beispiel Light:
|
|
||||||
POST /devices/leselampe_esszimmer/set
|
|
||||||
{"type": "light", "payload": {"power": "on", "brightness": 80}}
|
|
||||||
|
|
||||||
Beispiel Thermostat:
|
|
||||||
POST /devices/thermostat_wolfgang/set
|
|
||||||
{"type": "thermostat", "payload": {"target": 21.0}}
|
|
||||||
|
|
||||||
Validierung:
|
|
||||||
- ✅ Type-spezifische Payload-Validierung (Zeile 437-487)
|
|
||||||
- ✅ Read-only Check → 405 METHOD_NOT_ALLOWED (Zeile 431-435)
|
|
||||||
- ✅ Ungültige Payload → 422 UNPROCESSABLE_ENTITY
|
|
||||||
- ✅ Device nicht gefunden → 404 NOT_FOUND
|
|
||||||
|
|
||||||
Bewertung:
|
|
||||||
- ✅ Exakt wie erwartet implementiert
|
|
||||||
- ✅ Alle geforderten Error Codes vorhanden
|
|
||||||
- Kompatibilität: PERFEKT
|
|
||||||
|
|
||||||
|
|
||||||
4) Realtime-Endpoint (SSE)
|
|
||||||
Status: ✅ VORHANDEN als GET /realtime (Zeile 608-632)
|
|
||||||
|
|
||||||
Implementierung:
|
|
||||||
- ✅ Server-Sent Events (media_type="text/event-stream")
|
|
||||||
- ✅ Redis Pub/Sub basiert (event_generator, Zeile 510-607)
|
|
||||||
- ✅ Safari-kompatibel (Heartbeats, Retry-Hints)
|
|
||||||
|
|
||||||
Aktuelles Event-Format (aus apps/abstraction/main.py:250-256):
|
|
||||||
{
|
|
||||||
"type": "state", ✅ OK
|
|
||||||
"device_id": str, ✅ OK
|
|
||||||
"payload": dict, ✅ OK - z.B. {"power":"on","brightness":80}
|
|
||||||
"ts": str ✅ OK - ISO-8601 format von datetime.now(timezone.utc)
|
|
||||||
}
|
|
||||||
|
|
||||||
Beispiel-Event:
|
|
||||||
{
|
|
||||||
"type": "state",
|
|
||||||
"device_id": "thermostat_wolfgang",
|
|
||||||
"payload": {"current": 19.5, "target": 21.0},
|
|
||||||
"ts": "2025-11-17T14:23:45.123456+00:00"
|
|
||||||
}
|
|
||||||
|
|
||||||
Bewertung:
|
|
||||||
- ✅ Alle geforderten Felder vorhanden
|
|
||||||
- ✅ Timestamp im korrekten Format
|
|
||||||
- ✅ SSE mit proper headers und error handling
|
|
||||||
- Kompatibilität: PERFEKT
|
|
||||||
|
|
||||||
|
|
||||||
ZUSAMMENFASSUNG
|
|
||||||
===============
|
|
||||||
|
|
||||||
Alle 4 geforderten Endpunkte sind implementiert!
|
|
||||||
|
|
||||||
Kompatibilität mit HomeKit-Bridge Anforderungen:
|
|
||||||
- GET /devices: HOCH (nur Name-Feld unterschiedlich)
|
|
||||||
- GET /layout: HOCH (Struktur-Transformation nötig)
|
|
||||||
- POST /devices/{id}/set: PERFEKT (1:1 wie gefordert)
|
|
||||||
- GET /realtime (SSE): PERFEKT (1:1 wie gefordert)
|
|
||||||
|
|
||||||
Erforderliche Anpassungen für Bridge:
|
|
||||||
1. GET /devices: "name" als "short_name" interpretieren ✓ trivial
|
|
||||||
2. GET /layout: Verschachtelte Struktur zu flat mapping umwandeln ✓ einfach
|
|
||||||
|
|
||||||
Keine Code-Änderungen in der API erforderlich!
|
|
||||||
Die Bridge kann die bestehenden Endpoints direkt nutzen.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -166,9 +24,11 @@ from packages.home_capabilities import (
|
|||||||
ContactState,
|
ContactState,
|
||||||
TempHumidityState,
|
TempHumidityState,
|
||||||
RelayState,
|
RelayState,
|
||||||
load_layout,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Import configuration management
|
||||||
|
from apps.api.config import initialize_config, load_devices, load_layout
|
||||||
|
|
||||||
# Import resolvers (must be before router imports to avoid circular dependency)
|
# Import resolvers (must be before router imports to avoid circular dependency)
|
||||||
from apps.api.resolvers import (
|
from apps.api.resolvers import (
|
||||||
DeviceDTO,
|
DeviceDTO,
|
||||||
@@ -179,9 +39,12 @@ from apps.api.resolvers import (
|
|||||||
clear_room_cache,
|
clear_room_cache,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# STATE CACHES
|
# STATE CACHES
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -206,18 +69,37 @@ app.add_middleware(
|
|||||||
"http://localhost:8002",
|
"http://localhost:8002",
|
||||||
"http://172.19.1.11:8002",
|
"http://172.19.1.11:8002",
|
||||||
"http://127.0.0.1:8002",
|
"http://127.0.0.1:8002",
|
||||||
|
"https://homea2.hottis.de"
|
||||||
],
|
],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
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.on_event("startup")
|
@app.get("/devices/{device_id}/state")
|
||||||
async def startup_event():
|
async def get_device_state(device_id: str):
|
||||||
"""Include routers after app is initialized to avoid circular imports."""
|
try:
|
||||||
from apps.api.routes.groups_scenes import router as groups_scenes_router
|
logger.debug(f"Fetching state for device {device_id}")
|
||||||
app.include_router(groups_scenes_router, prefix="")
|
state = device_states[device_id]
|
||||||
|
return state
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="Device state not found")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -284,6 +166,21 @@ async def redis_state_listener():
|
|||||||
async def startup_event():
|
async def startup_event():
|
||||||
"""Start background tasks on application startup."""
|
"""Start background tasks on application startup."""
|
||||||
global background_task
|
global background_task
|
||||||
|
|
||||||
|
# Include routers
|
||||||
|
from apps.api.routes.groups_scenes import router as groups_scenes_router
|
||||||
|
from apps.api.routes.rooms import router as rooms_router
|
||||||
|
|
||||||
|
app.include_router(groups_scenes_router, prefix="")
|
||||||
|
app.include_router(rooms_router, prefix="")
|
||||||
|
|
||||||
|
# Load and validate configuration (devices + layout)
|
||||||
|
try:
|
||||||
|
initialize_config()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize configuration: {e}")
|
||||||
|
raise # Fatal error - application will not start
|
||||||
|
|
||||||
background_task = asyncio.create_task(redis_state_listener())
|
background_task = asyncio.create_task(redis_state_listener())
|
||||||
logger.info("Started background Redis state listener")
|
logger.info("Started background Redis state listener")
|
||||||
|
|
||||||
@@ -331,32 +228,11 @@ class DeviceInfo(BaseModel):
|
|||||||
device_id: str
|
device_id: str
|
||||||
type: str
|
type: str
|
||||||
name: str
|
name: str
|
||||||
|
homekit_aid: int
|
||||||
features: dict[str, Any] = {}
|
features: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
# Configuration helpers
|
# Configuration helpers
|
||||||
def load_devices() -> list[dict[str, Any]]:
|
|
||||||
"""Load devices from configuration file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: List of device configurations
|
|
||||||
"""
|
|
||||||
config_path = Path(__file__).parent.parent.parent / "config" / "devices.yaml"
|
|
||||||
|
|
||||||
if not config_path.exists():
|
|
||||||
return []
|
|
||||||
|
|
||||||
with open(config_path, "r") as f:
|
|
||||||
config = yaml.safe_load(f)
|
|
||||||
|
|
||||||
# Normalize device entries: accept both 'id' and 'device_id', use 'device_id' internally
|
|
||||||
devices = config.get("devices", [])
|
|
||||||
for device in devices:
|
|
||||||
device["device_id"] = device.pop("device_id", device.pop("id", None))
|
|
||||||
|
|
||||||
return devices
|
|
||||||
|
|
||||||
|
|
||||||
def get_mqtt_settings() -> tuple[str, int]:
|
def get_mqtt_settings() -> tuple[str, int]:
|
||||||
"""Get MQTT broker settings from environment.
|
"""Get MQTT broker settings from environment.
|
||||||
|
|
||||||
@@ -462,25 +338,6 @@ async def publish_mqtt(topic: str, payload: dict[str, Any]) -> None:
|
|||||||
await client.publish(topic, message, qos=1)
|
await client.publish(topic, message, qos=1)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/devices")
|
|
||||||
async def get_devices() -> list[DeviceInfo]:
|
|
||||||
"""Get list of available devices.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: List of device information including features
|
|
||||||
"""
|
|
||||||
devices = load_devices()
|
|
||||||
return [
|
|
||||||
DeviceInfo(
|
|
||||||
device_id=device["device_id"],
|
|
||||||
type=device["type"],
|
|
||||||
name=device.get("name", device["device_id"]),
|
|
||||||
features=device.get("features", {})
|
|
||||||
)
|
|
||||||
for device in devices
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/devices/states")
|
@app.get("/devices/states")
|
||||||
async def get_device_states() -> dict[str, dict[str, Any]]:
|
async def get_device_states() -> dict[str, dict[str, Any]]:
|
||||||
"""Get current states of all devices from in-memory cache.
|
"""Get current states of all devices from in-memory cache.
|
||||||
@@ -488,9 +345,48 @@ async def get_device_states() -> dict[str, dict[str, Any]]:
|
|||||||
Returns:
|
Returns:
|
||||||
dict: Dictionary mapping device_id to state payload
|
dict: Dictionary mapping device_id to state payload
|
||||||
"""
|
"""
|
||||||
|
logger.debug("Fetching all device states")
|
||||||
return 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"]),
|
||||||
|
homekit_aid=device["homekit_aid"],
|
||||||
|
features=device.get("features", {})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/devices")
|
||||||
|
async def get_devices() -> list[DeviceInfo]:
|
||||||
|
"""Get list of available devices.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of device information including features
|
||||||
|
"""
|
||||||
|
logger.debug("Fetching list of devices")
|
||||||
|
devices = load_devices()
|
||||||
|
return [
|
||||||
|
DeviceInfo(
|
||||||
|
device_id=device["device_id"],
|
||||||
|
type=device["type"],
|
||||||
|
name=device.get("name", device["device_id"]),
|
||||||
|
homekit_aid=device["homekit_aid"],
|
||||||
|
features=device.get("features", {})
|
||||||
|
)
|
||||||
|
for device in devices
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/layout")
|
@app.get("/layout")
|
||||||
async def get_layout() -> dict[str, Any]:
|
async def get_layout() -> dict[str, Any]:
|
||||||
"""Get UI layout configuration.
|
"""Get UI layout configuration.
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import logging
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, TypedDict
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
|
from apps.api.config import load_layout
|
||||||
from packages.home_capabilities import (
|
from packages.home_capabilities import (
|
||||||
GroupConfig,
|
GroupConfig,
|
||||||
GroupsConfigRoot,
|
GroupsConfigRoot,
|
||||||
SceneStep,
|
SceneStep,
|
||||||
get_group_by_id,
|
get_group_by_id,
|
||||||
load_layout,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
219
apps/api/routes/rooms.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
"""
|
||||||
|
Room-based device control endpoints.
|
||||||
|
|
||||||
|
Provides bulk control operations for devices within rooms:
|
||||||
|
- /rooms/{room_name}/lights - Control all lights in a room
|
||||||
|
- /rooms/{room_name}/heating - Control all thermostats in a room
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from apps.api.config import load_layout
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Rooms"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rooms")
|
||||||
|
async def get_rooms() -> list[dict[str, str]]:
|
||||||
|
"""Get list of all room IDs and names.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with room id and name
|
||||||
|
"""
|
||||||
|
layout = load_layout()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": room.id,
|
||||||
|
"name": room.name
|
||||||
|
}
|
||||||
|
for room in layout.rooms
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class LightsControlRequest(BaseModel):
|
||||||
|
"""Request model for controlling lights in a room."""
|
||||||
|
power: str # "on" or "off"
|
||||||
|
brightness: int | None = None # Optional brightness 0-100
|
||||||
|
|
||||||
|
|
||||||
|
class HeatingControlRequest(BaseModel):
|
||||||
|
"""Request model for controlling heating in a room."""
|
||||||
|
target: float # Target temperature
|
||||||
|
|
||||||
|
|
||||||
|
def get_room_devices(room_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""Get all devices in a specific room from layout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room_id: ID of the room
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of device dicts with device_id, title, icon, rank, excluded
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If room not found
|
||||||
|
"""
|
||||||
|
layout = load_layout()
|
||||||
|
|
||||||
|
for room in layout.rooms:
|
||||||
|
if room.id == room_id:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"device_id": device.device_id,
|
||||||
|
"title": device.title,
|
||||||
|
"icon": device.icon,
|
||||||
|
"rank": device.rank,
|
||||||
|
"excluded": device.excluded
|
||||||
|
}
|
||||||
|
for device in room.devices
|
||||||
|
]
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Room '{room_id}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rooms/{room_id}/lights", status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
async def control_room_lights(room_id: str, request: LightsControlRequest) -> dict[str, Any]:
|
||||||
|
"""Control all lights (light and relay devices) in a room.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room_id: ID of the room
|
||||||
|
request: Light control parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with affected device_ids and command summary
|
||||||
|
"""
|
||||||
|
from apps.api.main import load_devices, publish_abstract_set
|
||||||
|
|
||||||
|
# Get all devices in room
|
||||||
|
room_devices = get_room_devices(room_id)
|
||||||
|
|
||||||
|
# Filter out excluded devices
|
||||||
|
room_device_ids = {d["device_id"] for d in room_devices if not d.get("excluded", False)}
|
||||||
|
|
||||||
|
# Load all devices to filter by type
|
||||||
|
all_devices = load_devices()
|
||||||
|
|
||||||
|
# Filter for light/relay devices in this room
|
||||||
|
light_devices = [
|
||||||
|
d for d in all_devices
|
||||||
|
if d["device_id"] in room_device_ids and d["type"] in ("light", "relay")
|
||||||
|
]
|
||||||
|
|
||||||
|
if not light_devices:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"No light devices found in room '{room_id}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build payload
|
||||||
|
payload = {"power": request.power}
|
||||||
|
if request.brightness is not None and request.power == "on":
|
||||||
|
payload["brightness"] = request.brightness
|
||||||
|
|
||||||
|
# Send commands to all light devices
|
||||||
|
affected_ids = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for device in light_devices:
|
||||||
|
try:
|
||||||
|
await publish_abstract_set(
|
||||||
|
device_type=device["type"],
|
||||||
|
device_id=device["device_id"],
|
||||||
|
payload=payload
|
||||||
|
)
|
||||||
|
affected_ids.append(device["device_id"])
|
||||||
|
logger.info(f"Sent command to {device['device_id']}: {payload}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to control {device['device_id']}: {e}")
|
||||||
|
errors.append({
|
||||||
|
"device_id": device["device_id"],
|
||||||
|
"error": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"room": room_id,
|
||||||
|
"command": "lights",
|
||||||
|
"payload": payload,
|
||||||
|
"affected_devices": affected_ids,
|
||||||
|
"success_count": len(affected_ids),
|
||||||
|
"error_count": len(errors),
|
||||||
|
"errors": errors if errors else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rooms/{room_id}/heating", status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
async def control_room_heating(room_id: str, request: HeatingControlRequest) -> dict[str, Any]:
|
||||||
|
"""Control all thermostats in a room.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room_id: ID of the room
|
||||||
|
request: Heating control parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with affected device_ids and command summary
|
||||||
|
"""
|
||||||
|
from apps.api.main import load_devices, publish_abstract_set
|
||||||
|
|
||||||
|
# Get all devices in room
|
||||||
|
room_devices = get_room_devices(room_id)
|
||||||
|
|
||||||
|
# Filter out excluded devices
|
||||||
|
room_device_ids = {d["device_id"] for d in room_devices if not d.get("excluded", False)}
|
||||||
|
|
||||||
|
# Load all devices to filter by type
|
||||||
|
all_devices = load_devices()
|
||||||
|
|
||||||
|
# Filter for thermostat devices in this room
|
||||||
|
thermostat_devices = [
|
||||||
|
d for d in all_devices
|
||||||
|
if d["device_id"] in room_device_ids and d["type"] == "thermostat"
|
||||||
|
]
|
||||||
|
|
||||||
|
if not thermostat_devices:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"No thermostat devices found in room '{room_name}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build payload
|
||||||
|
payload = {"target": request.target}
|
||||||
|
|
||||||
|
# Send commands to all thermostat devices
|
||||||
|
affected_ids = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for device in thermostat_devices:
|
||||||
|
try:
|
||||||
|
await publish_abstract_set(
|
||||||
|
device_type="thermostat",
|
||||||
|
device_id=device["device_id"],
|
||||||
|
payload=payload
|
||||||
|
)
|
||||||
|
affected_ids.append(device["device_id"])
|
||||||
|
logger.info(f"Sent heating command to {device['device_id']}: {payload}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to control {device['device_id']}: {e}")
|
||||||
|
errors.append({
|
||||||
|
"device_id": device["device_id"],
|
||||||
|
"error": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"room": room_id,
|
||||||
|
"command": "heating",
|
||||||
|
"payload": payload,
|
||||||
|
"affected_devices": affected_ids,
|
||||||
|
"success_count": len(affected_ids),
|
||||||
|
"error_count": len(errors),
|
||||||
|
"errors": errors if errors else None
|
||||||
|
}
|
||||||
31
apps/homekit/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# Environment defaults (can be overridden at runtime)
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
LOG_LEVEL="INFO" \
|
||||||
|
HOMEKIT_NAME="Home Automation Bridge" \
|
||||||
|
HOMEKIT_PIN="031-45-154" \
|
||||||
|
HOMEKIT_PORT="51826" \
|
||||||
|
API_BASE="http://api:8001" \
|
||||||
|
HOMEKIT_API_TOKEN="" \
|
||||||
|
HOMEKIT_PERSIST_FILE="/data/homekit.state"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy only requirements first for better build caching
|
||||||
|
COPY apps/homekit/requirements.txt ./apps/homekit/requirements.txt
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip \
|
||||||
|
&& pip install --no-cache-dir -r apps/homekit/requirements.txt
|
||||||
|
|
||||||
|
# Copy full source tree
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
# Expose HomeKit TCP port (mDNS uses UDP 5353 via host network)
|
||||||
|
EXPOSE 51826/tcp
|
||||||
|
|
||||||
|
# Volume for persistent HomeKit state (pairings etc.)
|
||||||
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
# Start the HomeKit bridge
|
||||||
|
CMD ["python", "-m", "apps.homekit.main"]
|
||||||
@@ -14,7 +14,7 @@ class ContactAccessory(Accessory):
|
|||||||
|
|
||||||
category = CATEGORY_SENSOR
|
category = CATEGORY_SENSOR
|
||||||
|
|
||||||
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
def __init__(self, driver, device, api_client, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Initialize the contact sensor accessory.
|
Initialize the contact sensor accessory.
|
||||||
|
|
||||||
@@ -22,9 +22,8 @@ class ContactAccessory(Accessory):
|
|||||||
driver: HAP driver instance
|
driver: HAP driver instance
|
||||||
device: Device object from DeviceRegistry
|
device: Device object from DeviceRegistry
|
||||||
api_client: ApiClient for sending commands
|
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
|
name = device.name
|
||||||
super().__init__(driver, name, *args, **kwargs)
|
super().__init__(driver, name, *args, **kwargs)
|
||||||
self.device = device
|
self.device = device
|
||||||
self.api_client = api_client
|
self.api_client = api_client
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class OnOffLightAccessory(Accessory):
|
|||||||
|
|
||||||
category = CATEGORY_LIGHTBULB
|
category = CATEGORY_LIGHTBULB
|
||||||
|
|
||||||
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
def __init__(self, driver, device, api_client, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Initialize the light accessory.
|
Initialize the light accessory.
|
||||||
|
|
||||||
@@ -24,9 +24,8 @@ class OnOffLightAccessory(Accessory):
|
|||||||
driver: HAP driver instance
|
driver: HAP driver instance
|
||||||
device: Device object from DeviceRegistry
|
device: Device object from DeviceRegistry
|
||||||
api_client: ApiClient for sending commands
|
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
|
name = device.name
|
||||||
super().__init__(driver, name, *args, **kwargs)
|
super().__init__(driver, name, *args, **kwargs)
|
||||||
self.device = device
|
self.device = device
|
||||||
self.api_client = api_client
|
self.api_client = api_client
|
||||||
@@ -57,9 +56,9 @@ class OnOffLightAccessory(Accessory):
|
|||||||
class DimmableLightAccessory(OnOffLightAccessory):
|
class DimmableLightAccessory(OnOffLightAccessory):
|
||||||
"""Dimmable Light with brightness control."""
|
"""Dimmable Light with brightness control."""
|
||||||
|
|
||||||
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
def __init__(self, driver, device, api_client, *args, **kwargs):
|
||||||
# Don't call super().__init__() yet - we need to set up service first
|
# Don't call super().__init__() yet - we need to set up service first
|
||||||
name = display_name or device.friendly_name or device.name
|
name = device.name
|
||||||
Accessory.__init__(self, driver, name, *args, **kwargs)
|
Accessory.__init__(self, driver, name, *args, **kwargs)
|
||||||
self.device = device
|
self.device = device
|
||||||
self.api_client = api_client
|
self.api_client = api_client
|
||||||
@@ -106,9 +105,9 @@ class DimmableLightAccessory(OnOffLightAccessory):
|
|||||||
class ColorLightAccessory(DimmableLightAccessory):
|
class ColorLightAccessory(DimmableLightAccessory):
|
||||||
"""RGB Light with full color control."""
|
"""RGB Light with full color control."""
|
||||||
|
|
||||||
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
def __init__(self, driver, device, api_client, *args, **kwargs):
|
||||||
# Don't call super().__init__() - build everything from scratch
|
# Don't call super().__init__() - build everything from scratch
|
||||||
name = display_name or device.friendly_name or device.name
|
name = device.name
|
||||||
Accessory.__init__(self, driver, name, *args, **kwargs)
|
Accessory.__init__(self, driver, name, *args, **kwargs)
|
||||||
self.device = device
|
self.device = device
|
||||||
self.api_client = api_client
|
self.api_client = api_client
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class OutletAccessory(Accessory):
|
|||||||
|
|
||||||
category = CATEGORY_OUTLET
|
category = CATEGORY_OUTLET
|
||||||
|
|
||||||
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
def __init__(self, driver, device, api_client, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Initialize the outlet accessory.
|
Initialize the outlet accessory.
|
||||||
|
|
||||||
@@ -23,9 +23,8 @@ class OutletAccessory(Accessory):
|
|||||||
driver: HAP driver instance
|
driver: HAP driver instance
|
||||||
device: Device object from DeviceRegistry
|
device: Device object from DeviceRegistry
|
||||||
api_client: ApiClient for sending commands
|
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
|
name = device.name
|
||||||
super().__init__(driver, name, *args, **kwargs)
|
super().__init__(driver, name, *args, **kwargs)
|
||||||
self.device = device
|
self.device = device
|
||||||
self.api_client = api_client
|
self.api_client = api_client
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class TempHumidityAccessory(Accessory):
|
|||||||
|
|
||||||
category = CATEGORY_SENSOR
|
category = CATEGORY_SENSOR
|
||||||
|
|
||||||
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
def __init__(self, driver, device, api_client, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Initialize the temp/humidity sensor accessory.
|
Initialize the temp/humidity sensor accessory.
|
||||||
|
|
||||||
@@ -23,9 +23,8 @@ class TempHumidityAccessory(Accessory):
|
|||||||
driver: HAP driver instance
|
driver: HAP driver instance
|
||||||
device: Device object from DeviceRegistry
|
device: Device object from DeviceRegistry
|
||||||
api_client: ApiClient for sending commands
|
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
|
name = device.name
|
||||||
super().__init__(driver, name, *args, **kwargs)
|
super().__init__(driver, name, *args, **kwargs)
|
||||||
self.device = device
|
self.device = device
|
||||||
self.api_client = api_client
|
self.api_client = api_client
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class ThermostatAccessory(Accessory):
|
|||||||
|
|
||||||
category = CATEGORY_THERMOSTAT
|
category = CATEGORY_THERMOSTAT
|
||||||
|
|
||||||
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
def __init__(self, driver, device, api_client, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Initialize the thermostat accessory.
|
Initialize the thermostat accessory.
|
||||||
|
|
||||||
@@ -25,9 +25,8 @@ class ThermostatAccessory(Accessory):
|
|||||||
driver: HAP driver instance
|
driver: HAP driver instance
|
||||||
device: Device object from DeviceRegistry
|
device: Device object from DeviceRegistry
|
||||||
api_client: ApiClient for sending commands
|
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
|
name = device.name
|
||||||
super().__init__(driver, name, *args, **kwargs)
|
super().__init__(driver, name, *args, **kwargs)
|
||||||
self.device = device
|
self.device = device
|
||||||
self.api_client = api_client
|
self.api_client = api_client
|
||||||
|
|||||||
@@ -50,26 +50,7 @@ class ApiClient:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get devices: {e}")
|
logger.error(f"Failed to get devices: {e}")
|
||||||
raise
|
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:
|
def get_device_state(self, device_id: str) -> Dict:
|
||||||
"""
|
"""
|
||||||
Get current state of a specific device.
|
Get current state of a specific device.
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ class Device:
|
|||||||
device_id: str
|
device_id: str
|
||||||
type: str # "light", "thermostat", "relay", "contact", "temp_humidity", "cover"
|
type: str # "light", "thermostat", "relay", "contact", "temp_humidity", "cover"
|
||||||
name: str # Short name from /devices
|
name: str # Short name from /devices
|
||||||
friendly_name: str # Display title from /layout (fallback to name)
|
homekit_aid: int # HomeKit Accessory ID
|
||||||
room: Optional[str] # Room name from layout
|
|
||||||
features: Dict[str, bool] # Feature flags (e.g., {"power": true, "brightness": true})
|
features: Dict[str, bool] # Feature flags (e.g., {"power": true, "brightness": true})
|
||||||
read_only: bool # True for sensors that don't accept commands
|
read_only: bool # True for sensors that don't accept commands
|
||||||
|
|
||||||
@@ -50,24 +49,7 @@ class DeviceRegistry:
|
|||||||
"""
|
"""
|
||||||
# Get devices and layout
|
# Get devices and layout
|
||||||
devices_data = api_client.get_devices()
|
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
|
# Create Device objects
|
||||||
devices = []
|
devices = []
|
||||||
for dev_data in devices_data:
|
for dev_data in devices_data:
|
||||||
@@ -76,8 +58,11 @@ class DeviceRegistry:
|
|||||||
logger.warning(f"Device without device_id: {dev_data}")
|
logger.warning(f"Device without device_id: {dev_data}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get layout info
|
# Check for required homekit_aid field
|
||||||
room_name, title = layout_map.get(device_id, (None, ''))
|
homekit_aid = dev_data.get('homekit_aid')
|
||||||
|
if homekit_aid is None:
|
||||||
|
logger.error(f"Device {device_id} is missing required homekit_aid field - skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
# Determine if read-only (sensors don't accept set commands)
|
# Determine if read-only (sensors don't accept set commands)
|
||||||
device_type = dev_data.get('type', '')
|
device_type = dev_data.get('type', '')
|
||||||
@@ -86,9 +71,8 @@ class DeviceRegistry:
|
|||||||
device = Device(
|
device = Device(
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
type=device_type,
|
type=device_type,
|
||||||
name=dev_data.get('name', device_id),
|
name=device_id,
|
||||||
friendly_name=title or dev_data.get('name', device_id),
|
homekit_aid=homekit_aid,
|
||||||
room=room_name,
|
|
||||||
features=dev_data.get('features', {}),
|
features=dev_data.get('features', {}),
|
||||||
read_only=read_only
|
read_only=read_only
|
||||||
)
|
)
|
||||||
|
|||||||
30
apps/homekit/docker-compose.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
services:
|
||||||
|
homekit-bridge:
|
||||||
|
image: gitea.hottis.de/wn/home-automation/homekit:0.5.0
|
||||||
|
build:
|
||||||
|
context: ../../
|
||||||
|
dockerfile: apps/homekit/Dockerfile
|
||||||
|
container_name: homekit-bridge
|
||||||
|
|
||||||
|
# Required for mDNS/Bonjour to work properly
|
||||||
|
network_mode: host
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- LOG_LEVEL=INFO
|
||||||
|
- HOMEKIT_NAME=Hottis Home Automation Bridge
|
||||||
|
- HOMEKIT_PIN=031-45-154
|
||||||
|
- HOMEKIT_PORT=51826
|
||||||
|
|
||||||
|
- API_BASE=http://homea2-api-internal.hottis.de
|
||||||
|
- HOMEKIT_API_TOKEN=
|
||||||
|
|
||||||
|
- HOMEKIT_PERSIST_FILE=/data/homekit.state
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- homekit_data:/data
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
homekit_data:
|
||||||
|
driver: local
|
||||||
@@ -31,8 +31,9 @@ from .api_client import ApiClient
|
|||||||
from .device_registry import DeviceRegistry
|
from .device_registry import DeviceRegistry
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -71,14 +72,11 @@ def build_bridge(driver: AccessoryDriver, api_client: ApiClient) -> Bridge:
|
|||||||
try:
|
try:
|
||||||
accessory = create_accessory_for_device(device, api_client, driver)
|
accessory = create_accessory_for_device(device, api_client, driver)
|
||||||
if accessory:
|
if accessory:
|
||||||
# Set room information in the accessory (HomeKit will use this for suggestions)
|
# Set AID from device configuration
|
||||||
if device.room:
|
accessory.aid = device.homekit_aid
|
||||||
# Store room info for potential future use
|
|
||||||
accessory._room_name = device.room
|
|
||||||
|
|
||||||
bridge.add_accessory(accessory)
|
bridge.add_accessory(accessory)
|
||||||
accessory_map[device.device_id] = accessory
|
accessory_map[device.device_id] = accessory
|
||||||
logger.info(f"Added accessory: {device.friendly_name} ({device.type}) in room: {device.room or 'Unknown'}")
|
logger.info(f"Added accessory: {device.name} ({device.type}, AID={device.homekit_aid}, {accessory.__class__.__name__})")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"No accessory mapping for device: {device.name} ({device.type})")
|
logger.warning(f"No accessory mapping for device: {device.name} ({device.type})")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -90,23 +88,6 @@ def build_bridge(driver: AccessoryDriver, api_client: ApiClient) -> Bridge:
|
|||||||
logger.info(f"Bridge built with {len(accessory_map)} accessories")
|
logger.info(f"Bridge built with {len(accessory_map)} accessories")
|
||||||
return bridge
|
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):
|
def create_accessory_for_device(device, api_client: ApiClient, driver: AccessoryDriver):
|
||||||
"""
|
"""
|
||||||
Create appropriate HomeKit accessory based on device type and features.
|
Create appropriate HomeKit accessory based on device type and features.
|
||||||
@@ -115,32 +96,30 @@ def create_accessory_for_device(device, api_client: ApiClient, driver: Accessory
|
|||||||
"""
|
"""
|
||||||
device_type = device.type
|
device_type = device.type
|
||||||
features = device.features
|
features = device.features
|
||||||
display_name = get_accessory_name(device)
|
|
||||||
|
|
||||||
# Light accessories
|
# Light accessories
|
||||||
if device_type == "light":
|
if device_type == "light":
|
||||||
if features.get("color_hsb"):
|
if features.get("color_hsb"):
|
||||||
return ColorLightAccessory(driver, device, api_client, display_name=display_name)
|
return ColorLightAccessory(driver, device, api_client)
|
||||||
elif features.get("brightness"):
|
elif features.get("brightness"):
|
||||||
return DimmableLightAccessory(driver, device, api_client, display_name=display_name)
|
return DimmableLightAccessory(driver, device, api_client)
|
||||||
else:
|
else:
|
||||||
return OnOffLightAccessory(driver, device, api_client, display_name=display_name)
|
return OnOffLightAccessory(driver, device, api_client)
|
||||||
|
|
||||||
# Thermostat
|
# Thermostat
|
||||||
elif device_type == "thermostat":
|
elif device_type == "thermostat":
|
||||||
return ThermostatAccessory(driver, device, api_client, display_name=display_name)
|
return ThermostatAccessory(driver, device, api_client)
|
||||||
|
|
||||||
# Contact sensor
|
# Contact sensor
|
||||||
elif device_type == "contact":
|
elif device_type == "contact":
|
||||||
return ContactAccessory(driver, device, api_client, display_name=display_name)
|
return ContactAccessory(driver, device, api_client)
|
||||||
|
|
||||||
# Temperature/Humidity sensor
|
# Temperature/Humidity sensor
|
||||||
elif device_type == "temp_humidity_sensor":
|
elif device_type == "temp_humidity_sensor":
|
||||||
return TempHumidityAccessory(driver, device, api_client, display_name=display_name)
|
return TempHumidityAccessory(driver, device, api_client)
|
||||||
|
|
||||||
# Relay/Outlet
|
# Relay/Outlet
|
||||||
elif device_type == "relay":
|
elif device_type == "relay":
|
||||||
return OutletAccessory(driver, device, api_client, display_name=display_name)
|
return OutletAccessory(driver, device, api_client)
|
||||||
|
|
||||||
# Cover/Blinds (optional)
|
# Cover/Blinds (optional)
|
||||||
elif device_type == "cover":
|
elif device_type == "cover":
|
||||||
|
|||||||
35
apps/pulsegen/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Pulsegen Dockerfile
|
||||||
|
# MQTT Pulse Generator 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
|
||||||
|
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 10001 -S app && \
|
||||||
|
adduser -u 10001 -S app -G app
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY apps/pulsegen/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/pulsegen/ /app/apps/pulsegen/
|
||||||
|
|
||||||
|
# Change ownership to app user
|
||||||
|
RUN chown -R app:app /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER app
|
||||||
|
|
||||||
|
# Run application
|
||||||
|
CMD ["python", "-m", "apps.pulsegen.main"]
|
||||||
53
apps/pulsegen/README.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Pulsegen
|
||||||
|
|
||||||
|
MQTT-basierte Pulse-Generator Applikation für Home Automation.
|
||||||
|
|
||||||
|
## Funktionen
|
||||||
|
|
||||||
|
- MQTT-Kommunikation über `aiomqtt`
|
||||||
|
- Automatische Reconnect-Logik
|
||||||
|
- Graceful shutdown (SIGTERM/SIGINT)
|
||||||
|
- JSON message parsing
|
||||||
|
- Konfigurierbar über Umgebungsvariablen
|
||||||
|
|
||||||
|
## Umgebungsvariablen
|
||||||
|
|
||||||
|
- `MQTT_BROKER`: MQTT Broker Hostname (default: `localhost`)
|
||||||
|
- `MQTT_PORT`: MQTT Broker Port (default: `1883`)
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
Lokal starten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/pulsegen
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # oder venv\Scripts\activate auf Windows
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f apps/pulsegen/Dockerfile -t pulsegen .
|
||||||
|
```
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -e MQTT_BROKER=172.16.2.16 -e MQTT_PORT=1883 pulsegen
|
||||||
|
```
|
||||||
|
|
||||||
|
## MQTT Topics
|
||||||
|
|
||||||
|
### Subscribed
|
||||||
|
|
||||||
|
- `pulsegen/command/#` - Kommandos für pulsegen
|
||||||
|
- `home/+/+/state` - Device state updates
|
||||||
|
|
||||||
|
### Published
|
||||||
|
|
||||||
|
- `pulsegen/status` - Status-Updates der Applikation
|
||||||
1
apps/pulsegen/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Pulsegen - MQTT pulse generator application."""
|
||||||
241
apps/pulsegen/main.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""Pulsegen - MQTT pulse generator application."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiomqtt import Client, Message
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
COIL_STATUS_PREFIX = "dt1/di"
|
||||||
|
COIL_STATUS_TOPIC = f"{COIL_STATUS_PREFIX}/+"
|
||||||
|
PULSEGEN_COMMAND_PREFIX = "pulsegen/command"
|
||||||
|
PULSEGEN_COMMAND_TOPIC = f"{PULSEGEN_COMMAND_PREFIX}/+/+"
|
||||||
|
COIL_COMMAND_PREFIX = "dt1/coil"
|
||||||
|
PULSEGEN_STATUS_PREFIX = "pulsegen/status"
|
||||||
|
|
||||||
|
COIL_STATUS_CACHE: dict[int, bool] = {}
|
||||||
|
|
||||||
|
def get_mqtt_settings() -> tuple[str, int]:
|
||||||
|
"""Get MQTT broker settings from environment variables.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (broker_host, broker_port)
|
||||||
|
"""
|
||||||
|
broker = os.getenv("MQTT_BROKER", "localhost")
|
||||||
|
port = int(os.getenv("MQTT_PORT", "1883"))
|
||||||
|
logger.info(f"MQTT settings: broker={broker}, port={port}")
|
||||||
|
return broker, port
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_message(message: Message, client: Client) -> None:
|
||||||
|
"""Handle incoming MQTT message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: MQTT message object
|
||||||
|
client: MQTT client instance
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = message.payload.decode()
|
||||||
|
logger.info(f"Received message on {message.topic}: {payload}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
topic = str(message.topic)
|
||||||
|
|
||||||
|
match topic.split("/"):
|
||||||
|
case [prefix, di, coil_id] if f"{prefix}/{di}" == COIL_STATUS_PREFIX:
|
||||||
|
try:
|
||||||
|
coil_num = int(coil_id)
|
||||||
|
except ValueError:
|
||||||
|
logger.debug(f"Invalid coil id in topic: {topic}")
|
||||||
|
return
|
||||||
|
|
||||||
|
state = payload.lower() in ("1", "true", "on")
|
||||||
|
COIL_STATUS_CACHE[coil_num] = state
|
||||||
|
logger.info(f"Updated coil {coil_num} status to {state}")
|
||||||
|
|
||||||
|
logger.info(f"Publishing pulsegen status for coil {coil_num}: {state}")
|
||||||
|
await client.publish(
|
||||||
|
topic=f"{PULSEGEN_STATUS_PREFIX}/{coil_num}",
|
||||||
|
payload="on" if state else "off",
|
||||||
|
qos=1,
|
||||||
|
retain=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
case [prefix, command, coil_in_id, coil_out_id] if f"{prefix}/{command}" == PULSEGEN_COMMAND_PREFIX:
|
||||||
|
try:
|
||||||
|
coil_in_id = int(coil_in_id)
|
||||||
|
coil_out_id = int(coil_out_id)
|
||||||
|
except ValueError:
|
||||||
|
logger.debug(f"Invalid coil id in topic: {topic}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
coil_state = COIL_STATUS_CACHE[coil_in_id]
|
||||||
|
except KeyError:
|
||||||
|
logger.debug(f"Coil {coil_in_id} status unknown, cannot process command")
|
||||||
|
return
|
||||||
|
|
||||||
|
cmd = payload.lower() in ("1", "true", "on")
|
||||||
|
|
||||||
|
if cmd == coil_state:
|
||||||
|
logger.info(f"Coil {coil_in_id} already in desired state {cmd}, ignoring command")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Received pulsegen command on {topic}: {coil_in_id=}, {coil_out_id=}, {cmd=}")
|
||||||
|
|
||||||
|
|
||||||
|
coil_cmd_topic = f"{COIL_COMMAND_PREFIX}/{coil_out_id}"
|
||||||
|
|
||||||
|
logger.info(f"Sending raising edge command: topic={coil_cmd_topic}")
|
||||||
|
await client.publish(
|
||||||
|
topic=coil_cmd_topic,
|
||||||
|
payload="1",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
|
logger.info(f"Sending falling edge command: topic={coil_cmd_topic}")
|
||||||
|
await client.publish(
|
||||||
|
topic=coil_cmd_topic,
|
||||||
|
payload="0",
|
||||||
|
qos=1,
|
||||||
|
retain=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
case _:
|
||||||
|
logger.debug(f"Ignoring message on unrelated topic: {topic}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Exception when handling payload: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error handling message: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def publish_example(client: Client) -> None:
|
||||||
|
"""Example function to publish MQTT messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: MQTT client instance
|
||||||
|
"""
|
||||||
|
topic = "pulsegen/status"
|
||||||
|
payload = {
|
||||||
|
"status": "running",
|
||||||
|
"timestamp": asyncio.get_event_loop().time()
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.publish(
|
||||||
|
topic=topic,
|
||||||
|
payload=json.dumps(payload),
|
||||||
|
qos=1
|
||||||
|
)
|
||||||
|
logger.info(f"Published to {topic}: {payload}")
|
||||||
|
|
||||||
|
|
||||||
|
async def mqtt_worker(shutdown_event: asyncio.Event) -> None:
|
||||||
|
"""Main MQTT worker loop.
|
||||||
|
|
||||||
|
Connects to MQTT broker, subscribes to topics, and processes messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
shutdown_event: Event to signal shutdown
|
||||||
|
"""
|
||||||
|
broker, port = get_mqtt_settings()
|
||||||
|
|
||||||
|
|
||||||
|
reconnect_interval = 5 # seconds
|
||||||
|
|
||||||
|
while not shutdown_event.is_set():
|
||||||
|
try:
|
||||||
|
logger.info(f"Connecting to MQTT broker {broker}:{port}...")
|
||||||
|
|
||||||
|
async with Client(
|
||||||
|
hostname=broker,
|
||||||
|
port=port,
|
||||||
|
identifier=f"pulsegen-{uuid.uuid4()}",
|
||||||
|
) as client:
|
||||||
|
logger.info("Connected to MQTT broker")
|
||||||
|
|
||||||
|
# Subscribe to topics
|
||||||
|
for topic in [PULSEGEN_COMMAND_TOPIC, COIL_STATUS_TOPIC]:
|
||||||
|
await client.subscribe(topic)
|
||||||
|
logger.info(f"Subscribed to {topic}")
|
||||||
|
|
||||||
|
# Publish startup message
|
||||||
|
await publish_example(client)
|
||||||
|
|
||||||
|
# Message loop with timeout to allow shutdown check
|
||||||
|
async for message in client.messages:
|
||||||
|
if shutdown_event.is_set():
|
||||||
|
logger.info("Shutdown event detected, breaking message loop")
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
await handle_message(message, client)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in message handler: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# If we exit the loop due to shutdown, break the reconnect loop too
|
||||||
|
if shutdown_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("MQTT worker cancelled")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MQTT error: {e}", exc_info=True)
|
||||||
|
if not shutdown_event.is_set():
|
||||||
|
logger.info(f"Reconnecting in {reconnect_interval} seconds...")
|
||||||
|
await asyncio.sleep(reconnect_interval)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
"""Main application entry point."""
|
||||||
|
logger.info("Starting pulsegen application...")
|
||||||
|
|
||||||
|
# Shutdown event for graceful shutdown
|
||||||
|
shutdown_event = asyncio.Event()
|
||||||
|
|
||||||
|
# Setup signal handlers
|
||||||
|
def signal_handler(sig: int) -> None:
|
||||||
|
logger.info(f"Received signal {sig}, initiating shutdown...")
|
||||||
|
shutdown_event.set()
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
loop.add_signal_handler(sig, lambda s=sig: signal_handler(s))
|
||||||
|
|
||||||
|
# Start MQTT worker
|
||||||
|
worker_task = asyncio.create_task(mqtt_worker(shutdown_event))
|
||||||
|
|
||||||
|
# Wait for shutdown signal
|
||||||
|
await shutdown_event.wait()
|
||||||
|
|
||||||
|
# Give worker a moment to finish gracefully
|
||||||
|
logger.info("Waiting for MQTT worker to finish...")
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(worker_task, timeout=5.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("MQTT worker did not finish in time, cancelling...")
|
||||||
|
worker_task.cancel()
|
||||||
|
try:
|
||||||
|
await worker_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info("Pulsegen application stopped")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
1
apps/pulsegen/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
aiomqtt==2.3.0
|
||||||
@@ -6,7 +6,7 @@ FROM python:3.14-alpine
|
|||||||
# Prevent Python from writing .pyc files and enable unbuffered output
|
# Prevent Python from writing .pyc files and enable unbuffered output
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
RULES_CONFIG=config/rules.yaml \
|
RULES_CONFIG=/app/config/rules.yaml \
|
||||||
MQTT_BROKER=172.16.2.16 \
|
MQTT_BROKER=172.16.2.16 \
|
||||||
MQTT_PORT=1883 \
|
MQTT_PORT=1883 \
|
||||||
REDIS_HOST=localhost \
|
REDIS_HOST=localhost \
|
||||||
|
|||||||
@@ -1,371 +0,0 @@
|
|||||||
# Rule Interface Documentation
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The rule interface provides a clean abstraction for implementing automation rules. Rules respond to device state changes and can publish commands, persist state, and log diagnostics.
|
|
||||||
|
|
||||||
## Core Components
|
|
||||||
|
|
||||||
### 1. RuleDescriptor
|
|
||||||
|
|
||||||
Configuration data for a rule instance (loaded from `rules.yaml`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
RuleDescriptor(
|
|
||||||
id="window_setback_wohnzimmer", # Unique rule ID
|
|
||||||
name="Fensterabsenkung Wohnzimmer", # Optional display name
|
|
||||||
type="window_setback@1.0", # Rule type + version
|
|
||||||
targets={ # Rule-specific targets
|
|
||||||
"rooms": ["Wohnzimmer"],
|
|
||||||
"contacts": ["kontakt_wohnzimmer_..."],
|
|
||||||
"thermostats": ["thermostat_wohnzimmer"]
|
|
||||||
},
|
|
||||||
params={ # Rule-specific parameters
|
|
||||||
"eco_target": 16.0,
|
|
||||||
"open_min_secs": 20
|
|
||||||
}
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. RedisState
|
|
||||||
|
|
||||||
Async state persistence with automatic reconnection and retry logic:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Initialize (done by rule engine)
|
|
||||||
redis_state = RedisState("redis://172.23.1.116:6379/8")
|
|
||||||
|
|
||||||
# Simple key-value with TTL
|
|
||||||
await ctx.redis.set("rules:my_rule:temp", "22.5", ttl_secs=3600)
|
|
||||||
value = await ctx.redis.get("rules:my_rule:temp") # Returns "22.5" or None
|
|
||||||
|
|
||||||
# Hash storage (for multiple related values)
|
|
||||||
await ctx.redis.hset("rules:my_rule:sensors", "bedroom", "open")
|
|
||||||
await ctx.redis.hset("rules:my_rule:sensors", "kitchen", "closed")
|
|
||||||
value = await ctx.redis.hget("rules:my_rule:sensors", "bedroom") # "open"
|
|
||||||
|
|
||||||
# TTL management
|
|
||||||
await ctx.redis.expire("rules:my_rule:temp", 7200) # Extend to 2 hours
|
|
||||||
|
|
||||||
# JSON helpers (for complex data)
|
|
||||||
import json
|
|
||||||
data = {"temp": 22.5, "humidity": 45}
|
|
||||||
await ctx.redis.set("rules:my_rule:data", ctx.redis._dumps(data))
|
|
||||||
stored = await ctx.redis.get("rules:my_rule:data")
|
|
||||||
parsed = ctx.redis._loads(stored) if stored else None
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Conventions:**
|
|
||||||
- Use prefix `rules:{rule_id}:` for all keys
|
|
||||||
- Example: `rules:window_setback_wohnzimmer:thermo:device_123:previous`
|
|
||||||
- TTL recommended for temporary state (previous temperatures, timers)
|
|
||||||
|
|
||||||
**Robustness Features:**
|
|
||||||
- Automatic retry with exponential backoff (default: 3 retries)
|
|
||||||
- Connection pooling (max 10 connections)
|
|
||||||
- Automatic reconnection on Redis restart
|
|
||||||
- Health checks every 30 seconds
|
|
||||||
- All operations wait and retry, no exceptions on temporary outages
|
|
||||||
|
|
||||||
### 3. MQTTClient
|
|
||||||
|
|
||||||
Async MQTT client with event normalization and command publishing:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Initialize (done by rule engine)
|
|
||||||
mqtt_client = MQTTClient(
|
|
||||||
broker="172.16.2.16",
|
|
||||||
port=1883,
|
|
||||||
client_id="rule_engine"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Subscribe and receive normalized events
|
|
||||||
async for event in mqtt_client.connect():
|
|
||||||
# Event structure:
|
|
||||||
# {
|
|
||||||
# "topic": "home/contact/sensor_1/state",
|
|
||||||
# "type": "state",
|
|
||||||
# "cap": "contact", # Capability (contact, thermostat, etc.)
|
|
||||||
# "device_id": "sensor_1",
|
|
||||||
# "payload": {"contact": "open"},
|
|
||||||
# "ts": "2025-11-11T10:30:45.123456"
|
|
||||||
# }
|
|
||||||
|
|
||||||
if event['cap'] == 'contact':
|
|
||||||
handle_contact(event)
|
|
||||||
elif event['cap'] == 'thermostat':
|
|
||||||
handle_thermostat(event)
|
|
||||||
|
|
||||||
# Publish commands (within async context)
|
|
||||||
await mqtt_client.publish_set_thermostat("thermostat_id", 22.5)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Subscriptions:**
|
|
||||||
- `home/contact/+/state` - All contact sensor state changes
|
|
||||||
- `home/thermostat/+/state` - All thermostat state changes
|
|
||||||
|
|
||||||
**Publishing:**
|
|
||||||
- Topic: `home/thermostat/{device_id}/set`
|
|
||||||
- Payload: `{"type":"thermostat","payload":{"target":22.5}}`
|
|
||||||
- QoS: 1 (at least once delivery)
|
|
||||||
|
|
||||||
**Robustness:**
|
|
||||||
- Automatic reconnection with exponential backoff
|
|
||||||
- Connection logging (connect/disconnect events)
|
|
||||||
- Clean session handling
|
|
||||||
|
|
||||||
### 4. MQTTPublisher (Legacy)
|
|
||||||
|
|
||||||
Simplified wrapper around MQTTClient for backward compatibility:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Set thermostat temperature
|
|
||||||
await ctx.mqtt.publish_set_thermostat("thermostat_wohnzimmer", 21.5)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. RuleContext
|
|
||||||
|
|
||||||
Runtime context provided to rules:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class RuleContext:
|
|
||||||
logger # Logger instance
|
|
||||||
mqtt # MQTTPublisher
|
|
||||||
redis # RedisState
|
|
||||||
now() -> datetime # Current timestamp
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Rule Abstract Base Class
|
|
||||||
|
|
||||||
All rules extend this:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyRule(Rule):
|
|
||||||
async def on_event(self, evt: dict, desc: RuleDescriptor, ctx: RuleContext) -> None:
|
|
||||||
# Event structure:
|
|
||||||
# {
|
|
||||||
# "topic": "home/contact/device_id/state",
|
|
||||||
# "type": "state",
|
|
||||||
# "cap": "contact",
|
|
||||||
# "device_id": "kontakt_wohnzimmer",
|
|
||||||
# "payload": {"contact": "open"},
|
|
||||||
# "ts": "2025-11-11T10:30:45.123456"
|
|
||||||
# }
|
|
||||||
|
|
||||||
device_id = evt['device_id']
|
|
||||||
cap = evt['cap']
|
|
||||||
|
|
||||||
if cap == 'contact':
|
|
||||||
contact_state = evt['payload'].get('contact')
|
|
||||||
# ... implement logic
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementing a New Rule
|
|
||||||
|
|
||||||
### Step 1: Create Rule Class
|
|
||||||
|
|
||||||
```python
|
|
||||||
from packages.rule_interface import Rule, RuleDescriptor, RuleContext
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
class MyCustomRule(Rule):
|
|
||||||
"""My custom automation rule."""
|
|
||||||
|
|
||||||
async def on_event(
|
|
||||||
self,
|
|
||||||
evt: dict[str, Any],
|
|
||||||
desc: RuleDescriptor,
|
|
||||||
ctx: RuleContext
|
|
||||||
) -> None:
|
|
||||||
"""Process device state changes."""
|
|
||||||
|
|
||||||
# 1. Extract event data
|
|
||||||
device_id = evt['device_id']
|
|
||||||
cap = evt['cap']
|
|
||||||
payload = evt['payload']
|
|
||||||
|
|
||||||
# 2. Filter to relevant devices
|
|
||||||
if device_id not in desc.targets.get('my_devices', []):
|
|
||||||
return
|
|
||||||
|
|
||||||
# 3. Implement logic
|
|
||||||
if cap == 'contact':
|
|
||||||
if payload.get('contact') == 'open':
|
|
||||||
# Do something
|
|
||||||
await ctx.mqtt.publish_set_thermostat(
|
|
||||||
'some_thermostat',
|
|
||||||
desc.params.get('temp', 20.0)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. Persist state if needed
|
|
||||||
state_key = f"rule:{desc.id}:device:{device_id}:state"
|
|
||||||
await ctx.redis.set(state_key, payload.get('contact'))
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Register in RULE_IMPLEMENTATIONS
|
|
||||||
|
|
||||||
```python
|
|
||||||
# In your rule module (e.g., my_custom_rule.py)
|
|
||||||
RULE_IMPLEMENTATIONS = {
|
|
||||||
'my_custom@1.0': MyCustomRule,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Configure in rules.yaml
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
rules:
|
|
||||||
- id: my_custom_living_room
|
|
||||||
name: My Custom Rule for Living Room
|
|
||||||
type: my_custom@1.0
|
|
||||||
targets:
|
|
||||||
my_devices:
|
|
||||||
- device_1
|
|
||||||
- device_2
|
|
||||||
params:
|
|
||||||
temp: 22.0
|
|
||||||
duration_secs: 300
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Idempotency
|
|
||||||
|
|
||||||
Rules MUST be idempotent - processing the same event multiple times should be safe:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Good: Idempotent
|
|
||||||
async def on_event(self, evt, desc, ctx):
|
|
||||||
if evt['payload'].get('contact') == 'open':
|
|
||||||
await ctx.mqtt.publish_set_thermostat('thermo', 16.0)
|
|
||||||
|
|
||||||
# Bad: Not idempotent (increments counter)
|
|
||||||
async def on_event(self, evt, desc, ctx):
|
|
||||||
counter = await ctx.redis.get('counter') or '0'
|
|
||||||
await ctx.redis.set('counter', str(int(counter) + 1))
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
Handle errors gracefully - the engine will catch and log exceptions:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def on_event(self, evt, desc, ctx):
|
|
||||||
try:
|
|
||||||
await ctx.mqtt.publish_set_thermostat('thermo', 16.0)
|
|
||||||
except Exception as e:
|
|
||||||
ctx.logger.error(f"Failed to set thermostat: {e}")
|
|
||||||
# Don't raise - let event processing continue
|
|
||||||
```
|
|
||||||
|
|
||||||
### State Keys
|
|
||||||
|
|
||||||
Use consistent naming for Redis keys:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Pattern: rule:{rule_id}:{category}:{device_id}:{field}
|
|
||||||
state_key = f"rule:{desc.id}:contact:{device_id}:state"
|
|
||||||
ts_key = f"rule:{desc.id}:contact:{device_id}:ts"
|
|
||||||
prev_key = f"rule:{desc.id}:thermo:{device_id}:previous"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logging
|
|
||||||
|
|
||||||
Use appropriate log levels:
|
|
||||||
|
|
||||||
```python
|
|
||||||
ctx.logger.debug("Detailed diagnostic info")
|
|
||||||
ctx.logger.info("Normal operation milestones")
|
|
||||||
ctx.logger.warning("Unexpected but handled situations")
|
|
||||||
ctx.logger.error("Errors that prevent operation")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Event Structure Reference
|
|
||||||
|
|
||||||
### Contact Sensor Event
|
|
||||||
|
|
||||||
```python
|
|
||||||
{
|
|
||||||
"topic": "home/contact/kontakt_wohnzimmer/state",
|
|
||||||
"type": "state",
|
|
||||||
"cap": "contact",
|
|
||||||
"device_id": "kontakt_wohnzimmer",
|
|
||||||
"payload": {
|
|
||||||
"contact": "open" # or "closed"
|
|
||||||
},
|
|
||||||
"ts": "2025-11-11T10:30:45.123456"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Thermostat Event
|
|
||||||
|
|
||||||
```python
|
|
||||||
{
|
|
||||||
"topic": "home/thermostat/thermostat_wohnzimmer/state",
|
|
||||||
"type": "state",
|
|
||||||
"cap": "thermostat",
|
|
||||||
"device_id": "thermostat_wohnzimmer",
|
|
||||||
"payload": {
|
|
||||||
"target": 21.0,
|
|
||||||
"current": 20.5,
|
|
||||||
"mode": "heat"
|
|
||||||
},
|
|
||||||
"ts": "2025-11-11T10:30:45.123456"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Rules
|
|
||||||
|
|
||||||
Rules can be tested independently of the engine:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
from packages.my_custom_rule import MyCustomRule
|
|
||||||
from packages.rule_interface import RuleDescriptor, RuleContext
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_my_rule():
|
|
||||||
# Setup
|
|
||||||
rule = MyCustomRule()
|
|
||||||
|
|
||||||
desc = RuleDescriptor(
|
|
||||||
id="test_rule",
|
|
||||||
type="my_custom@1.0",
|
|
||||||
targets={"my_devices": ["device_1"]},
|
|
||||||
params={"temp": 22.0}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mock context
|
|
||||||
ctx = RuleContext(
|
|
||||||
logger=MagicMock(),
|
|
||||||
mqtt_publisher=AsyncMock(),
|
|
||||||
redis_state=AsyncMock(),
|
|
||||||
now_fn=lambda: datetime.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Test event
|
|
||||||
evt = {
|
|
||||||
"device_id": "device_1",
|
|
||||||
"cap": "contact",
|
|
||||||
"payload": {"contact": "open"},
|
|
||||||
"ts": "2025-11-11T10:30:45.123456"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Execute
|
|
||||||
await rule.on_event(evt, desc, ctx)
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
ctx.mqtt.publish_set_thermostat.assert_called_once_with('some_thermostat', 22.0)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Extension Points
|
|
||||||
|
|
||||||
The interface is designed to be extended without modifying the engine:
|
|
||||||
|
|
||||||
1. **New rule types**: Just implement `Rule` and register in `RULE_IMPLEMENTATIONS`
|
|
||||||
2. **New MQTT commands**: Extend `MQTTPublisher` with new methods
|
|
||||||
3. **New state backends**: Implement `RedisState` interface with different storage
|
|
||||||
4. **Custom context**: Extend `RuleContext` with additional utilities
|
|
||||||
|
|
||||||
The engine only depends on the abstract interfaces, not specific implementations.
|
|
||||||
15
apps/static/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Static assets Dockerfile (minimal webserver for /static only)
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
WORKDIR /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Remove default nginx content
|
||||||
|
RUN rm -rf ./*
|
||||||
|
|
||||||
|
# Copy only static assets from the UI project
|
||||||
|
COPY apps/static/static/ ./
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Use default nginx config; caller can mount custom config if needed
|
||||||
290
apps/static/static/api-client.js
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
const requestBody = { type, payload };
|
||||||
|
console.log('API setDeviceState request:', requestBody);
|
||||||
|
await fetch(this.api(`/devices/${deviceId}/set`), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
BIN
apps/static/static/apple-touch-icon-114x114.png
Normal file
|
After Width: | Height: | Size: 618 B |
BIN
apps/static/static/apple-touch-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 639 B |
BIN
apps/static/static/apple-touch-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 827 B |
BIN
apps/static/static/apple-touch-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 884 B |
BIN
apps/static/static/apple-touch-icon-16x16.png
Normal file
|
After Width: | Height: | Size: 153 B |
BIN
apps/static/static/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 1018 B |
BIN
apps/static/static/apple-touch-icon-32x32.png
Normal file
|
After Width: | Height: | Size: 210 B |
BIN
apps/static/static/apple-touch-icon-57x57.png
Normal file
|
After Width: | Height: | Size: 336 B |
BIN
apps/static/static/apple-touch-icon-60x60.png
Normal file
|
After Width: | Height: | Size: 346 B |
BIN
apps/static/static/apple-touch-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 413 B |
BIN
apps/static/static/apple-touch-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 432 B |
BIN
apps/static/static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 1018 B |
4
apps/static/static/apple-touch-icon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="180" height="180" rx="40" fill="#667EEA"/>
|
||||||
|
<text x="90" y="130" font-size="80" text-anchor="middle" fill="white">🏡</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 244 B |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 721 B |
BIN
apps/static/static/garage-icon-114x114.png
Normal file
|
After Width: | Height: | Size: 519 B |
BIN
apps/static/static/garage-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 547 B |
BIN
apps/static/static/garage-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 641 B |
BIN
apps/static/static/garage-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 695 B |
BIN
apps/static/static/garage-icon-16x16.png
Normal file
|
After Width: | Height: | Size: 126 B |
BIN
apps/static/static/garage-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 808 B |
BIN
apps/static/static/garage-icon-32x32.png
Normal file
|
After Width: | Height: | Size: 192 B |
BIN
apps/static/static/garage-icon-57x57.png
Normal file
|
After Width: | Height: | Size: 257 B |
BIN
apps/static/static/garage-icon-60x60.png
Normal file
|
After Width: | Height: | Size: 271 B |
BIN
apps/static/static/garage-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 347 B |
BIN
apps/static/static/garage-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 368 B |
BIN
apps/static/static/garage-icon.png
Normal file
|
After Width: | Height: | Size: 808 B |
4
apps/static/static/garage-icon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="180" height="180" rx="40" fill="#667EEA"/>
|
||||||
|
<text x="90" y="130" font-size="80" text-anchor="middle" fill="white">🚗</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 244 B |
1
apps/static/static/index.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
empty
|
||||||
166
apps/static/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();
|
||||||
|
*/
|
||||||
@@ -1,49 +1,41 @@
|
|||||||
# UI Service Dockerfile
|
# UI Service Dockerfile (Application only, without static files)
|
||||||
# FastAPI + Jinja2 + HTMX Dashboard
|
|
||||||
|
|
||||||
FROM python:3.14-alpine
|
FROM python:3.14-alpine
|
||||||
|
|
||||||
# Prevent Python from writing .pyc files and enable unbuffered output
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
UI_PORT=8002 \
|
UI_PORT=8002 \
|
||||||
API_BASE=http://api:8001 \
|
API_BASE=http://api:8001 \
|
||||||
BASE_PATH=""
|
BASE_PATH="" \
|
||||||
|
STATIC_BASE=http://static:8080
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN addgroup -g 10001 -S app && \
|
RUN addgroup -g 10001 -S app && \
|
||||||
adduser -u 10001 -S app -G app
|
adduser -u 10001 -S app -G app
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
curl \
|
curl \
|
||||||
gcc \
|
gcc \
|
||||||
musl-dev \
|
musl-dev \
|
||||||
linux-headers
|
linux-headers
|
||||||
|
|
||||||
# Install Python dependencies
|
|
||||||
COPY apps/ui/requirements.txt /app/requirements.txt
|
COPY apps/ui/requirements.txt /app/requirements.txt
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application code
|
# Copy only Python code and templates, but exclude static assets
|
||||||
COPY apps/__init__.py /app/apps/__init__.py
|
COPY apps/__init__.py /app/apps/__init__.py
|
||||||
COPY apps/ui/ /app/apps/ui/
|
COPY apps/ui/__init__.py /app/apps/ui/__init__.py
|
||||||
|
COPY apps/ui/main.py /app/apps/ui/main.py
|
||||||
|
COPY apps/ui/api_client.py /app/apps/ui/api_client.py
|
||||||
|
COPY apps/ui/templates/ /app/apps/ui/templates/
|
||||||
|
|
||||||
# Change ownership to app user
|
|
||||||
RUN chown -R app:app /app
|
RUN chown -R app:app /app
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER app
|
USER app
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD curl -f http://localhost:${UI_PORT}/health || exit 1
|
CMD curl -f http://localhost:${UI_PORT}/health || exit 1
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8002
|
EXPOSE 8002
|
||||||
|
|
||||||
# Run application
|
|
||||||
CMD ["python", "-m", "uvicorn", "apps.ui.main:app", "--host", "0.0.0.0", "--port", "8002"]
|
CMD ["python", "-m", "uvicorn", "apps.ui.main:app", "--host", "0.0.0.0", "--port", "8002"]
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
# UI Service - Docker
|
|
||||||
|
|
||||||
FastAPI + Jinja2 + HTMX Dashboard für Home Automation
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t ui:dev -f apps/ui/Dockerfile .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Run
|
|
||||||
|
|
||||||
### Lokal
|
|
||||||
```bash
|
|
||||||
docker run --rm -p 8002:8002 -e API_BASE=http://localhost:8001 ui:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Compose
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
ui:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: apps/ui/Dockerfile
|
|
||||||
ports:
|
|
||||||
- "8002:8002"
|
|
||||||
environment:
|
|
||||||
- API_BASE=http://api:8001
|
|
||||||
depends_on:
|
|
||||||
- api
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kubernetes
|
|
||||||
```yaml
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: ui
|
|
||||||
spec:
|
|
||||||
replicas: 2
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: ui
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: ui
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: ui
|
|
||||||
image: ui:dev
|
|
||||||
ports:
|
|
||||||
- containerPort: 8002
|
|
||||||
env:
|
|
||||||
- name: API_BASE
|
|
||||||
value: "http://api-service:8001"
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: 8002
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 10
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: 8002
|
|
||||||
initialDelaySeconds: 3
|
|
||||||
periodSeconds: 5
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "128Mi"
|
|
||||||
cpu: "100m"
|
|
||||||
limits:
|
|
||||||
memory: "256Mi"
|
|
||||||
cpu: "500m"
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: ui-service
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: ui
|
|
||||||
ports:
|
|
||||||
- protocol: TCP
|
|
||||||
port: 8002
|
|
||||||
targetPort: 8002
|
|
||||||
type: LoadBalancer
|
|
||||||
```
|
|
||||||
|
|
||||||
## Umgebungsvariablen
|
|
||||||
|
|
||||||
| Variable | Default | Beschreibung |
|
|
||||||
|----------|---------|--------------|
|
|
||||||
| `API_BASE` | `http://api:8001` | URL des API-Services |
|
|
||||||
| `UI_PORT` | `8002` | Port der UI-Anwendung |
|
|
||||||
| `PYTHONDONTWRITEBYTECODE` | `1` | Keine .pyc Files |
|
|
||||||
| `PYTHONUNBUFFERED` | `1` | Unbuffered Output |
|
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
- `GET /` - Dashboard
|
|
||||||
- `GET /health` - Health Check
|
|
||||||
- `GET /dashboard` - Dashboard (alias)
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
- Container läuft als **non-root** User `app` (UID: 10001)
|
|
||||||
- Minimales Python 3.11-slim Base Image
|
|
||||||
- Keine unnötigen System-Pakete
|
|
||||||
- Health Check integriert
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- ✅ FastAPI Backend
|
|
||||||
- ✅ Jinja2 Templates
|
|
||||||
- ✅ HTMX für reactive UI
|
|
||||||
- ✅ Server-Sent Events (SSE)
|
|
||||||
- ✅ Responsive Design
|
|
||||||
- ✅ Docker & Kubernetes ready
|
|
||||||
- ✅ Health Check Endpoint
|
|
||||||
- ✅ Non-root Container
|
|
||||||
- ✅ Configurable API Backend
|
|
||||||
|
|
||||||
## Entwicklung
|
|
||||||
|
|
||||||
### Lokales Testing
|
|
||||||
```bash
|
|
||||||
# Build
|
|
||||||
docker build -t ui:dev -f apps/ui/Dockerfile .
|
|
||||||
|
|
||||||
# Run
|
|
||||||
docker run -d --name ui-test -p 8002:8002 -e API_BASE=http://localhost:8001 ui:dev
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
docker logs -f ui-test
|
|
||||||
|
|
||||||
# Health Check
|
|
||||||
curl http://localhost:8002/health
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
docker stop ui-test && docker rm ui-test
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
```bash
|
|
||||||
bash /tmp/test_ui_dockerfile.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Container startet nicht
|
|
||||||
```bash
|
|
||||||
docker logs ui-test
|
|
||||||
```
|
|
||||||
|
|
||||||
### Health Check schlägt fehl
|
|
||||||
```bash
|
|
||||||
docker exec ui-test curl http://localhost:8002/health
|
|
||||||
```
|
|
||||||
|
|
||||||
### API_BASE nicht korrekt
|
|
||||||
```bash
|
|
||||||
docker logs ui-test 2>&1 | grep "UI using API_BASE"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Non-root Verifizieren
|
|
||||||
```bash
|
|
||||||
docker exec ui-test id
|
|
||||||
# Sollte zeigen: uid=10001(app) gid=10001(app)
|
|
||||||
```
|
|
||||||
118
apps/ui/main.py
@@ -5,7 +5,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
@@ -16,9 +16,11 @@ logger = logging.getLogger(__name__)
|
|||||||
# Read configuration from environment variables
|
# Read configuration from environment variables
|
||||||
API_BASE = os.getenv("API_BASE", "http://localhost:8001")
|
API_BASE = os.getenv("API_BASE", "http://localhost:8001")
|
||||||
BASE_PATH = os.getenv("BASE_PATH", "") # e.g., "/ui" for reverse proxy
|
BASE_PATH = os.getenv("BASE_PATH", "") # e.g., "/ui" for reverse proxy
|
||||||
|
STATIC_BASE = os.getenv("STATIC_BASE", "/static")
|
||||||
|
|
||||||
print(f"UI using API_BASE: {API_BASE}")
|
print(f"UI using API_BASE: {API_BASE}")
|
||||||
print(f"UI using BASE_PATH: {BASE_PATH}")
|
print(f"UI using BASE_PATH: {BASE_PATH}")
|
||||||
|
print(f"UI using STATIC_BASE: {STATIC_BASE}")
|
||||||
|
|
||||||
def api_url(path: str) -> str:
|
def api_url(path: str) -> str:
|
||||||
"""Helper function to construct API URLs.
|
"""Helper function to construct API URLs.
|
||||||
@@ -43,12 +45,53 @@ app = FastAPI(
|
|||||||
templates_dir = Path(__file__).parent / "templates"
|
templates_dir = Path(__file__).parent / "templates"
|
||||||
templates = Jinja2Templates(directory=str(templates_dir))
|
templates = Jinja2Templates(directory=str(templates_dir))
|
||||||
|
|
||||||
|
# Make STATIC_BASE available in all templates
|
||||||
|
templates.env.globals["STATIC_BASE"] = STATIC_BASE
|
||||||
|
|
||||||
# Setup static files
|
# Setup static files
|
||||||
static_dir = Path(__file__).parent / "static"
|
static_dir = Path(__file__).parent / "static"
|
||||||
static_dir.mkdir(exist_ok=True)
|
static_dir.mkdir(exist_ok=True)
|
||||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/apple-touch-icon.png")
|
||||||
|
async def apple_touch_icon():
|
||||||
|
"""Serve Apple Touch Icon with proper headers."""
|
||||||
|
icon_path = static_dir / "apple-touch-icon.png"
|
||||||
|
return FileResponse(
|
||||||
|
path=icon_path,
|
||||||
|
media_type="image/png",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "public, max-age=31536000",
|
||||||
|
"Content-Type": "image/png"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/favicon.ico")
|
||||||
|
async def favicon():
|
||||||
|
"""Serve favicon."""
|
||||||
|
icon_path = static_dir / "apple-touch-icon.png"
|
||||||
|
return FileResponse(
|
||||||
|
path=icon_path,
|
||||||
|
media_type="image/png"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/manifest.json")
|
||||||
|
async def manifest():
|
||||||
|
"""Serve Web App Manifest with proper headers."""
|
||||||
|
manifest_path = static_dir / "manifest.json"
|
||||||
|
return FileResponse(
|
||||||
|
path=manifest_path,
|
||||||
|
media_type="application/manifest+json",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "public, max-age=86400",
|
||||||
|
"Content-Type": "application/manifest+json"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health() -> JSONResponse:
|
async def health() -> JSONResponse:
|
||||||
"""Health check endpoint for Kubernetes/Docker.
|
"""Health check endpoint for Kubernetes/Docker.
|
||||||
@@ -60,7 +103,8 @@ async def health() -> JSONResponse:
|
|||||||
"status": "ok",
|
"status": "ok",
|
||||||
"service": "ui",
|
"service": "ui",
|
||||||
"api_base": API_BASE,
|
"api_base": API_BASE,
|
||||||
"base_path": BASE_PATH
|
"base_path": BASE_PATH,
|
||||||
|
"static_base": STATIC_BASE,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -74,7 +118,75 @@ async def index(request: Request) -> HTMLResponse:
|
|||||||
Returns:
|
Returns:
|
||||||
HTMLResponse: Rendered dashboard
|
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)
|
@app.get("/dashboard", response_class=HTMLResponse)
|
||||||
|
|||||||
@@ -4,7 +4,18 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Home Automation</title>
|
<title>Home Automation</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
|
||||||
|
<!-- Apple Touch Icon -->
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="{{ STATIC_BASE }}/apple-touch-icon.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="{{ STATIC_BASE }}/apple-touch-icon.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="120x120" href="{{ STATIC_BASE }}/apple-touch-icon.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="{{ STATIC_BASE }}/apple-touch-icon.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="{{ STATIC_BASE }}/apple-touch-icon.png">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ STATIC_BASE }}/favicon.svg">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Dashboard">
|
||||||
|
<meta name="theme-color" content="#667eea">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
1066
apps/ui/templates/device.html
Normal file
730
apps/ui/templates/garage.html
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
<!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>
|
||||||
|
|
||||||
|
<!-- Apple Touch Icon -->
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="{{ STATIC_BASE }}/garage-icon-180x180.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="{{ STATIC_BASE }}/garage-icon-152x152.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="120x120" href="{{ STATIC_BASE }}/garage-icon-120x120.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="76x76" href="{{ STATIC_BASE }}/garage-icon-76x76.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="{{ STATIC_BASE }}/garage-icon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="{{ STATIC_BASE }}/garage-icon-16x16.png">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Garage">
|
||||||
|
<meta name="theme-color" content="#667eea">
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-section {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
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: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 100px;
|
||||||
|
height: 50px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 25px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch.on {
|
||||||
|
background: #34c759;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 4px;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch.on::after {
|
||||||
|
transform: translateX(50px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch:active::after {
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-section h4 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-values {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-value.full-width {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-value.half-width {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-value {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(102, 126, 234, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 8px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-section {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-value {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-value {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-values {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-row {
|
||||||
|
gap: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
#error-container:empty {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
</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_BASE }}/types.js"></script>
|
||||||
|
<script src="{{ STATIC_BASE }}/api-client.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Device IDs for garage devices
|
||||||
|
const GARAGE_DEVICES = [
|
||||||
|
'power_relay_caroutlet',
|
||||||
|
'powermeter_caroutlet',
|
||||||
|
'sensor_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 only the relay device (it will include the powermeter)
|
||||||
|
const relayDevice = garageDevices.find(d => d.device_id === 'power_relay_caroutlet');
|
||||||
|
if (relayDevice) {
|
||||||
|
const deviceSection = createDeviceSection(relayDevice);
|
||||||
|
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 fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
// Create separate sections for each component
|
||||||
|
renderDeviceContent(fragment, device);
|
||||||
|
|
||||||
|
return fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDeviceContent(container, device) {
|
||||||
|
// Render all content as separate device sections for Car Outlet
|
||||||
|
if (device.device_id === 'power_relay_caroutlet') {
|
||||||
|
// 1. Header section
|
||||||
|
const headerSection = document.createElement('div');
|
||||||
|
headerSection.className = 'device-section';
|
||||||
|
headerSection.innerHTML = `
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; gap: 12px;">
|
||||||
|
<div style="font-size: 32px;">⚡</div>
|
||||||
|
<div style="font-size: 20px; font-weight: 600; color: #333;">Car Outlet</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(headerSection);
|
||||||
|
|
||||||
|
// 2. Control section
|
||||||
|
const controlSection = document.createElement('div');
|
||||||
|
controlSection.className = 'device-section';
|
||||||
|
controlSection.dataset.deviceId = device.device_id;
|
||||||
|
renderOutletControls(controlSection, device);
|
||||||
|
container.appendChild(controlSection);
|
||||||
|
|
||||||
|
// 3. Feedback section
|
||||||
|
const feedbackDevice = Object.values(devicesData).find(d => d.device_id === 'sensor_caroutlet');
|
||||||
|
if (feedbackDevice) {
|
||||||
|
const feedbackSection = document.createElement('div');
|
||||||
|
feedbackSection.className = 'device-section';
|
||||||
|
feedbackSection.dataset.deviceId = feedbackDevice.device_id;
|
||||||
|
renderFeedbackDisplay(feedbackSection, feedbackDevice);
|
||||||
|
container.appendChild(feedbackSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Powermeter section
|
||||||
|
const powermeterDevice = Object.values(devicesData).find(d => d.device_id === 'powermeter_caroutlet');
|
||||||
|
if (powermeterDevice) {
|
||||||
|
const powermeterSection = document.createElement('div');
|
||||||
|
powermeterSection.className = 'device-section';
|
||||||
|
renderThreePhasePowerDisplay(powermeterSection, powermeterDevice);
|
||||||
|
container.appendChild(powermeterSection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOutletControls(container, device) {
|
||||||
|
const controlGroup = document.createElement('div');
|
||||||
|
controlGroup.style.textAlign = 'center';
|
||||||
|
|
||||||
|
const state = deviceStates[device.device_id];
|
||||||
|
const currentPower = state?.power === 'on';
|
||||||
|
|
||||||
|
const toggleSwitch = document.createElement('button');
|
||||||
|
toggleSwitch.className = `toggle-switch ${currentPower ? 'on' : ''}`;
|
||||||
|
toggleSwitch.onclick = () => {
|
||||||
|
const currentState = deviceStates[device.device_id]?.power === 'on';
|
||||||
|
toggleOutlet(device.device_id, currentState ? 'off' : 'on');
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'toggle-label';
|
||||||
|
label.textContent = currentPower ? 'Ein' : 'Aus';
|
||||||
|
|
||||||
|
controlGroup.appendChild(toggleSwitch);
|
||||||
|
controlGroup.appendChild(label);
|
||||||
|
|
||||||
|
container.appendChild(controlGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFeedbackDisplay(container, device) {
|
||||||
|
const state = deviceStates[device.device_id] || {};
|
||||||
|
const controlGroup = document.createElement('div');
|
||||||
|
controlGroup.style.textAlign = 'center';
|
||||||
|
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'toggle-label';
|
||||||
|
|
||||||
|
console.log(`Rendering feedback for ${device.device_id}:`, state);
|
||||||
|
|
||||||
|
if (state.contact === 'closed') {
|
||||||
|
label.textContent = 'Schütz ✅ eingeschaltet';
|
||||||
|
} else {
|
||||||
|
label.textContent = 'Schütz 🅾️ ausgeschaltet';
|
||||||
|
}
|
||||||
|
|
||||||
|
controlGroup.appendChild(label);
|
||||||
|
container.appendChild(controlGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderThreePhasePowerDisplay(container, device) {
|
||||||
|
const state = deviceStates[device.device_id] || {};
|
||||||
|
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
container.appendChild(overviewGrid);
|
||||||
|
|
||||||
|
const phaseTitle = document.createElement('h4');
|
||||||
|
phaseTitle.style.margin = '20px 0 8px 0';
|
||||||
|
phaseTitle.style.fontSize = '16px';
|
||||||
|
phaseTitle.style.fontWeight = '600';
|
||||||
|
phaseTitle.style.color = '#333';
|
||||||
|
container.appendChild(phaseTitle);
|
||||||
|
|
||||||
|
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 full-width">
|
||||||
|
<span class="value" id="phase1-power-${device.device_id}">${state.phase1_power?.toFixed(0) || '--'}</span>
|
||||||
|
<span class="unit">W</span>
|
||||||
|
</div>
|
||||||
|
<div class="phase-row">
|
||||||
|
<div class="phase-value half-width">
|
||||||
|
<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 half-width">
|
||||||
|
<span class="value" id="phase1-current-${device.device_id}">${state.phase1_current?.toFixed(2) || '--'}</span>
|
||||||
|
<span class="unit">A</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="phase-section">
|
||||||
|
<h4>Phase 2</h4>
|
||||||
|
<div class="phase-values">
|
||||||
|
<div class="phase-value full-width">
|
||||||
|
<span class="value" id="phase2-power-${device.device_id}">${state.phase2_power?.toFixed(0) || '--'}</span>
|
||||||
|
<span class="unit">W</span>
|
||||||
|
</div>
|
||||||
|
<div class="phase-row">
|
||||||
|
<div class="phase-value half-width">
|
||||||
|
<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 half-width">
|
||||||
|
<span class="value" id="phase2-current-${device.device_id}">${state.phase2_current?.toFixed(2) || '--'}</span>
|
||||||
|
<span class="unit">A</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="phase-section">
|
||||||
|
<h4>Phase 3</h4>
|
||||||
|
<div class="phase-values">
|
||||||
|
<div class="phase-value full-width">
|
||||||
|
<span class="value" id="phase3-power-${device.device_id}">${state.phase3_power?.toFixed(0) || '--'}</span>
|
||||||
|
<span class="unit">W</span>
|
||||||
|
</div>
|
||||||
|
<div class="phase-row">
|
||||||
|
<div class="phase-value half-width">
|
||||||
|
<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 half-width">
|
||||||
|
<span class="value" id="phase3-current-${device.device_id}">${state.phase3_current?.toFixed(2) || '--'}</span>
|
||||||
|
<span class="unit">A</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(phaseGrid);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleOutlet(deviceId, newState) {
|
||||||
|
try {
|
||||||
|
const device = devicesData[deviceId];
|
||||||
|
await sendCommand(deviceId, {
|
||||||
|
type: device.type,
|
||||||
|
payload: { 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCommand(deviceId, payload) {
|
||||||
|
const device = devicesData[deviceId];
|
||||||
|
await window.apiClient.setDeviceState(deviceId, device.type, payload.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (deviceId) {
|
||||||
|
case 'power_relay_caroutlet':
|
||||||
|
updateOutletUI(deviceId, state);
|
||||||
|
break;
|
||||||
|
case 'sensor_caroutlet':
|
||||||
|
updateFeedbackDisplay(deviceId, state);
|
||||||
|
break;
|
||||||
|
case 'powermeter_caroutlet':
|
||||||
|
updateThreePhasePowerUI(deviceId, state);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOutletUI(deviceId, state) {
|
||||||
|
const section = document.querySelector(`[data-device-id="${deviceId}"]`);
|
||||||
|
if (!section) return;
|
||||||
|
|
||||||
|
const toggleSwitch = section.querySelector('.toggle-switch');
|
||||||
|
const label = section.querySelector('.toggle-label');
|
||||||
|
|
||||||
|
if (toggleSwitch && label && state.power) {
|
||||||
|
const isOn = state.power === 'on';
|
||||||
|
toggleSwitch.className = `toggle-switch ${isOn ? 'on' : ''}`;
|
||||||
|
label.textContent = isOn ? 'Ein' : 'Aus';
|
||||||
|
|
||||||
|
// Update state display in separate card
|
||||||
|
const cards = section.querySelectorAll('.card');
|
||||||
|
if (cards.length >= 3) { // Header, Control, State
|
||||||
|
const stateCard = cards[2];
|
||||||
|
stateCard.innerHTML = `
|
||||||
|
<div style="font-size: 18px; font-weight: 600; color: ${isOn ? '#34c759' : '#666'};">
|
||||||
|
Status: ${isOn ? 'Eingeschaltet' : 'Ausgeschaltet'}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFeedbackDisplay(deviceId, state) {
|
||||||
|
const section = document.querySelector(`[data-device-id="${deviceId}"]`);
|
||||||
|
if (!section) return;
|
||||||
|
|
||||||
|
const label = section.querySelector('.toggle-label');
|
||||||
|
|
||||||
|
if (label) {
|
||||||
|
const isOn = state.contact === 'closed';
|
||||||
|
label.textContent = isOn ? 'Schütz ✅ eingeschaltet' : 'Schütz 🅾️ ausgeschaltet';
|
||||||
|
|
||||||
|
// Update state display in separate card
|
||||||
|
const cards = section.querySelectorAll('.card');
|
||||||
|
if (cards.length >= 3) { // Header, Control, State
|
||||||
|
const stateCard = cards[2];
|
||||||
|
stateCard.innerHTML = `
|
||||||
|
<div style="font-size: 18px; font-weight: 600; color: ${isOn ? '#34c759' : '#666'};">
|
||||||
|
Status: ${isOn ? 'Eingeschaltet' : 'Ausgeschaltet'}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -4,6 +4,18 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Home Automation</title>
|
<title>Home Automation</title>
|
||||||
|
|
||||||
|
<!-- Apple Touch Icon -->
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="{{ STATIC_BASE }}/apple-touch-icon-180x180.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="{{ STATIC_BASE }}/apple-touch-icon-152x152.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="120x120" href="{{ STATIC_BASE }}/apple-touch-icon-120x120.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="76x76" href="{{ STATIC_BASE }}/apple-touch-icon-76x76.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="{{ STATIC_BASE }}/apple-touch-icon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="{{ STATIC_BASE }}/apple-touch-icon-16x16.png">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Home Automation">
|
||||||
|
<meta name="theme-color" content="#667eea">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -464,3 +476,4 @@
|
|||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
487
apps/ui/templates/room.html
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
<!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>
|
||||||
|
|
||||||
|
<!-- Apple Touch Icon -->
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="{{ STATIC_BASE }}/apple-touch-icon.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="{{ STATIC_BASE }}/apple-touch-icon.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="120x120" href="{{ STATIC_BASE }}/apple-touch-icon.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="{{ STATIC_BASE }}/apple-touch-icon.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="{{ STATIC_BASE }}/apple-touch-icon.png">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="{{ room_name }}">
|
||||||
|
<meta name="theme-color" content="#667eea">
|
||||||
|
<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_BASE }}/types.js"></script>
|
||||||
|
<script src="{{ STATIC_BASE }}/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>
|
||||||
320
apps/ui/templates/rooms.html
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
<!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>
|
||||||
|
|
||||||
|
<!-- Apple Touch Icon -->
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="{{ STATIC_BASE }}/apple-touch-icon-180x180.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="{{ STATIC_BASE }}/apple-touch-icon-152x152.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="120x120" href="{{ STATIC_BASE }}/apple-touch-icon-120x120.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="76x76" href="{{ STATIC_BASE }}/apple-touch-icon-76x76.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="{{ STATIC_BASE }}/apple-touch-icon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="{{ STATIC_BASE }}/apple-touch-icon-16x16.png">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Räume">
|
||||||
|
<meta name="theme-color" content="#667eea">
|
||||||
|
<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_BASE }}/types.js"></script>
|
||||||
|
<script src="{{ STATIC_BASE }}/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,16 +1,8 @@
|
|||||||
version: 1
|
version: 1
|
||||||
mqtt:
|
|
||||||
broker: "172.16.2.16"
|
|
||||||
port: 1883
|
|
||||||
client_id: "home-automation-abstraction"
|
|
||||||
username: null
|
|
||||||
password: null
|
|
||||||
keepalive: 60
|
|
||||||
redis:
|
|
||||||
url: "redis://172.23.1.116:6379/8"
|
|
||||||
channel: "ui:updates"
|
|
||||||
devices:
|
devices:
|
||||||
- device_id: lampe_semeniere_wohnzimmer
|
- device_id: lampe_semeniere_wohnzimmer
|
||||||
|
homekit_aid: 2
|
||||||
|
name: Semeniere
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -24,7 +16,33 @@ devices:
|
|||||||
ieee_address: "0xf0d1b8000015480b"
|
ieee_address: "0xf0d1b8000015480b"
|
||||||
model: "AC10691"
|
model: "AC10691"
|
||||||
vendor: "OSRAM"
|
vendor: "OSRAM"
|
||||||
|
- device_id: stehlampe_esszimmer_spiegel
|
||||||
|
homekit_aid: 3
|
||||||
|
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
|
||||||
|
homekit_aid: 4
|
||||||
|
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
|
- device_id: grosse_lampe_wohnzimmer
|
||||||
|
homekit_aid: 5
|
||||||
|
name: grosse Lampe
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -39,6 +57,8 @@ devices:
|
|||||||
model: "AC10691"
|
model: "AC10691"
|
||||||
vendor: "OSRAM"
|
vendor: "OSRAM"
|
||||||
- device_id: lampe_naehtischchen_wohnzimmer
|
- device_id: lampe_naehtischchen_wohnzimmer
|
||||||
|
homekit_aid: 6
|
||||||
|
name: Nähtischchen
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -52,21 +72,9 @@ devices:
|
|||||||
ieee_address: "0x842e14fffee560ee"
|
ieee_address: "0x842e14fffee560ee"
|
||||||
model: "HG06337"
|
model: "HG06337"
|
||||||
vendor: "Lidl"
|
vendor: "Lidl"
|
||||||
- device_id: kleine_lampe_rechts_esszimmer
|
|
||||||
type: relay
|
|
||||||
cap_version: "relay@1.0.0"
|
|
||||||
technology: zigbee2mqtt
|
|
||||||
features:
|
|
||||||
power: true
|
|
||||||
topics:
|
|
||||||
state: "zigbee2mqtt/0xf0d1b80000156645"
|
|
||||||
set: "zigbee2mqtt/0xf0d1b80000156645/set"
|
|
||||||
metadata:
|
|
||||||
friendly_name: "kleine Lampe rechts Esszimmer"
|
|
||||||
ieee_address: "0xf0d1b80000156645"
|
|
||||||
model: "AC10691"
|
|
||||||
vendor: "OSRAM"
|
|
||||||
- device_id: kleine_lampe_links_esszimmer
|
- device_id: kleine_lampe_links_esszimmer
|
||||||
|
homekit_aid: 7
|
||||||
|
name: kleine Lampe
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -81,6 +89,8 @@ devices:
|
|||||||
model: "AC10691"
|
model: "AC10691"
|
||||||
vendor: "OSRAM"
|
vendor: "OSRAM"
|
||||||
- device_id: leselampe_esszimmer
|
- device_id: leselampe_esszimmer
|
||||||
|
homekit_aid: 8
|
||||||
|
name: Leselampe
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -96,6 +106,8 @@ devices:
|
|||||||
model: "LED1842G3"
|
model: "LED1842G3"
|
||||||
vendor: "IKEA"
|
vendor: "IKEA"
|
||||||
- device_id: medusalampe_schlafzimmer
|
- device_id: medusalampe_schlafzimmer
|
||||||
|
homekit_aid: 9
|
||||||
|
name: Medusa-Lampe
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -110,7 +122,9 @@ devices:
|
|||||||
model: "AC10691"
|
model: "AC10691"
|
||||||
vendor: "OSRAM"
|
vendor: "OSRAM"
|
||||||
- device_id: sportlicht_am_fernseher_studierzimmer
|
- device_id: sportlicht_am_fernseher_studierzimmer
|
||||||
|
homekit_aid: 10
|
||||||
type: light
|
type: light
|
||||||
|
name: am Fernseher
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
features:
|
features:
|
||||||
@@ -126,6 +140,8 @@ devices:
|
|||||||
model: "LED1733G7"
|
model: "LED1733G7"
|
||||||
vendor: "IKEA"
|
vendor: "IKEA"
|
||||||
- device_id: deckenlampe_schlafzimmer
|
- device_id: deckenlampe_schlafzimmer
|
||||||
|
homekit_aid: 11
|
||||||
|
name: Deckenlampe
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -141,6 +157,8 @@ devices:
|
|||||||
model: "8718699688882"
|
model: "8718699688882"
|
||||||
vendor: "Philips"
|
vendor: "Philips"
|
||||||
- device_id: bettlicht_wolfgang
|
- device_id: bettlicht_wolfgang
|
||||||
|
homekit_aid: 12
|
||||||
|
name: Bettlicht Wolfgang
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -156,6 +174,8 @@ devices:
|
|||||||
model: "9290020399"
|
model: "9290020399"
|
||||||
vendor: "Philips"
|
vendor: "Philips"
|
||||||
- device_id: bettlicht_patty
|
- device_id: bettlicht_patty
|
||||||
|
homekit_aid: 13
|
||||||
|
name: Bettlicht Patty
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -171,6 +191,8 @@ devices:
|
|||||||
model: "9290020399"
|
model: "9290020399"
|
||||||
vendor: "Philips"
|
vendor: "Philips"
|
||||||
- device_id: schranklicht_hinten_patty
|
- device_id: schranklicht_hinten_patty
|
||||||
|
homekit_aid: 14
|
||||||
|
name: Schranklicht hinten
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -186,6 +208,8 @@ devices:
|
|||||||
model: "8718699673147"
|
model: "8718699673147"
|
||||||
vendor: "Philips"
|
vendor: "Philips"
|
||||||
- device_id: schranklicht_vorne_patty
|
- device_id: schranklicht_vorne_patty
|
||||||
|
homekit_aid: 15
|
||||||
|
name: Schranklicht vorne
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -200,6 +224,8 @@ devices:
|
|||||||
model: "AC10691"
|
model: "AC10691"
|
||||||
vendor: "OSRAM"
|
vendor: "OSRAM"
|
||||||
- device_id: leselampe_patty
|
- device_id: leselampe_patty
|
||||||
|
homekit_aid: 16
|
||||||
|
name: Leselampe
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -215,6 +241,8 @@ devices:
|
|||||||
model: "8718699673147"
|
model: "8718699673147"
|
||||||
vendor: "Philips"
|
vendor: "Philips"
|
||||||
- device_id: deckenlampe_esszimmer
|
- device_id: deckenlampe_esszimmer
|
||||||
|
homekit_aid: 17
|
||||||
|
name: Deckenlampe
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -229,38 +257,9 @@ devices:
|
|||||||
ieee_address: "0x0017880108a03e45"
|
ieee_address: "0x0017880108a03e45"
|
||||||
model: "929002241201"
|
model: "929002241201"
|
||||||
vendor: "Philips"
|
vendor: "Philips"
|
||||||
- device_id: standlampe_esszimmer
|
|
||||||
type: light
|
|
||||||
cap_version: "light@1.2.0"
|
|
||||||
technology: zigbee2mqtt
|
|
||||||
features:
|
|
||||||
power: true
|
|
||||||
brightness: true
|
|
||||||
color_temperature: true
|
|
||||||
topics:
|
|
||||||
state: "zigbee2mqtt/0xbc33acfffe21f547"
|
|
||||||
set: "zigbee2mqtt/0xbc33acfffe21f547/set"
|
|
||||||
metadata:
|
|
||||||
friendly_name: "Standlampe Esszimmer"
|
|
||||||
ieee_address: "0xbc33acfffe21f547"
|
|
||||||
model: "LED1732G11"
|
|
||||||
vendor: "IKEA"
|
|
||||||
- device_id: haustuer
|
|
||||||
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
|
- device_id: deckenlampe_flur_oben
|
||||||
|
homekit_aid: 18
|
||||||
|
name: Deckenlampe oben
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -277,6 +276,8 @@ devices:
|
|||||||
model: "929003099001"
|
model: "929003099001"
|
||||||
vendor: "Philips"
|
vendor: "Philips"
|
||||||
- device_id: kueche_deckenlampe
|
- device_id: kueche_deckenlampe
|
||||||
|
homekit_aid: 19
|
||||||
|
name: Deckenlampe
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -292,6 +293,8 @@ devices:
|
|||||||
model: "929002469202"
|
model: "929002469202"
|
||||||
vendor: "Philips"
|
vendor: "Philips"
|
||||||
- device_id: sportlicht_tisch
|
- device_id: sportlicht_tisch
|
||||||
|
homekit_aid: 20
|
||||||
|
name: am Tisch
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -307,6 +310,8 @@ devices:
|
|||||||
model: "4058075729063"
|
model: "4058075729063"
|
||||||
vendor: "LEDVANCE"
|
vendor: "LEDVANCE"
|
||||||
- device_id: sportlicht_regal
|
- device_id: sportlicht_regal
|
||||||
|
homekit_aid: 21
|
||||||
|
name: am Regal
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -322,6 +327,8 @@ devices:
|
|||||||
model: "4058075729063"
|
model: "4058075729063"
|
||||||
vendor: "LEDVANCE"
|
vendor: "LEDVANCE"
|
||||||
- device_id: licht_flur_oben_am_spiegel
|
- device_id: licht_flur_oben_am_spiegel
|
||||||
|
homekit_aid: 22
|
||||||
|
name: Spiegel
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -338,6 +345,8 @@ devices:
|
|||||||
model: "LED1732G11"
|
model: "LED1732G11"
|
||||||
vendor: "IKEA"
|
vendor: "IKEA"
|
||||||
- device_id: experimentlabtest
|
- device_id: experimentlabtest
|
||||||
|
homekit_aid: 23
|
||||||
|
name: Test Lampe
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -353,6 +362,8 @@ devices:
|
|||||||
model: "4058075208421"
|
model: "4058075208421"
|
||||||
vendor: "LEDVANCE"
|
vendor: "LEDVANCE"
|
||||||
- device_id: thermostat_wolfgang
|
- device_id: thermostat_wolfgang
|
||||||
|
homekit_aid: 24
|
||||||
|
name: Heizung
|
||||||
type: thermostat
|
type: thermostat
|
||||||
cap_version: "thermostat@1.0.0"
|
cap_version: "thermostat@1.0.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -371,6 +382,8 @@ devices:
|
|||||||
model: "GS361A-H04"
|
model: "GS361A-H04"
|
||||||
vendor: "Siterwell"
|
vendor: "Siterwell"
|
||||||
- device_id: thermostat_kueche
|
- device_id: thermostat_kueche
|
||||||
|
homekit_aid: 25
|
||||||
|
name: Heizung
|
||||||
type: thermostat
|
type: thermostat
|
||||||
cap_version: "thermostat@1.0.0"
|
cap_version: "thermostat@1.0.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -389,6 +402,8 @@ devices:
|
|||||||
model: "GS361A-H04"
|
model: "GS361A-H04"
|
||||||
vendor: "Siterwell"
|
vendor: "Siterwell"
|
||||||
- device_id: thermostat_schlafzimmer
|
- device_id: thermostat_schlafzimmer
|
||||||
|
homekit_aid: 26
|
||||||
|
name: Heizung
|
||||||
type: thermostat
|
type: thermostat
|
||||||
cap_version: "thermostat@1.0.0"
|
cap_version: "thermostat@1.0.0"
|
||||||
technology: max
|
technology: max
|
||||||
@@ -407,6 +422,8 @@ devices:
|
|||||||
peer_id: "42"
|
peer_id: "42"
|
||||||
channel: "1"
|
channel: "1"
|
||||||
- device_id: thermostat_esszimmer
|
- device_id: thermostat_esszimmer
|
||||||
|
homekit_aid: 27
|
||||||
|
name: Heizung
|
||||||
type: thermostat
|
type: thermostat
|
||||||
cap_version: "thermostat@1.0.0"
|
cap_version: "thermostat@1.0.0"
|
||||||
technology: max
|
technology: max
|
||||||
@@ -425,6 +442,8 @@ devices:
|
|||||||
peer_id: "45"
|
peer_id: "45"
|
||||||
channel: "1"
|
channel: "1"
|
||||||
- device_id: thermostat_wohnzimmer
|
- device_id: thermostat_wohnzimmer
|
||||||
|
homekit_aid: 28
|
||||||
|
name: Heizung
|
||||||
type: thermostat
|
type: thermostat
|
||||||
cap_version: "thermostat@1.0.0"
|
cap_version: "thermostat@1.0.0"
|
||||||
technology: max
|
technology: max
|
||||||
@@ -443,6 +462,8 @@ devices:
|
|||||||
peer_id: "46"
|
peer_id: "46"
|
||||||
channel: "1"
|
channel: "1"
|
||||||
- device_id: thermostat_patty
|
- device_id: thermostat_patty
|
||||||
|
homekit_aid: 29
|
||||||
|
name: Heizung
|
||||||
type: thermostat
|
type: thermostat
|
||||||
cap_version: "thermostat@1.0.0"
|
cap_version: "thermostat@1.0.0"
|
||||||
technology: max
|
technology: max
|
||||||
@@ -461,6 +482,8 @@ devices:
|
|||||||
peer_id: "39"
|
peer_id: "39"
|
||||||
channel: "1"
|
channel: "1"
|
||||||
- device_id: thermostat_bad_oben
|
- device_id: thermostat_bad_oben
|
||||||
|
homekit_aid: 30
|
||||||
|
name: Heizung
|
||||||
type: thermostat
|
type: thermostat
|
||||||
cap_version: "thermostat@1.0.0"
|
cap_version: "thermostat@1.0.0"
|
||||||
technology: max
|
technology: max
|
||||||
@@ -479,6 +502,8 @@ devices:
|
|||||||
peer_id: "41"
|
peer_id: "41"
|
||||||
channel: "1"
|
channel: "1"
|
||||||
- device_id: thermostat_bad_unten
|
- device_id: thermostat_bad_unten
|
||||||
|
homekit_aid: 31
|
||||||
|
name: Heizung
|
||||||
type: thermostat
|
type: thermostat
|
||||||
cap_version: "thermostat@1.0.0"
|
cap_version: "thermostat@1.0.0"
|
||||||
technology: max
|
technology: max
|
||||||
@@ -497,6 +522,8 @@ devices:
|
|||||||
peer_id: "48"
|
peer_id: "48"
|
||||||
channel: "1"
|
channel: "1"
|
||||||
- device_id: sterne_wohnzimmer
|
- device_id: sterne_wohnzimmer
|
||||||
|
homekit_aid: 32
|
||||||
|
name: Sterne
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
@@ -511,215 +538,257 @@ devices:
|
|||||||
model: "AC10691"
|
model: "AC10691"
|
||||||
vendor: "OSRAM"
|
vendor: "OSRAM"
|
||||||
- device_id: kontakt_schlafzimmer_strasse
|
- device_id: kontakt_schlafzimmer_strasse
|
||||||
|
homekit_aid: 33
|
||||||
|
name: Fenster
|
||||||
type: contact
|
type: contact
|
||||||
name: Kontakt Schlafzimmer Straße
|
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
technology: max
|
technology: max
|
||||||
topics:
|
topics:
|
||||||
state: homegear/instance1/plain/52/1/STATE
|
state: homegear/instance1/plain/52/1/STATE
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: kontakt_esszimmer_strasse_rechts
|
- device_id: kontakt_esszimmer_strasse_rechts
|
||||||
|
homekit_aid: 34
|
||||||
type: contact
|
type: contact
|
||||||
name: Kontakt Esszimmer Straße rechts
|
name: Fenster rechts
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
technology: max
|
technology: max
|
||||||
topics:
|
topics:
|
||||||
state: homegear/instance1/plain/26/1/STATE
|
state: homegear/instance1/plain/26/1/STATE
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: kontakt_esszimmer_strasse_links
|
- device_id: kontakt_esszimmer_strasse_links
|
||||||
|
homekit_aid: 35
|
||||||
|
name: Fenster links
|
||||||
type: contact
|
type: contact
|
||||||
name: Kontakt Esszimmer Straße links
|
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
technology: max
|
technology: max
|
||||||
topics:
|
topics:
|
||||||
state: homegear/instance1/plain/27/1/STATE
|
state: homegear/instance1/plain/27/1/STATE
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: kontakt_wohnzimmer_garten_rechts
|
- device_id: kontakt_wohnzimmer_garten_rechts
|
||||||
|
homekit_aid: 36
|
||||||
|
name: Fenster rechts
|
||||||
type: contact
|
type: contact
|
||||||
name: Kontakt Wohnzimmer Garten rechts
|
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
technology: max
|
technology: max
|
||||||
topics:
|
topics:
|
||||||
state: homegear/instance1/plain/28/1/STATE
|
state: homegear/instance1/plain/28/1/STATE
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: kontakt_wohnzimmer_garten_links
|
- device_id: kontakt_wohnzimmer_garten_links
|
||||||
|
homekit_aid: 37
|
||||||
|
name: Fenster links
|
||||||
type: contact
|
type: contact
|
||||||
name: Kontakt Wohnzimmer Garten links
|
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
technology: max
|
technology: max
|
||||||
topics:
|
topics:
|
||||||
state: homegear/instance1/plain/29/1/STATE
|
state: homegear/instance1/plain/29/1/STATE
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: kontakt_kueche_garten_fenster
|
- device_id: kontakt_kueche_garten_fenster
|
||||||
|
homekit_aid: 38
|
||||||
|
name: Fenster Garten
|
||||||
type: contact
|
type: contact
|
||||||
name: Kontakt Küche Garten Fenster
|
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
topics:
|
topics:
|
||||||
state: zigbee2mqtt/0x00158d008b332785
|
state: zigbee2mqtt/0x00158d008b332785
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: kontakt_kueche_garten_tuer
|
- device_id: kontakt_kueche_garten_tuer
|
||||||
|
homekit_aid: 39
|
||||||
type: contact
|
type: contact
|
||||||
name: Kontakt Küche Garten Tür
|
name: Terrassentür
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
topics:
|
topics:
|
||||||
state: zigbee2mqtt/0x00158d008b332788
|
state: zigbee2mqtt/0x00158d008b332788
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: kontakt_kueche_strasse_rechts
|
- device_id: kontakt_kueche_strasse_rechts
|
||||||
|
homekit_aid: 40
|
||||||
|
name: Fenster Straße rechts
|
||||||
type: contact
|
type: contact
|
||||||
name: Kontakt Küche Straße rechts
|
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
topics:
|
topics:
|
||||||
state: zigbee2mqtt/0x00158d008b151803
|
state: zigbee2mqtt/0x00158d008b151803
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: kontakt_kueche_strasse_links
|
- device_id: kontakt_kueche_strasse_links
|
||||||
|
homekit_aid: 41
|
||||||
|
name: Fenster Straße links
|
||||||
type: contact
|
type: contact
|
||||||
name: Kontakt Küche Straße links
|
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
topics:
|
topics:
|
||||||
state: zigbee2mqtt/0x00158d008b331d0b
|
state: zigbee2mqtt/0x00158d008b331d0b
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: kontakt_patty_garten_rechts
|
- device_id: kontakt_patty_garten_rechts
|
||||||
|
homekit_aid: 42
|
||||||
type: contact
|
type: contact
|
||||||
name: Kontakt Patty Garten rechts
|
name: Fenster Garten rechts
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
technology: max
|
technology: max
|
||||||
topics:
|
topics:
|
||||||
state: homegear/instance1/plain/18/1/STATE
|
state: homegear/instance1/plain/18/1/STATE
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: kontakt_patty_garten_links
|
- device_id: kontakt_patty_garten_links
|
||||||
|
homekit_aid: 43
|
||||||
|
homekit_aid: 43
|
||||||
type: contact
|
type: contact
|
||||||
name: Kontakt Patty Garten links
|
name: Fenster Garten links
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
technology: max
|
technology: max
|
||||||
topics:
|
topics:
|
||||||
state: homegear/instance1/plain/22/1/STATE
|
state: homegear/instance1/plain/22/1/STATE
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: kontakt_patty_strasse
|
- device_id: kontakt_patty_strasse
|
||||||
|
homekit_aid: 44
|
||||||
type: contact
|
type: contact
|
||||||
name: Kontakt Patty Straße
|
name: Fenster Straße
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
topics:
|
topics:
|
||||||
state: zigbee2mqtt/0x00158d000af457cf
|
state: zigbee2mqtt/0x00158d000af457cf
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: kontakt_wolfgang_garten
|
- device_id: kontakt_wolfgang_garten
|
||||||
|
homekit_aid: 45
|
||||||
type: contact
|
type: contact
|
||||||
name: Kontakt Wolfgang Garten
|
name: Fenster
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
topics:
|
topics:
|
||||||
state: zigbee2mqtt/0x00158d008b3328da
|
state: zigbee2mqtt/0x00158d008b3328da
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: kontakt_bad_oben_strasse
|
- device_id: kontakt_bad_oben_strasse
|
||||||
|
homekit_aid: 46
|
||||||
type: contact
|
type: contact
|
||||||
name: Kontakt Bad Oben Straße
|
name: Fenster
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
topics:
|
topics:
|
||||||
state: zigbee2mqtt/0x00158d008b333aec
|
state: zigbee2mqtt/0x00158d008b333aec
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: kontakt_bad_unten_strasse
|
- device_id: kontakt_bad_unten_strasse
|
||||||
|
homekit_aid: 47
|
||||||
type: contact
|
type: contact
|
||||||
name: Kontakt Bad Unten Straße
|
name: Fenster
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
technology: max
|
technology: max
|
||||||
topics:
|
topics:
|
||||||
state: homegear/instance1/plain/44/1/STATE
|
state: homegear/instance1/plain/44/1/STATE
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: sensor_schlafzimmer
|
- device_id: sensor_schlafzimmer
|
||||||
|
homekit_aid: 48
|
||||||
type: temp_humidity_sensor
|
type: temp_humidity_sensor
|
||||||
name: Temperatur & Luftfeuchte
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
topics:
|
topics:
|
||||||
state: zigbee2mqtt/0x00158d00043292dc
|
state: zigbee2mqtt/0x00158d00043292dc
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: sensor_wohnzimmer
|
- device_id: sensor_wohnzimmer
|
||||||
|
homekit_aid: 49
|
||||||
type: temp_humidity_sensor
|
type: temp_humidity_sensor
|
||||||
name: Temperatur & Luftfeuchte
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
topics:
|
topics:
|
||||||
state: zigbee2mqtt/0x00158d0008975707
|
state: zigbee2mqtt/0x00158d0008975707
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: sensor_kueche
|
- device_id: sensor_kueche
|
||||||
|
homekit_aid: 50
|
||||||
type: temp_humidity_sensor
|
type: temp_humidity_sensor
|
||||||
name: Temperatur & Luftfeuchte
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
topics:
|
topics:
|
||||||
state: zigbee2mqtt/0x00158d00083299bb
|
state: zigbee2mqtt/0x00158d00083299bb
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: sensor_arbeitszimmer_patty
|
- device_id: sensor_arbeitszimmer_patty
|
||||||
|
homekit_aid: 51
|
||||||
type: temp_humidity_sensor
|
type: temp_humidity_sensor
|
||||||
name: Temperatur & Luftfeuchte
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
topics:
|
topics:
|
||||||
state: zigbee2mqtt/0x00158d0003f052b7
|
state: zigbee2mqtt/0x00158d0003f052b7
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: sensor_arbeitszimmer_wolfgang
|
- device_id: sensor_arbeitszimmer_wolfgang
|
||||||
|
homekit_aid: 52
|
||||||
type: temp_humidity_sensor
|
type: temp_humidity_sensor
|
||||||
name: Temperatur & Luftfeuchte
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
topics:
|
topics:
|
||||||
state: zigbee2mqtt/0x00158d000543fb99
|
state: zigbee2mqtt/0x00158d000543fb99
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: sensor_bad_oben
|
- device_id: sensor_bad_oben
|
||||||
|
homekit_aid: 53
|
||||||
type: temp_humidity_sensor
|
type: temp_humidity_sensor
|
||||||
name: Temperatur & Luftfeuchte
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
topics:
|
topics:
|
||||||
state: zigbee2mqtt/0x00158d00093e8987
|
state: zigbee2mqtt/0x00158d00093e8987
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: sensor_bad_unten
|
- device_id: sensor_bad_unten
|
||||||
|
homekit_aid: 54
|
||||||
type: temp_humidity_sensor
|
type: temp_humidity_sensor
|
||||||
name: Temperatur & Luftfeuchte
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
topics:
|
topics:
|
||||||
state: zigbee2mqtt/0x00158d00093e662a
|
state: zigbee2mqtt/0x00158d00093e662a
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: sensor_flur
|
- device_id: sensor_flur
|
||||||
|
homekit_aid: 55
|
||||||
type: temp_humidity_sensor
|
type: temp_humidity_sensor
|
||||||
name: Temperatur & Luftfeuchte
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
topics:
|
topics:
|
||||||
state: zigbee2mqtt/0x00158d000836ccc6
|
state: zigbee2mqtt/0x00158d000836ccc6
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: sensor_waschkueche
|
- device_id: sensor_waschkueche
|
||||||
|
homekit_aid: 56
|
||||||
type: temp_humidity_sensor
|
type: temp_humidity_sensor
|
||||||
name: Temperatur & Luftfeuchte
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
topics:
|
topics:
|
||||||
state: zigbee2mqtt/0x00158d000449f3bc
|
state: zigbee2mqtt/0x00158d000449f3bc
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: sensor_sportzimmer
|
- device_id: sensor_sportzimmer
|
||||||
|
homekit_aid: 57
|
||||||
type: temp_humidity_sensor
|
type: temp_humidity_sensor
|
||||||
name: Temperatur & Luftfeuchte
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
technology: zigbee2mqtt
|
technology: zigbee2mqtt
|
||||||
topics:
|
topics:
|
||||||
state: zigbee2mqtt/0x00158d0009421422
|
state: zigbee2mqtt/0x00158d0009421422
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: licht_spuele_kueche
|
- device_id: licht_spuele_kueche
|
||||||
|
homekit_aid: 58
|
||||||
|
name: Spüle
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
technology: shelly
|
technology: shelly
|
||||||
features:
|
features:
|
||||||
power: true
|
power: true
|
||||||
topics:
|
topics:
|
||||||
set: "shellies/LightKitchenSink/relay/0/command"
|
set: "shellies/shellyplug-s-DED4E4/relay/0/command"
|
||||||
state: "shellies/LightKitchenSink/relay/0"
|
state: "shellies/shellyplug-s-DED4E4/relay/0"
|
||||||
|
- device_id: putzlicht_kueche
|
||||||
|
homekit_aid: 59
|
||||||
|
name: Putzlicht
|
||||||
|
type: light
|
||||||
|
cap_version: "light@1.2.0"
|
||||||
|
technology: zigbee2mqtt
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
brightness: true
|
||||||
|
topics:
|
||||||
|
state: "zigbee2mqtt/0xa4c138563834406c"
|
||||||
|
set: "zigbee2mqtt/0xa4c138563834406c/set"
|
||||||
- device_id: licht_schrank_esszimmer
|
- device_id: licht_schrank_esszimmer
|
||||||
|
homekit_aid: 60
|
||||||
|
name: Schrank
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
technology: shelly
|
technology: shelly
|
||||||
@@ -729,7 +798,9 @@ devices:
|
|||||||
set: "shellies/schrankesszimmer/relay/0/command"
|
set: "shellies/schrankesszimmer/relay/0/command"
|
||||||
state: "shellies/schrankesszimmer/relay/0"
|
state: "shellies/schrankesszimmer/relay/0"
|
||||||
- device_id: licht_regal_wohnzimmer
|
- device_id: licht_regal_wohnzimmer
|
||||||
|
homekit_aid: 61
|
||||||
type: relay
|
type: relay
|
||||||
|
name: Regal
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
technology: shelly
|
technology: shelly
|
||||||
features:
|
features:
|
||||||
@@ -737,16 +808,9 @@ devices:
|
|||||||
topics:
|
topics:
|
||||||
set: "shellies/wohnzimmer-regal/relay/0/command"
|
set: "shellies/wohnzimmer-regal/relay/0/command"
|
||||||
state: "shellies/wohnzimmer-regal/relay/0"
|
state: "shellies/wohnzimmer-regal/relay/0"
|
||||||
- device_id: licht_flur_schrank
|
|
||||||
type: relay
|
|
||||||
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
|
- device_id: licht_terasse
|
||||||
|
homekit_aid: 62
|
||||||
|
name: Terrasse
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
technology: shelly
|
technology: shelly
|
||||||
@@ -755,7 +819,226 @@ devices:
|
|||||||
topics:
|
topics:
|
||||||
set: "shellies/lichtterasse/relay/0/command"
|
set: "shellies/lichtterasse/relay/0/command"
|
||||||
state: "shellies/lichtterasse/relay/0"
|
state: "shellies/lichtterasse/relay/0"
|
||||||
|
- device_id: kugellampe_patty
|
||||||
|
homekit_aid: 63
|
||||||
|
name: Kugellampe Patty
|
||||||
|
type: light
|
||||||
|
cap_version: "light@1.2.0"
|
||||||
|
technology: zigbee2mqtt
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
brightness: true
|
||||||
|
topics:
|
||||||
|
state: "zigbee2mqtt/0xbc33acfffe21f547"
|
||||||
|
set: "zigbee2mqtt/0xbc33acfffe21f547/set"
|
||||||
|
- device_id: kueche_fensterbank_licht
|
||||||
|
homekit_aid: 64
|
||||||
|
name: Fensterbank Küche
|
||||||
|
type: light
|
||||||
|
cap_version: "light@1.2.0"
|
||||||
|
technology: zigbee2mqtt
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
brightness: true
|
||||||
|
topics:
|
||||||
|
state: "zigbee2mqtt/0xf0d1b8000017515d"
|
||||||
|
set: "zigbee2mqtt/0xf0d1b8000017515d/set"
|
||||||
|
- device_id: licht_kommode_schlafzimmer
|
||||||
|
homekit_aid: 65
|
||||||
|
name: Kommode Schlafzimmer
|
||||||
|
type: relay
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: tasmota
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
topics:
|
||||||
|
set: "cmnd/tasmota/04/POWER"
|
||||||
|
state: "stat/tasmota/04/POWER"
|
||||||
|
- device_id: licht_fensterbank_esszimmer
|
||||||
|
homekit_aid: 66
|
||||||
|
name: Fensterbank Esszimmer
|
||||||
|
type: relay
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: tasmota
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
topics:
|
||||||
|
set: "cmnd/tasmota/02/POWER"
|
||||||
|
state: "stat/tasmota/02/POWER"
|
||||||
|
- device_id: licht_schreibtisch_patty
|
||||||
|
homekit_aid: 67
|
||||||
|
name: Schreibtisch Patty
|
||||||
|
type: relay
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: tasmota
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
topics:
|
||||||
|
set: "cmnd/tasmota/03/POWER"
|
||||||
|
state: "stat/tasmota/03/POWER"
|
||||||
|
- device_id: kugeln_regal_flur
|
||||||
|
homekit_aid: 68
|
||||||
|
name: Kugeln Regal Flur
|
||||||
|
type: relay
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: tasmota
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
topics:
|
||||||
|
set: "cmnd/tasmota/01/POWER"
|
||||||
|
state: "stat/tasmota/01/POWER"
|
||||||
|
- device_id: schrank_flur_haustuer
|
||||||
|
homekit_aid: 69
|
||||||
|
name: Schrank Flur Haustür
|
||||||
|
type: relay
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: tasmota
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
topics:
|
||||||
|
set: "cmnd/tasmota/05/POWER"
|
||||||
|
state: "stat/tasmota/05/POWER"
|
||||||
|
- device_id: gartenlicht_vorne
|
||||||
|
homekit_aid: 70
|
||||||
|
name: Gartenlicht vorne
|
||||||
|
type: relay
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: tasmota
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
topics:
|
||||||
|
set: "cmnd/tasmota/06/POWER"
|
||||||
|
state: "stat/tasmota/06/POWER"
|
||||||
|
|
||||||
|
- device_id: power_relay_caroutlet
|
||||||
|
homekit_aid: 71
|
||||||
|
name: Car Outlet
|
||||||
|
type: relay
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: hottis_pv_modbus
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
topics:
|
||||||
|
set: "IoT/Car/Control"
|
||||||
|
state: "IoT/Car/Control/State"
|
||||||
|
- device_id: powermeter_caroutlet
|
||||||
|
homekit_aid: 72
|
||||||
|
name: Car Outlet
|
||||||
|
type: three_phase_powermeter
|
||||||
|
cap_version: "three_phase_powermeter@1.0.0"
|
||||||
|
technology: hottis_pv_modbus
|
||||||
|
topics:
|
||||||
|
state: "IoT/Car/Values"
|
||||||
|
- device_id: sensor_caroutlet
|
||||||
|
homekit_aid: 73
|
||||||
|
name: Car Outlet
|
||||||
|
type: contact
|
||||||
|
cap_version: contact_sensor@1.0.0
|
||||||
|
technology: hottis_pv_modbus
|
||||||
|
topics:
|
||||||
|
state: IoT/Car/Feedback/State
|
||||||
|
|
||||||
|
- device_id: schranklicht_flur_vor_kueche
|
||||||
|
homekit_aid: 74
|
||||||
|
name: Schranklicht Flur vor Küche
|
||||||
|
type: light
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: zigbee2mqtt
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
topics:
|
||||||
|
state: "zigbee2mqtt/0xf0d1b80000155a1f"
|
||||||
|
set: "zigbee2mqtt/0xf0d1b80000155a1f/set"
|
||||||
|
- device_id: deckenlampe_wohnzimmer
|
||||||
|
homekit_aid: 75
|
||||||
|
name: Deckenlampe Wohnzimmer
|
||||||
|
type: light
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: zigbee2mqtt
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
brightness: true
|
||||||
|
topics:
|
||||||
|
state: "zigbee2mqtt/0x842e14fffea72027"
|
||||||
|
set: "zigbee2mqtt/0x842e14fffea72027/set"
|
||||||
|
|
||||||
|
|
||||||
|
- device_id: keller_flur_licht
|
||||||
|
homekit_aid: 76
|
||||||
|
name: Keller Flur Licht
|
||||||
|
type: relay
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: hottis_wago_modbus
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
topics:
|
||||||
|
set: "pulsegen/command/10/21"
|
||||||
|
state: "pulsegen/status/10"
|
||||||
|
- device_id: waschkueche_licht
|
||||||
|
homekit_aid: 77
|
||||||
|
name: Waschküche Licht
|
||||||
|
type: relay
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: hottis_wago_modbus
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
topics:
|
||||||
|
set: "pulsegen/command/8/22"
|
||||||
|
state: "pulsegen/status/8"
|
||||||
|
- device_id: werkstatt_licht
|
||||||
|
homekit_aid: 78
|
||||||
|
name: Werkstatt Licht
|
||||||
|
type: relay
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: hottis_wago_modbus
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
topics:
|
||||||
|
set: "pulsegen/command/7/19"
|
||||||
|
state: "pulsegen/status/7"
|
||||||
|
- device_id: sportzimmer_licht
|
||||||
|
homekit_aid: 79
|
||||||
|
name: Sportzimmer Licht
|
||||||
|
type: relay
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: hottis_wago_modbus
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
topics:
|
||||||
|
set: "pulsegen/command/9/20"
|
||||||
|
state: "pulsegen/status/9"
|
||||||
|
- device_id: deckenlampe_patty
|
||||||
|
homekit_aid: 80
|
||||||
|
name: Deckenlampe Patty
|
||||||
|
type: relay
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: hottis_wago_modbus
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
topics:
|
||||||
|
set: "pulsegen/command/4/16"
|
||||||
|
state: "pulsegen/status/4"
|
||||||
|
- device_id: regallampe_esszimmer
|
||||||
|
homekit_aid: 81
|
||||||
|
name: Regallampe Esszimmer
|
||||||
|
type: relay
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: hottis_wifi_relay
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
topics:
|
||||||
|
set: "IoT/WifiRelay1/State"
|
||||||
|
state: "IoT/WifiRelay1/State"
|
||||||
|
|
||||||
|
- device_id: herdlicht
|
||||||
|
homekit_aid: 82
|
||||||
|
name: Herdlicht
|
||||||
|
type: light
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: zigbee2mqtt
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
brightness: true
|
||||||
|
topics:
|
||||||
|
state: "zigbee2mqtt/herdlicht"
|
||||||
|
set: "zigbee2mqtt/herdlicht/set"
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
version: 1
|
|
||||||
|
|
||||||
mqtt:
|
|
||||||
broker: "172.16.2.16"
|
|
||||||
port: 1883
|
|
||||||
client_id: "home-automation-abstraction"
|
|
||||||
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: simulator
|
|
||||||
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: simulator
|
|
||||||
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: simulator
|
|
||||||
features:
|
|
||||||
power: true
|
|
||||||
brightness: true
|
|
||||||
topics:
|
|
||||||
set: "vendor/test_lampe_3/set"
|
|
||||||
state: "vendor/test_lampe_3/state"
|
|
||||||
- device_id: test_thermo_1
|
|
||||||
type: thermostat
|
|
||||||
cap_version: "thermostat@2.0.0"
|
|
||||||
technology: simulator
|
|
||||||
features:
|
|
||||||
mode: false
|
|
||||||
target: true
|
|
||||||
current: true
|
|
||||||
battery: true
|
|
||||||
topics:
|
|
||||||
set: "vendor/test_thermo_1/set"
|
|
||||||
state: "vendor/test_thermo_1/state"
|
|
||||||
- device_id: experiment_light_1
|
|
||||||
type: light
|
|
||||||
cap_version: "light@1.2.0"
|
|
||||||
technology: zigbee2mqtt
|
|
||||||
features:
|
|
||||||
power: true
|
|
||||||
brightness: true
|
|
||||||
topics:
|
|
||||||
set: "zigbee2mqtt/0xf0d1b80000195038/set"
|
|
||||||
state: "zigbee2mqtt/0xf0d1b80000195038"
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
version: 1
|
|
||||||
|
|
||||||
mqtt:
|
|
||||||
broker: "172.16.2.16"
|
|
||||||
port: 1883
|
|
||||||
client_id: "home-automation-abstraction"
|
|
||||||
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: simulator
|
|
||||||
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: simulator
|
|
||||||
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: simulator
|
|
||||||
features:
|
|
||||||
power: true
|
|
||||||
brightness: true
|
|
||||||
topics:
|
|
||||||
set: "vendor/test_lampe_3/set"
|
|
||||||
state: "vendor/test_lampe_3/state"
|
|
||||||
- device_id: test_thermo_1
|
|
||||||
type: thermostat
|
|
||||||
cap_version: "thermostat@2.0.0"
|
|
||||||
technology: simulator
|
|
||||||
features:
|
|
||||||
mode: false
|
|
||||||
target: true
|
|
||||||
current: true
|
|
||||||
battery: true
|
|
||||||
topics:
|
|
||||||
set: "vendor/test_thermo_1/set"
|
|
||||||
state: "vendor/test_thermo_1/state"
|
|
||||||
- device_id: experiment_light_1
|
|
||||||
type: light
|
|
||||||
cap_version: "light@1.2.0"
|
|
||||||
technology: zigbee2mqtt
|
|
||||||
features:
|
|
||||||
power: true
|
|
||||||
brightness: true
|
|
||||||
topics:
|
|
||||||
set: "zigbee2mqtt/0xf0d1b80000195038/set"
|
|
||||||
state: "zigbee2mqtt/0xf0d1b80000195038"
|
|
||||||
@@ -16,21 +16,13 @@ groups:
|
|||||||
capabilities:
|
capabilities:
|
||||||
power: true
|
power: true
|
||||||
|
|
||||||
- id: "schlafzimmer_lichter"
|
|
||||||
name: "Schlafzimmer – alle Lampen"
|
|
||||||
selector:
|
|
||||||
type: "light"
|
|
||||||
room: "Schlafzimmer"
|
|
||||||
capabilities:
|
|
||||||
power: true
|
|
||||||
brightness: true
|
|
||||||
|
|
||||||
- id: "schlafzimmer_schlummer_licht"
|
- id: "schlafzimmer_schlummer_licht"
|
||||||
name: "Schlafzimmer – Schlummerlicht"
|
name: "Schlafzimmer – Schlummerlicht"
|
||||||
device_ids:
|
device_ids:
|
||||||
- bettlicht_patty
|
- bettlicht_patty
|
||||||
- bettlicht_wolfgang
|
- bettlicht_wolfgang
|
||||||
- medusalampe_schlafzimmer
|
- medusalampe_schlafzimmer
|
||||||
|
- licht_kommode_schlafzimmer
|
||||||
capabilities:
|
capabilities:
|
||||||
power: true
|
power: true
|
||||||
brightness: true
|
brightness: true
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
rooms:
|
rooms:
|
||||||
- name: Schlafzimmer
|
- id: schlafzimmer
|
||||||
|
name: Schlafzimmer
|
||||||
devices:
|
devices:
|
||||||
- device_id: bettlicht_patty
|
- device_id: bettlicht_patty
|
||||||
title: Bettlicht Patty
|
title: Bettlicht Patty
|
||||||
@@ -17,6 +18,10 @@ rooms:
|
|||||||
title: Medusa-Lampe Schlafzimmer
|
title: Medusa-Lampe Schlafzimmer
|
||||||
icon: 💡
|
icon: 💡
|
||||||
rank: 40
|
rank: 40
|
||||||
|
- device_id: licht_kommode_schlafzimmer
|
||||||
|
title: Kommode Schlafzimmer
|
||||||
|
icon: 💡
|
||||||
|
rank: 42
|
||||||
- device_id: thermostat_schlafzimmer
|
- device_id: thermostat_schlafzimmer
|
||||||
title: Thermostat Schlafzimmer
|
title: Thermostat Schlafzimmer
|
||||||
icon: 🌡️
|
icon: 🌡️
|
||||||
@@ -29,7 +34,8 @@ rooms:
|
|||||||
title: Temperatur & Luftfeuchte
|
title: Temperatur & Luftfeuchte
|
||||||
icon: 🌡️
|
icon: 🌡️
|
||||||
rank: 47
|
rank: 47
|
||||||
- name: Esszimmer
|
- id: esszimmer
|
||||||
|
name: Esszimmer
|
||||||
devices:
|
devices:
|
||||||
- device_id: deckenlampe_esszimmer
|
- device_id: deckenlampe_esszimmer
|
||||||
title: Deckenlampe Esszimmer
|
title: Deckenlampe Esszimmer
|
||||||
@@ -39,16 +45,24 @@ rooms:
|
|||||||
title: Leselampe Esszimmer
|
title: Leselampe Esszimmer
|
||||||
icon: 💡
|
icon: 💡
|
||||||
rank: 60
|
rank: 60
|
||||||
- device_id: standlampe_esszimmer
|
- device_id: licht_fensterbank_esszimmer
|
||||||
title: Standlampe Esszimmer
|
title: Fensterbank Esszimmer
|
||||||
icon: 💡
|
icon: 💡
|
||||||
rank: 70
|
rank: 70
|
||||||
- device_id: kleine_lampe_links_esszimmer
|
- device_id: kleine_lampe_links_esszimmer
|
||||||
title: kleine Lampe links Esszimmer
|
title: kleine Lampe links Esszimmer
|
||||||
icon: 💡
|
icon: 💡
|
||||||
rank: 80
|
rank: 80
|
||||||
- device_id: kleine_lampe_rechts_esszimmer
|
- device_id: stehlampe_esszimmer_spiegel
|
||||||
title: kleine Lampe rechts Esszimmer
|
title: Stehlampe Esszimmer Spiegel
|
||||||
|
icon: 💡
|
||||||
|
rank: 81
|
||||||
|
- device_id: stehlampe_esszimmer_schrank
|
||||||
|
title: Stehlampe Esszimmer Schrank
|
||||||
|
icon: 💡
|
||||||
|
rank: 82
|
||||||
|
- device_id: regallampe_esszimmer
|
||||||
|
title: Regallampe Esszimmer
|
||||||
icon: 💡
|
icon: 💡
|
||||||
rank: 90
|
rank: 90
|
||||||
- device_id: licht_schrank_esszimmer
|
- device_id: licht_schrank_esszimmer
|
||||||
@@ -67,7 +81,8 @@ rooms:
|
|||||||
title: Kontakt Straße links
|
title: Kontakt Straße links
|
||||||
icon: 🪟
|
icon: 🪟
|
||||||
rank: 97
|
rank: 97
|
||||||
- name: Wohnzimmer
|
- id: wohnzimmer
|
||||||
|
name: Wohnzimmer
|
||||||
devices:
|
devices:
|
||||||
- device_id: lampe_naehtischchen_wohnzimmer
|
- device_id: lampe_naehtischchen_wohnzimmer
|
||||||
title: Lampe Naehtischchen Wohnzimmer
|
title: Lampe Naehtischchen Wohnzimmer
|
||||||
@@ -89,6 +104,10 @@ rooms:
|
|||||||
title: Regallicht Wohnzimmer
|
title: Regallicht Wohnzimmer
|
||||||
icon: 💡
|
icon: 💡
|
||||||
rank: 132
|
rank: 132
|
||||||
|
- device_id: deckenlampe_wohnzimmer
|
||||||
|
title: Deckenlampe Wohnzimmer
|
||||||
|
icon: 💡
|
||||||
|
rank: 133
|
||||||
- device_id: thermostat_wohnzimmer
|
- device_id: thermostat_wohnzimmer
|
||||||
title: Thermostat Wohnzimmer
|
title: Thermostat Wohnzimmer
|
||||||
icon: 🌡️
|
icon: 🌡️
|
||||||
@@ -105,7 +124,8 @@ rooms:
|
|||||||
title: Temperatur & Luftfeuchte
|
title: Temperatur & Luftfeuchte
|
||||||
icon: 🌡️
|
icon: 🌡️
|
||||||
rank: 138
|
rank: 138
|
||||||
- name: Küche
|
- id: kueche
|
||||||
|
name: Küche
|
||||||
devices:
|
devices:
|
||||||
- device_id: kueche_deckenlampe
|
- device_id: kueche_deckenlampe
|
||||||
title: Küche Deckenlampe
|
title: Küche Deckenlampe
|
||||||
@@ -115,6 +135,19 @@ rooms:
|
|||||||
title: Küche Spüle
|
title: Küche Spüle
|
||||||
icon: 💡
|
icon: 💡
|
||||||
rank: 142
|
rank: 142
|
||||||
|
- device_id: putzlicht_kueche
|
||||||
|
title: Küche Putzlicht
|
||||||
|
icon: 💡
|
||||||
|
rank: 143
|
||||||
|
excluded: true
|
||||||
|
- device_id: kueche_fensterbank_licht
|
||||||
|
title: Küche Fensterbank
|
||||||
|
icon: 💡
|
||||||
|
rank: 144
|
||||||
|
- device_id: herdlicht
|
||||||
|
title: Herdlicht
|
||||||
|
icon: 💡
|
||||||
|
rank: 145
|
||||||
- device_id: thermostat_kueche
|
- device_id: thermostat_kueche
|
||||||
title: Kueche
|
title: Kueche
|
||||||
icon: 🌡️
|
icon: 🌡️
|
||||||
@@ -139,22 +172,35 @@ rooms:
|
|||||||
title: Temperatur & Luftfeuchte
|
title: Temperatur & Luftfeuchte
|
||||||
icon: 🌡️
|
icon: 🌡️
|
||||||
rank: 155
|
rank: 155
|
||||||
- name: Arbeitszimmer Patty
|
- id: arbeitszimmer_patty
|
||||||
|
name: Arbeitszimmer Patty
|
||||||
devices:
|
devices:
|
||||||
- device_id: leselampe_patty
|
- device_id: leselampe_patty
|
||||||
title: Leselampe Patty
|
title: Leselampe Patty
|
||||||
icon: 💡
|
icon: 💡
|
||||||
rank: 160
|
rank: 160
|
||||||
- device_id: schranklicht_hinten_patty
|
- device_id: schranklicht_hinten_patty
|
||||||
title: Schranklicht hinten Patty
|
title: Schranklicht hinten
|
||||||
icon: 💡
|
icon: 💡
|
||||||
rank: 170
|
rank: 170
|
||||||
- device_id: schranklicht_vorne_patty
|
- device_id: schranklicht_vorne_patty
|
||||||
title: Schranklicht vorne Patty
|
title: Schranklicht vorne
|
||||||
icon: 💡
|
icon: 💡
|
||||||
rank: 180
|
rank: 180
|
||||||
|
- device_id: kugellampe_patty
|
||||||
|
title: Kugellampe
|
||||||
|
icon: 💡
|
||||||
|
rank: 181
|
||||||
|
- device_id: licht_schreibtisch_patty
|
||||||
|
title: Licht Schreibtisch
|
||||||
|
icon: 💡
|
||||||
|
rank: 182
|
||||||
|
- device_id: deckenlampe_patty
|
||||||
|
title: Deckenlampe
|
||||||
|
icon: 💡
|
||||||
|
rank: 183
|
||||||
- device_id: thermostat_patty
|
- device_id: thermostat_patty
|
||||||
title: Thermostat Patty
|
title: Thermostat
|
||||||
icon: 🌡️
|
icon: 🌡️
|
||||||
rank: 185
|
rank: 185
|
||||||
- device_id: kontakt_patty_garten_rechts
|
- device_id: kontakt_patty_garten_rechts
|
||||||
@@ -173,7 +219,8 @@ rooms:
|
|||||||
title: Temperatur & Luftfeuchte
|
title: Temperatur & Luftfeuchte
|
||||||
icon: 🌡️
|
icon: 🌡️
|
||||||
rank: 189
|
rank: 189
|
||||||
- name: Arbeitszimmer Wolfgang
|
- id: arbeitszimmer_wolfgang
|
||||||
|
name: Arbeitszimmer Wolfgang
|
||||||
devices:
|
devices:
|
||||||
- device_id: thermostat_wolfgang
|
- device_id: thermostat_wolfgang
|
||||||
title: Wolfgang
|
title: Wolfgang
|
||||||
@@ -191,29 +238,35 @@ rooms:
|
|||||||
title: Temperatur & Luftfeuchte
|
title: Temperatur & Luftfeuchte
|
||||||
icon: 🌡️
|
icon: 🌡️
|
||||||
rank: 202
|
rank: 202
|
||||||
- name: Flur
|
- id: flur
|
||||||
|
name: Flur
|
||||||
devices:
|
devices:
|
||||||
- device_id: deckenlampe_flur_oben
|
- device_id: deckenlampe_flur_oben
|
||||||
title: Deckenlampe Flur oben
|
title: Deckenlampe Flur oben
|
||||||
icon: 💡
|
icon: 💡
|
||||||
rank: 210
|
rank: 210
|
||||||
- device_id: haustuer
|
- device_id: kugeln_regal_flur
|
||||||
title: Haustür
|
title: Kugeln Regal
|
||||||
icon: 💡
|
|
||||||
rank: 220
|
|
||||||
- device_id: licht_flur_schrank
|
|
||||||
title: Schranklicht Flur
|
|
||||||
icon: 💡
|
icon: 💡
|
||||||
rank: 222
|
rank: 222
|
||||||
- device_id: licht_flur_oben_am_spiegel
|
- device_id: licht_flur_oben_am_spiegel
|
||||||
title: Licht Flur oben am Spiegel
|
title: Licht oben am Spiegel
|
||||||
icon: 💡
|
icon: 💡
|
||||||
rank: 230
|
rank: 230
|
||||||
|
- device_id: schrank_flur_haustuer
|
||||||
|
title: Schranklicht an der Haustür
|
||||||
|
icon: 💡
|
||||||
|
rank: 231
|
||||||
|
- device_id: schranklicht_flur_vor_kueche
|
||||||
|
title: Schranklicht vor Küche
|
||||||
|
icon: 💡
|
||||||
|
rank: 232
|
||||||
- device_id: sensor_flur
|
- device_id: sensor_flur
|
||||||
title: Temperatur & Luftfeuchte
|
title: Temperatur & Luftfeuchte
|
||||||
icon: 🌡️
|
icon: 🌡️
|
||||||
rank: 235
|
rank: 235
|
||||||
- name: Sportzimmer
|
- id: sportzimmer
|
||||||
|
name: Sportzimmer
|
||||||
devices:
|
devices:
|
||||||
- device_id: sportlicht_regal
|
- device_id: sportlicht_regal
|
||||||
title: Sportlicht Regal
|
title: Sportlicht Regal
|
||||||
@@ -227,11 +280,16 @@ rooms:
|
|||||||
title: Sportlicht am Fernseher, Studierzimmer
|
title: Sportlicht am Fernseher, Studierzimmer
|
||||||
icon: 🏃
|
icon: 🏃
|
||||||
rank: 260
|
rank: 260
|
||||||
|
- device_id: sportzimmer_licht
|
||||||
|
title: Deckenlampe
|
||||||
|
icon: 💡
|
||||||
|
rank: 262
|
||||||
- device_id: sensor_sportzimmer
|
- device_id: sensor_sportzimmer
|
||||||
title: Temperatur & Luftfeuchte
|
title: Temperatur & Luftfeuchte
|
||||||
icon: 🌡️
|
icon: 🌡️
|
||||||
rank: 265
|
rank: 265
|
||||||
- name: Bad Oben
|
- id: bad_oben
|
||||||
|
name: Bad Oben
|
||||||
devices:
|
devices:
|
||||||
- device_id: thermostat_bad_oben
|
- device_id: thermostat_bad_oben
|
||||||
title: Thermostat Bad Oben
|
title: Thermostat Bad Oben
|
||||||
@@ -245,7 +303,8 @@ rooms:
|
|||||||
title: Temperatur & Luftfeuchte
|
title: Temperatur & Luftfeuchte
|
||||||
icon: 🌡️
|
icon: 🌡️
|
||||||
rank: 272
|
rank: 272
|
||||||
- name: Bad Unten
|
- id: bad_unten
|
||||||
|
name: Bad Unten
|
||||||
devices:
|
devices:
|
||||||
- device_id: thermostat_bad_unten
|
- device_id: thermostat_bad_unten
|
||||||
title: Thermostat Bad Unten
|
title: Thermostat Bad Unten
|
||||||
@@ -259,16 +318,56 @@ rooms:
|
|||||||
title: Temperatur & Luftfeuchte
|
title: Temperatur & Luftfeuchte
|
||||||
icon: 🌡️
|
icon: 🌡️
|
||||||
rank: 282
|
rank: 282
|
||||||
- name: Waschküche
|
- id: waschkueche
|
||||||
|
name: Waschküche
|
||||||
devices:
|
devices:
|
||||||
- device_id: sensor_waschkueche
|
- device_id: sensor_waschkueche
|
||||||
title: Temperatur & Luftfeuchte
|
title: Temperatur & Luftfeuchte
|
||||||
icon: 🌡️
|
icon: 🌡️
|
||||||
rank: 290
|
rank: 290
|
||||||
- name: Outdoor
|
- device_id: waschkueche_licht
|
||||||
|
title: Waschküche Licht
|
||||||
|
icon: 💡
|
||||||
|
rank: 340
|
||||||
|
|
||||||
|
- id: outdoor
|
||||||
|
name: Outdoor
|
||||||
devices:
|
devices:
|
||||||
- device_id: licht_terasse
|
- device_id: licht_terasse
|
||||||
title: Licht Terasse
|
title: Licht Terasse
|
||||||
icon: 💡
|
icon: 💡
|
||||||
rank: 290
|
rank: 290
|
||||||
|
- device_id: gartenlicht_vorne
|
||||||
|
title: Gartenlicht vorne
|
||||||
|
icon: 💡
|
||||||
|
rank: 291
|
||||||
|
- id: garage
|
||||||
|
name: Garage
|
||||||
|
devices:
|
||||||
|
- device_id: power_relay_caroutlet
|
||||||
|
title: Ladestrom
|
||||||
|
icon: ⚡
|
||||||
|
rank: 310
|
||||||
|
- device_id: sensor_caroutlet
|
||||||
|
title: Schützzustand
|
||||||
|
icon: 🔌
|
||||||
|
rank: 315
|
||||||
|
- device_id: powermeter_caroutlet
|
||||||
|
title: Messwerte
|
||||||
|
icon: 📊
|
||||||
|
rank: 320
|
||||||
|
- id: keller
|
||||||
|
name: Keller
|
||||||
|
devices:
|
||||||
|
- device_id: keller_flur_licht
|
||||||
|
title: Keller Flur Licht
|
||||||
|
icon: 💡
|
||||||
|
rank: 330
|
||||||
|
- device_id: werkstatt_licht
|
||||||
|
title: Werkstatt Licht
|
||||||
|
icon: 💡
|
||||||
|
rank: 350
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
# 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: "🔆"
|
|
||||||
rank: 10
|
|
||||||
- device_id: test_thermo_1
|
|
||||||
title: Thermostat
|
|
||||||
icon: "🌡️"
|
|
||||||
rank: 15
|
|
||||||
|
|
||||||
- name: Schlafzimmer
|
|
||||||
devices:
|
|
||||||
- device_id: test_lampe_3
|
|
||||||
title: Nachttischlampe
|
|
||||||
icon: "🛏️"
|
|
||||||
rank: 10
|
|
||||||
|
|
||||||
- name: Lab
|
|
||||||
devices:
|
|
||||||
- device_id: experiment_light_1
|
|
||||||
title: Experimentierlampe
|
|
||||||
icon: "💡"
|
|
||||||
rank: 10
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# MAX! Thermostats - Room Assignment
|
|
||||||
#
|
|
||||||
# Extracted from layout.yaml
|
|
||||||
# Format: Room Name | Device ID (if thermostat exists)
|
|
||||||
#
|
|
||||||
|
|
||||||
Schlafzimmer
|
|
||||||
42
|
|
||||||
|
|
||||||
Esszimmer
|
|
||||||
45
|
|
||||||
|
|
||||||
Wohnzimmer
|
|
||||||
46
|
|
||||||
|
|
||||||
Arbeitszimmer Patty
|
|
||||||
39
|
|
||||||
|
|
||||||
Bad Oben
|
|
||||||
41
|
|
||||||
|
|
||||||
Bad Unten
|
|
||||||
48
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
Schlafzimmer
|
|
||||||
0x00158d00043292dc
|
|
||||||
|
|
||||||
Esszimmer
|
|
||||||
|
|
||||||
Wohnzimmer
|
|
||||||
0x00158d0008975707
|
|
||||||
|
|
||||||
Küche
|
|
||||||
0x00158d00083299bb
|
|
||||||
|
|
||||||
Arbeitszimmer Patty
|
|
||||||
0x00158d0003f052b7
|
|
||||||
|
|
||||||
Arbeitszimmer Wolfgang
|
|
||||||
0x00158d000543fb99
|
|
||||||
|
|
||||||
Bad Oben
|
|
||||||
0x00158d00093e8987
|
|
||||||
|
|
||||||
Bad Unten
|
|
||||||
0x00158d00093e662a
|
|
||||||
|
|
||||||
Flur
|
|
||||||
0x00158d000836ccc6
|
|
||||||
|
|
||||||
Waschküche
|
|
||||||
0x00158d000449f3bc
|
|
||||||
|
|
||||||
Sportzimmer
|
|
||||||
0x00158d0009421422
|
|
||||||
30
create_icons.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
Script to create additional PNG icon sizes for better iOS compatibility
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
def create_icon_sizes():
|
||||||
|
static_dir = Path("/Users/wn/Workspace/home-automation/apps/ui/static")
|
||||||
|
|
||||||
|
# Sizes that iOS might need
|
||||||
|
sizes = [16, 32, 57, 60, 72, 76, 114, 120, 144, 152, 180]
|
||||||
|
|
||||||
|
# Create home icons
|
||||||
|
base_icon = Image.open(static_dir / "apple-touch-icon.png")
|
||||||
|
for size in sizes:
|
||||||
|
resized = base_icon.resize((size, size), Image.Resampling.LANCZOS)
|
||||||
|
resized.save(static_dir / f"apple-touch-icon-{size}x{size}.png")
|
||||||
|
print(f"Created apple-touch-icon-{size}x{size}.png")
|
||||||
|
|
||||||
|
# Create garage icons
|
||||||
|
garage_icon = Image.open(static_dir / "garage-icon.png")
|
||||||
|
for size in sizes:
|
||||||
|
resized = garage_icon.resize((size, size), Image.Resampling.LANCZOS)
|
||||||
|
resized.save(static_dir / f"garage-icon-{size}x{size}.png")
|
||||||
|
print(f"Created garage-icon-{size}x{size}.png")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
create_icon_sizes()
|
||||||
118
create_proper_icons.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
Script to create proper PNG icons with house and car symbols
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
def create_proper_icons():
|
||||||
|
static_dir = Path("/Users/wn/Workspace/home-automation/apps/ui/static")
|
||||||
|
|
||||||
|
# Create home icon with house symbol
|
||||||
|
def create_home_icon(size):
|
||||||
|
img = Image.new('RGBA', (size, size), color=(102, 126, 234, 255)) # #667EEA
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Calculate proportions
|
||||||
|
margin = size // 10
|
||||||
|
house_size = size - 2 * margin
|
||||||
|
|
||||||
|
# Draw house shape
|
||||||
|
# Base rectangle
|
||||||
|
base_height = house_size // 2
|
||||||
|
base_y = size - margin - base_height
|
||||||
|
draw.rectangle([margin, base_y, size - margin, size - margin], fill='white')
|
||||||
|
|
||||||
|
# Roof triangle
|
||||||
|
roof_height = house_size // 3
|
||||||
|
roof_points = [
|
||||||
|
(size // 2, margin), # top point
|
||||||
|
(margin, base_y), # bottom left
|
||||||
|
(size - margin, base_y) # bottom right
|
||||||
|
]
|
||||||
|
draw.polygon(roof_points, fill='white')
|
||||||
|
|
||||||
|
# Door
|
||||||
|
door_width = house_size // 6
|
||||||
|
door_height = base_height // 2
|
||||||
|
door_x = size // 2 - door_width // 2
|
||||||
|
door_y = size - margin - door_height
|
||||||
|
draw.rectangle([door_x, door_y, door_x + door_width, size - margin], fill=(102, 126, 234, 255))
|
||||||
|
|
||||||
|
# Window
|
||||||
|
window_size = house_size // 8
|
||||||
|
window_x = margin + house_size // 4
|
||||||
|
window_y = base_y + base_height // 4
|
||||||
|
draw.rectangle([window_x, window_y, window_x + window_size, window_y + window_size], fill=(102, 126, 234, 255))
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
# Create car icon with car symbol
|
||||||
|
def create_car_icon(size):
|
||||||
|
img = Image.new('RGBA', (size, size), color=(102, 126, 234, 255)) # #667EEA
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Calculate proportions
|
||||||
|
margin = size // 8
|
||||||
|
car_width = size - 2 * margin
|
||||||
|
car_height = car_width // 2
|
||||||
|
car_y = size // 2 - car_height // 2
|
||||||
|
|
||||||
|
# Draw car body
|
||||||
|
draw.rounded_rectangle([margin, car_y, size - margin, car_y + car_height],
|
||||||
|
radius=size//20, fill='white')
|
||||||
|
|
||||||
|
# Draw car roof
|
||||||
|
roof_margin = car_width // 4
|
||||||
|
roof_height = car_height // 2
|
||||||
|
roof_y = car_y - roof_height // 2
|
||||||
|
draw.rounded_rectangle([margin + roof_margin, roof_y,
|
||||||
|
size - margin - roof_margin, car_y + roof_height // 2],
|
||||||
|
radius=size//30, fill='white')
|
||||||
|
|
||||||
|
# Draw wheels
|
||||||
|
wheel_radius = car_height // 4
|
||||||
|
wheel_y = car_y + car_height - wheel_radius // 2
|
||||||
|
|
||||||
|
# Left wheel
|
||||||
|
left_wheel_x = margin + car_width // 4
|
||||||
|
draw.ellipse([left_wheel_x - wheel_radius, wheel_y - wheel_radius,
|
||||||
|
left_wheel_x + wheel_radius, wheel_y + wheel_radius],
|
||||||
|
fill=(102, 126, 234, 255))
|
||||||
|
|
||||||
|
# Right wheel
|
||||||
|
right_wheel_x = size - margin - car_width // 4
|
||||||
|
draw.ellipse([right_wheel_x - wheel_radius, wheel_y - wheel_radius,
|
||||||
|
right_wheel_x + wheel_radius, wheel_y + wheel_radius],
|
||||||
|
fill=(102, 126, 234, 255))
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
# Sizes to create
|
||||||
|
sizes = [16, 32, 57, 60, 72, 76, 114, 120, 144, 152, 180]
|
||||||
|
|
||||||
|
# Create home icons
|
||||||
|
for size in sizes:
|
||||||
|
home_icon = create_home_icon(size)
|
||||||
|
home_icon.save(static_dir / f"apple-touch-icon-{size}x{size}.png")
|
||||||
|
print(f"Created apple-touch-icon-{size}x{size}.png")
|
||||||
|
|
||||||
|
# Also create the main apple-touch-icon.png
|
||||||
|
main_icon = create_home_icon(180)
|
||||||
|
main_icon.save(static_dir / "apple-touch-icon.png")
|
||||||
|
print("Created apple-touch-icon.png")
|
||||||
|
|
||||||
|
# Create garage icons
|
||||||
|
for size in sizes:
|
||||||
|
car_icon = create_car_icon(size)
|
||||||
|
car_icon.save(static_dir / f"garage-icon-{size}x{size}.png")
|
||||||
|
print(f"Created garage-icon-{size}x{size}.png")
|
||||||
|
|
||||||
|
# Also create the main garage-icon.png
|
||||||
|
main_garage = create_car_icon(180)
|
||||||
|
main_garage.save(static_dir / "garage-icon.png")
|
||||||
|
print("Created garage-icon.png")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
create_proper_icons()
|
||||||