Compare commits
128 Commits
0.0.18
...
0.12.4-cha
| Author | SHA1 | Date | |
|---|---|---|---|
|
eb5532739c
|
|||
|
42411b1377
|
|||
|
b99158fd25
|
|||
|
d86e7eecc9
|
|||
|
8ab9db796c
|
|||
|
a2ddcf7de2
|
|||
|
3cc3683e8c
|
|||
|
e0810c72ea
|
|||
|
3c1253da08
|
|||
|
0efb6fab02
|
|||
|
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
|
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
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
|
when:
|
||||||
|
event: [tag]
|
||||||
|
ref:
|
||||||
|
exclude:
|
||||||
|
- refs/tags/*-configchange
|
||||||
|
|
||||||
matrix:
|
matrix:
|
||||||
APP:
|
APP:
|
||||||
- ui
|
- ui
|
||||||
- api
|
- api
|
||||||
- abstraction
|
- abstraction
|
||||||
- rules
|
- rules
|
||||||
|
- static
|
||||||
|
- pulsegen
|
||||||
|
- homekit
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
build:
|
build-${APP}:
|
||||||
image: plugins/kaniko
|
image: plugins/kaniko
|
||||||
settings:
|
settings:
|
||||||
registry:
|
registry:
|
||||||
@@ -18,8 +27,3 @@ steps:
|
|||||||
repo: ${FORGE_NAME}/${CI_REPO}/${APP}
|
repo: ${FORGE_NAME}/${CI_REPO}/${APP}
|
||||||
auto_tag: true
|
auto_tag: true
|
||||||
dockerfile: apps/${APP}/Dockerfile
|
dockerfile: apps/${APP}/Dockerfile
|
||||||
when:
|
|
||||||
event: [push, tag]
|
|
||||||
ref:
|
|
||||||
exclude:
|
|
||||||
- refs/tags/*-configchange
|
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
@@ -1,12 +1,25 @@
|
|||||||
|
when:
|
||||||
|
event: [tag]
|
||||||
|
ref:
|
||||||
|
exclude:
|
||||||
|
- refs/tags/*-configchange
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
- build
|
||||||
|
- namespace
|
||||||
|
- config
|
||||||
|
|
||||||
matrix:
|
matrix:
|
||||||
APP:
|
APP:
|
||||||
- ui
|
- ui
|
||||||
- api
|
- api
|
||||||
- abstraction
|
- abstraction
|
||||||
- rules
|
- rules
|
||||||
|
- static
|
||||||
|
- pulsegen
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
deploy:
|
deploy-${APP}:
|
||||||
image: quay.io/wollud1969/k8s-admin-helper:0.3.4
|
image: quay.io/wollud1969/k8s-admin-helper:0.3.4
|
||||||
environment:
|
environment:
|
||||||
KUBE_CONFIG_CONTENT:
|
KUBE_CONFIG_CONTENT:
|
||||||
@@ -16,13 +29,7 @@ steps:
|
|||||||
commands:
|
commands:
|
||||||
- printf "$KUBE_CONFIG_CONTENT" > /tmp/kubeconfig
|
- printf "$KUBE_CONFIG_CONTENT" > /tmp/kubeconfig
|
||||||
- export KUBECONFIG=/tmp/kubeconfig
|
- export KUBECONFIG=/tmp/kubeconfig
|
||||||
- echo "Deploying application ${APP} (${IMAGE}) to namespace ${NAMESPACE}"
|
- echo "Deploying application ${APP} ($IMAGE) to namespace $NAMESPACE"
|
||||||
when:
|
- cat deployment/${APP}-deployment.yaml | sed "s,%IMAGE%,$IMAGE,g" | kubectl apply -n $NAMESPACE -f -
|
||||||
event: [tag]
|
|
||||||
ref:
|
|
||||||
exclude:
|
|
||||||
- refs/tags/*-configchange
|
|
||||||
|
|
||||||
depends_on:
|
|
||||||
- build
|
|
||||||
- pre_deploy
|
|
||||||
|
|||||||
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,37 +0,0 @@
|
|||||||
steps:
|
|
||||||
create_namespace:
|
|
||||||
image: quay.io/wollud1969/k8s-admin-helper:0.3.4
|
|
||||||
environment:
|
|
||||||
KUBE_CONFIG_CONTENT:
|
|
||||||
from_secret: kube_config
|
|
||||||
NAMESPACE: "homea2"
|
|
||||||
commands:
|
|
||||||
- printf "$KUBE_CONFIG_CONTENT" > /tmp/kubeconfig
|
|
||||||
- export KUBECONFIG=/tmp/kubeconfig
|
|
||||||
- kubectl create namespace $NAMESPACE || echo "Namespace $NAMESPACE already exists"
|
|
||||||
when:
|
|
||||||
event: [tag]
|
|
||||||
ref:
|
|
||||||
exclude:
|
|
||||||
- refs/tags/*-configchange
|
|
||||||
|
|
||||||
apply_configuration:
|
|
||||||
image: quay.io/wollud1969/k8s-admin-helper:0.3.4
|
|
||||||
environment:
|
|
||||||
KUBE_CONFIG_CONTENT:
|
|
||||||
from_secret: kube_config
|
|
||||||
NAMESPACE: "homea2"
|
|
||||||
commands:
|
|
||||||
- printf "$KUBE_CONFIG_CONTENT" > /tmp/kubeconfig
|
|
||||||
- export KUBECONFIG=/tmp/kubeconfig
|
|
||||||
- kubectl create configmap home-automation-config
|
|
||||||
--from-file=devices=config/devices.yaml
|
|
||||||
--from-file=groups=config/groups.yaml
|
|
||||||
--from-file=layout=config/layout.yaml
|
|
||||||
--from-file=rules=config/rules.yaml
|
|
||||||
--from-file=scenes=config/scenes.yaml
|
|
||||||
--namespace=$NAMESPACE
|
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
|
||||||
when:
|
|
||||||
event: [tag]
|
|
||||||
|
|
||||||
@@ -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 && \
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -391,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,588 +4,52 @@ 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
|
||||||
import json
|
|
||||||
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,
|
||||||
|
hottis_led_stripe
|
||||||
|
)
|
||||||
|
|
||||||
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: str) -> dict[str, Any]:
|
|
||||||
"""Transform simulator light payload to abstract format.
|
|
||||||
|
|
||||||
Simulator uses same format as abstract protocol (no transformation needed).
|
|
||||||
"""
|
|
||||||
|
|
||||||
payload = json.loads(payload)
|
|
||||||
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _transform_thermostat_simulator_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Transform abstract thermostat payload to simulator format.
|
|
||||||
|
|
||||||
Simulator uses same format as abstract protocol (no transformation needed).
|
|
||||||
"""
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _transform_thermostat_simulator_to_abstract(payload: str) -> dict[str, Any]:
|
|
||||||
"""Transform simulator thermostat payload to abstract format.
|
|
||||||
|
|
||||||
Simulator uses same format as abstract protocol (no transformation needed).
|
|
||||||
"""
|
|
||||||
|
|
||||||
payload = json.loads(payload)
|
|
||||||
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# HANDLER FUNCTIONS: zigbee2mqtt technology
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def _transform_light_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Transform abstract light payload to zigbee2mqtt format.
|
|
||||||
|
|
||||||
Transformations:
|
|
||||||
- power: 'on'/'off' -> state: 'ON'/'OFF'
|
|
||||||
- brightness: 0-100 -> brightness: 0-254
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- Abstract: {'power': 'on', 'brightness': 100}
|
|
||||||
- zigbee2mqtt: {'state': 'ON', 'brightness': 254}
|
|
||||||
"""
|
|
||||||
vendor_payload = payload.copy()
|
|
||||||
|
|
||||||
# Transform power -> state with uppercase values
|
|
||||||
if "power" in vendor_payload:
|
|
||||||
power_value = vendor_payload.pop("power")
|
|
||||||
vendor_payload["state"] = power_value.upper() if isinstance(power_value, str) else power_value
|
|
||||||
|
|
||||||
# Transform brightness: 0-100 (%) -> 0-254 (zigbee2mqtt range)
|
|
||||||
if "brightness" in vendor_payload:
|
|
||||||
abstract_brightness = vendor_payload["brightness"]
|
|
||||||
if isinstance(abstract_brightness, (int, float)):
|
|
||||||
# Convert percentage (0-100) to zigbee2mqtt range (0-254)
|
|
||||||
vendor_payload["brightness"] = round(abstract_brightness * 254 / 100)
|
|
||||||
|
|
||||||
return vendor_payload
|
|
||||||
|
|
||||||
|
|
||||||
def _transform_light_zigbee2mqtt_to_abstract(payload: str) -> dict[str, Any]:
|
|
||||||
"""Transform zigbee2mqtt light payload to abstract format.
|
|
||||||
|
|
||||||
Transformations:
|
|
||||||
- state: 'ON'/'OFF' -> power: 'on'/'off'
|
|
||||||
- brightness: 0-254 -> brightness: 0-100
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- zigbee2mqtt: {'state': 'ON', 'brightness': 254}
|
|
||||||
- Abstract: {'power': 'on', 'brightness': 100}
|
|
||||||
"""
|
|
||||||
abstract_payload = json.loads(payload)
|
|
||||||
|
|
||||||
# Transform state -> power with lowercase values
|
|
||||||
if "state" in abstract_payload:
|
|
||||||
state_value = abstract_payload.pop("state")
|
|
||||||
abstract_payload["power"] = state_value.lower() if isinstance(state_value, str) else state_value
|
|
||||||
|
|
||||||
# Transform brightness: 0-254 (zigbee2mqtt range) -> 0-100 (%)
|
|
||||||
if "brightness" in abstract_payload:
|
|
||||||
vendor_brightness = abstract_payload["brightness"]
|
|
||||||
if isinstance(vendor_brightness, (int, float)):
|
|
||||||
# Convert zigbee2mqtt range (0-254) to percentage (0-100)
|
|
||||||
abstract_payload["brightness"] = round(vendor_brightness * 100 / 254)
|
|
||||||
|
|
||||||
return abstract_payload
|
|
||||||
|
|
||||||
|
|
||||||
def _transform_thermostat_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Transform abstract thermostat payload to zigbee2mqtt format.
|
|
||||||
|
|
||||||
Transformations:
|
|
||||||
- target -> current_heating_setpoint (as string)
|
|
||||||
- mode is ignored (zigbee2mqtt thermostats use system_mode in state only)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- Abstract: {'target': 22.0}
|
|
||||||
- zigbee2mqtt: {'current_heating_setpoint': '22.0'}
|
|
||||||
"""
|
|
||||||
vendor_payload = {}
|
|
||||||
|
|
||||||
if "target" in payload:
|
|
||||||
# zigbee2mqtt expects current_heating_setpoint as string
|
|
||||||
vendor_payload["current_heating_setpoint"] = str(payload["target"])
|
|
||||||
|
|
||||||
return vendor_payload
|
|
||||||
|
|
||||||
|
|
||||||
def _transform_thermostat_zigbee2mqtt_to_abstract(payload: str) -> dict[str, Any]:
|
|
||||||
"""Transform zigbee2mqtt thermostat payload to abstract format.
|
|
||||||
|
|
||||||
Transformations:
|
|
||||||
- current_heating_setpoint -> target (as float)
|
|
||||||
- local_temperature -> current (as float)
|
|
||||||
- system_mode -> mode
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- zigbee2mqtt: {'current_heating_setpoint': 15, 'local_temperature': 23, 'system_mode': 'heat'}
|
|
||||||
- Abstract: {'target': 15.0, 'current': 23.0, 'mode': 'heat'}
|
|
||||||
"""
|
|
||||||
payload = json.loads(payload)
|
|
||||||
abstract_payload = {}
|
|
||||||
|
|
||||||
# Extract target temperature
|
|
||||||
if "current_heating_setpoint" in payload:
|
|
||||||
setpoint = payload["current_heating_setpoint"]
|
|
||||||
abstract_payload["target"] = float(setpoint)
|
|
||||||
|
|
||||||
# Extract current temperature
|
|
||||||
if "local_temperature" in payload:
|
|
||||||
current = payload["local_temperature"]
|
|
||||||
abstract_payload["current"] = float(current)
|
|
||||||
|
|
||||||
# Extract mode
|
|
||||||
if "system_mode" in payload:
|
|
||||||
abstract_payload["mode"] = payload["system_mode"]
|
|
||||||
|
|
||||||
return abstract_payload
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# HANDLER FUNCTIONS: contact_sensor - zigbee2mqtt technology
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def _transform_contact_sensor_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Transform abstract contact sensor payload to zigbee2mqtt format.
|
|
||||||
|
|
||||||
Contact sensors are read-only, so this should not be called for SET commands.
|
|
||||||
Returns payload as-is for compatibility.
|
|
||||||
"""
|
|
||||||
logger.warning("Contact sensors are read-only - SET commands should not be used")
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _transform_contact_sensor_zigbee2mqtt_to_abstract(payload: str) -> dict[str, Any]:
|
|
||||||
"""Transform zigbee2mqtt contact sensor payload to abstract format.
|
|
||||||
|
|
||||||
Transformations:
|
|
||||||
- contact: bool -> "open" | "closed"
|
|
||||||
- zigbee2mqtt semantics: False = OPEN, True = CLOSED (inverted!)
|
|
||||||
- battery: pass through (already 0-100)
|
|
||||||
- linkquality: pass through
|
|
||||||
- device_temperature: pass through (if present)
|
|
||||||
- voltage: pass through (if present)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- zigbee2mqtt: {"contact": false, "battery": 100, "linkquality": 87}
|
|
||||||
- Abstract: {"contact": "open", "battery": 100, "linkquality": 87}
|
|
||||||
"""
|
|
||||||
payload = json.loads(payload)
|
|
||||||
abstract_payload = {}
|
|
||||||
|
|
||||||
# Transform contact state (inverted logic!)
|
|
||||||
if "contact" in payload:
|
|
||||||
contact_bool = payload["contact"]
|
|
||||||
# zigbee2mqtt: False = OPEN, True = CLOSED
|
|
||||||
abstract_payload["contact"] = "closed" if contact_bool else "open"
|
|
||||||
|
|
||||||
# Pass through optional fields
|
|
||||||
if "battery" in payload:
|
|
||||||
abstract_payload["battery"] = payload["battery"]
|
|
||||||
|
|
||||||
if "linkquality" in payload:
|
|
||||||
abstract_payload["linkquality"] = payload["linkquality"]
|
|
||||||
|
|
||||||
if "device_temperature" in payload:
|
|
||||||
abstract_payload["device_temperature"] = payload["device_temperature"]
|
|
||||||
|
|
||||||
if "voltage" in payload:
|
|
||||||
abstract_payload["voltage"] = payload["voltage"]
|
|
||||||
|
|
||||||
return abstract_payload
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# HANDLER FUNCTIONS: contact_sensor - max technology (Homegear MAX!)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def _transform_contact_sensor_max_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Transform abstract contact sensor payload to MAX! format.
|
|
||||||
|
|
||||||
Contact sensors are read-only, so this should not be called for SET commands.
|
|
||||||
Returns payload as-is for compatibility.
|
|
||||||
"""
|
|
||||||
logger.warning("Contact sensors are read-only - SET commands should not be used")
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _transform_contact_sensor_max_to_abstract(payload: str) -> dict[str, Any]:
|
|
||||||
"""Transform MAX! (Homegear) contact sensor payload to abstract format.
|
|
||||||
|
|
||||||
MAX! sends "true"/"false" (string or bool) on STATE topic.
|
|
||||||
|
|
||||||
Transformations:
|
|
||||||
- "true" or True -> "open" (window/door open)
|
|
||||||
- "false" or False -> "closed" (window/door closed)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- MAX!: "true" or True
|
|
||||||
- Abstract: {"contact": "open"}
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
contact_value = payload.strip().lower() == "true"
|
|
||||||
|
|
||||||
# MAX! semantics: True = OPEN, False = CLOSED
|
|
||||||
return {
|
|
||||||
"contact": "open" if contact_value else "closed"
|
|
||||||
}
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
logger.error(f"MAX! contact sensor failed to parse: {payload}, error: {e}")
|
|
||||||
return {
|
|
||||||
"contact": "closed" # Default to closed on error
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# HANDLER FUNCTIONS: temp_humidity_sensor - zigbee2mqtt technology
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def _transform_temp_humidity_sensor_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Transform abstract temp/humidity sensor payload to zigbee2mqtt format.
|
|
||||||
|
|
||||||
Temp/humidity sensors are read-only, so this should not be called for SET commands.
|
|
||||||
Returns payload as-is for compatibility.
|
|
||||||
"""
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _transform_temp_humidity_sensor_zigbee2mqtt_to_abstract(payload: str) -> dict[str, Any]:
|
|
||||||
"""Transform zigbee2mqtt temp/humidity sensor payload to abstract format.
|
|
||||||
|
|
||||||
Passthrough - zigbee2mqtt provides temperature, humidity, battery, linkquality directly.
|
|
||||||
"""
|
|
||||||
payload = json.loads(payload)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# HANDLER FUNCTIONS: temp_humidity_sensor - MAX! technology
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def _transform_temp_humidity_sensor_max_to_vendor(payload: str) -> dict[str, Any]:
|
|
||||||
"""Transform abstract temp/humidity sensor payload to MAX! format.
|
|
||||||
|
|
||||||
Temp/humidity sensors are read-only, so this should not be called for SET commands.
|
|
||||||
Returns payload as-is for compatibility.
|
|
||||||
"""
|
|
||||||
|
|
||||||
payload = json.loads(payload)
|
|
||||||
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _transform_temp_humidity_sensor_max_to_abstract(payload: str) -> dict[str, Any]:
|
|
||||||
"""Transform MAX! temp/humidity sensor payload to abstract format.
|
|
||||||
|
|
||||||
Passthrough - MAX! provides temperature, humidity, battery directly.
|
|
||||||
"""
|
|
||||||
payload = json.loads(payload)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# HANDLER FUNCTIONS: relay - zigbee2mqtt technology
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def _transform_relay_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Transform abstract relay payload to zigbee2mqtt format.
|
|
||||||
|
|
||||||
Relay only has power on/off, same transformation as light.
|
|
||||||
- power: 'on'/'off' -> state: 'ON'/'OFF'
|
|
||||||
"""
|
|
||||||
vendor_payload = payload.copy()
|
|
||||||
|
|
||||||
if "power" in vendor_payload:
|
|
||||||
power_value = vendor_payload.pop("power")
|
|
||||||
vendor_payload["state"] = power_value.upper() if isinstance(power_value, str) else power_value
|
|
||||||
|
|
||||||
return vendor_payload
|
|
||||||
|
|
||||||
|
|
||||||
def _transform_relay_zigbee2mqtt_to_abstract(payload: str) -> dict[str, Any]:
|
|
||||||
"""Transform zigbee2mqtt relay payload to abstract format.
|
|
||||||
|
|
||||||
Relay only has power on/off, same transformation as light.
|
|
||||||
- state: 'ON'/'OFF' -> power: 'on'/'off'
|
|
||||||
"""
|
|
||||||
payload = json.loads(payload)
|
|
||||||
abstract_payload = payload.copy()
|
|
||||||
|
|
||||||
if "state" in abstract_payload:
|
|
||||||
state_value = abstract_payload.pop("state")
|
|
||||||
abstract_payload["power"] = state_value.lower() if isinstance(state_value, str) else state_value
|
|
||||||
|
|
||||||
return abstract_payload
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# HANDLER FUNCTIONS: relay - shelly technology
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def _transform_relay_shelly_to_vendor(payload: dict[str, Any]) -> str:
|
|
||||||
"""Transform abstract relay payload to Shelly format.
|
|
||||||
|
|
||||||
Shelly expects plain text 'on' or 'off' (not JSON).
|
|
||||||
- power: 'on'/'off' -> 'on'/'off' (plain string)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- Abstract: {'power': 'on'}
|
|
||||||
- Shelly: 'on'
|
|
||||||
"""
|
|
||||||
power = payload.get("power", "off")
|
|
||||||
return power
|
|
||||||
|
|
||||||
|
|
||||||
def _transform_relay_shelly_to_abstract(payload: str) -> dict[str, Any]:
|
|
||||||
"""Transform Shelly relay payload to abstract format.
|
|
||||||
|
|
||||||
Shelly sends plain text 'on' or 'off' (not JSON).
|
|
||||||
- 'on'/'off' -> power: 'on'/'off'
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- Shelly: 'on'
|
|
||||||
- Abstract: {'power': 'on'}
|
|
||||||
"""
|
|
||||||
return {"power": payload.strip()}
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# HANDLER FUNCTIONS: relay - hottis_modbus technology
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def _transform_relay_hottis_modbus_to_vendor(payload: dict[str, Any]) -> str:
|
|
||||||
"""Transform abstract relay payload to Hottis Modbus format.
|
|
||||||
|
|
||||||
Hottis Modbus expects plain text 'on' or 'off' (not JSON).
|
|
||||||
- power: 'on'/'off' -> 'on'/'off' (plain string)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- Abstract: {'power': 'on'}
|
|
||||||
- Hottis Modbus: 'on'
|
|
||||||
"""
|
|
||||||
power = payload.get("power", "off")
|
|
||||||
return power
|
|
||||||
|
|
||||||
|
|
||||||
def _transform_relay_hottis_modbus_to_abstract(payload: str) -> dict[str, Any]:
|
|
||||||
"""Transform Hottis Modbus relay payload to abstract format.
|
|
||||||
|
|
||||||
Hottis Modbus sends plain text 'on' or 'off' (not JSON).
|
|
||||||
- 'on'/'off' -> power: 'on'/'off'
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- Hottis Modbus: 'on'
|
|
||||||
- Abstract: {'power': 'on'}
|
|
||||||
"""
|
|
||||||
return {"power": payload.strip()}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# HANDLER FUNCTIONS: three_phase_powermeter - hottis_modbus technology
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def _transform_three_phase_powermeter_hottis_modbus_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Transform abstract three_phase_powermeter payload to hottis_modbus format.
|
|
||||||
|
|
||||||
energy: float = Field(..., description="Total energy in kWh")
|
|
||||||
total_power: float = Field(..., description="Total power in W")
|
|
||||||
phase1_power: float = Field(..., description="Power for phase 1 in W")
|
|
||||||
phase2_power: float = Field(..., description="Power for phase 2 in W")
|
|
||||||
phase3_power: float = Field(..., description="Power for phase 3 in W")
|
|
||||||
phase1_voltage: float = Field(..., description="Voltage for phase 1 in V")
|
|
||||||
phase2_voltage: float = Field(..., description="Voltage for phase 2 in V")
|
|
||||||
phase3_voltage: float = Field(..., description="Voltage for phase 3 in V")
|
|
||||||
phase1_current: float = Field(..., description="Current for phase 1 in A")
|
|
||||||
phase2_current: float = Field(..., description="Current for phase 2 in A")
|
|
||||||
phase3_current: float = Field(..., description="Current for phase 3 in A")
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
vendor_payload = {
|
|
||||||
"energy": payload.get("energy", 0.0),
|
|
||||||
"total_power": payload.get("total_power", 0.0),
|
|
||||||
"phase1_power": payload.get("phase1_power", 0.0),
|
|
||||||
"phase2_power": payload.get("phase2_power", 0.0),
|
|
||||||
"phase3_power": payload.get("phase3_power", 0.0),
|
|
||||||
"phase1_voltage": payload.get("phase1_voltage", 0.0),
|
|
||||||
"phase2_voltage": payload.get("phase2_voltage", 0.0),
|
|
||||||
"phase3_voltage": payload.get("phase3_voltage", 0.0),
|
|
||||||
"phase1_current": payload.get("phase1_current", 0.0),
|
|
||||||
"phase2_current": payload.get("phase2_current", 0.0),
|
|
||||||
"phase3_current": payload.get("phase3_current", 0.0),
|
|
||||||
}
|
|
||||||
|
|
||||||
return vendor_payload
|
|
||||||
|
|
||||||
|
|
||||||
def _transform_three_phase_powermeter_hottis_modbus_to_abstract(payload: str) -> dict[str, Any]:
|
|
||||||
"""Transform hottis_modbus three_phase_powermeter payload to abstract format.
|
|
||||||
|
|
||||||
Transformations:
|
|
||||||
- Direct mapping of all power meter fields
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- hottis_modbus: {'energy': 123.45, 'total_power': 1500.0, 'phase1_power': 500.0, ...}
|
|
||||||
- Abstract: {'energy': 123.45, 'total_power': 1500.0, 'phase1_power': 500.0, ...}
|
|
||||||
"""
|
|
||||||
payload = json.loads(payload)
|
|
||||||
abstract_payload = {
|
|
||||||
"energy": payload.get("energy", 0.0),
|
|
||||||
"total_power": payload.get("total_power", 0.0),
|
|
||||||
"phase1_power": payload.get("phase1_power", 0.0),
|
|
||||||
"phase2_power": payload.get("phase2_power", 0.0),
|
|
||||||
"phase3_power": payload.get("phase3_power", 0.0),
|
|
||||||
"phase1_voltage": payload.get("phase1_voltage", 0.0),
|
|
||||||
"phase2_voltage": payload.get("phase2_voltage", 0.0),
|
|
||||||
"phase3_voltage": payload.get("phase3_voltage", 0.0),
|
|
||||||
"phase1_current": payload.get("phase1_current", 0.0),
|
|
||||||
"phase2_current": payload.get("phase2_current", 0.0),
|
|
||||||
"phase3_current": payload.get("phase3_current", 0.0),
|
|
||||||
}
|
|
||||||
|
|
||||||
return abstract_payload
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# HANDLER FUNCTIONS: max technology (Homegear MAX!)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def _transform_thermostat_max_to_vendor(payload: dict[str, Any]) -> str:
|
|
||||||
"""Transform abstract thermostat payload to MAX! (Homegear) format.
|
|
||||||
|
|
||||||
MAX! expects only the integer temperature value (no JSON).
|
|
||||||
|
|
||||||
Transformations:
|
|
||||||
- Extract 'target' temperature from payload
|
|
||||||
- Convert float to integer (MAX! only accepts integers)
|
|
||||||
- Return as plain string value
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- Abstract: {'mode': 'heat', 'target': 22.5}
|
|
||||||
- MAX!: "22"
|
|
||||||
|
|
||||||
Note: MAX! ignores mode - it's always in heating mode
|
|
||||||
"""
|
|
||||||
if "target" not in payload:
|
|
||||||
logger.warning(f"MAX! thermostat payload missing 'target': {payload}")
|
|
||||||
return "21" # Default fallback
|
|
||||||
|
|
||||||
target_temp = payload["target"]
|
|
||||||
|
|
||||||
# Convert to integer (MAX! protocol requirement)
|
|
||||||
if isinstance(target_temp, (int, float)):
|
|
||||||
int_temp = int(round(target_temp))
|
|
||||||
return str(int_temp)
|
|
||||||
|
|
||||||
logger.warning(f"MAX! invalid target temperature type: {type(target_temp)}, value: {target_temp}")
|
|
||||||
return "21"
|
|
||||||
|
|
||||||
|
|
||||||
def _transform_thermostat_max_to_abstract(payload: str) -> dict[str, Any]:
|
|
||||||
"""Transform MAX! (Homegear) thermostat payload to abstract format.
|
|
||||||
|
|
||||||
MAX! sends only the integer temperature value (no JSON).
|
|
||||||
|
|
||||||
Transformations:
|
|
||||||
- Parse plain string/int value
|
|
||||||
- Convert to float for abstract protocol
|
|
||||||
- Wrap in abstract payload structure with mode='heat'
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- MAX!: "22" or 22
|
|
||||||
- Abstract: {'target': 22.0, 'mode': 'heat'}
|
|
||||||
|
|
||||||
Note: MAX! doesn't send current temperature via SET_TEMPERATURE topic
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Handle both string and numeric input
|
|
||||||
target_temp = float(payload.strip())
|
|
||||||
|
|
||||||
return {
|
|
||||||
"target": target_temp,
|
|
||||||
"mode": "heat" # MAX! is always in heating mode
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# REGISTRY: Maps (device_type, technology, direction) -> handler function
|
# 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,
|
|
||||||
("light", "zigbee2mqtt", "to_vendor"): _transform_light_zigbee2mqtt_to_vendor,
|
|
||||||
("light", "zigbee2mqtt", "to_abstract"): _transform_light_zigbee2mqtt_to_abstract,
|
|
||||||
|
|
||||||
# Thermostat transformations
|
|
||||||
("thermostat", "simulator", "to_vendor"): _transform_thermostat_simulator_to_vendor,
|
|
||||||
("thermostat", "simulator", "to_abstract"): _transform_thermostat_simulator_to_abstract,
|
|
||||||
("thermostat", "zigbee2mqtt", "to_vendor"): _transform_thermostat_zigbee2mqtt_to_vendor,
|
|
||||||
("thermostat", "zigbee2mqtt", "to_abstract"): _transform_thermostat_zigbee2mqtt_to_abstract,
|
|
||||||
("thermostat", "max", "to_vendor"): _transform_thermostat_max_to_vendor,
|
|
||||||
("thermostat", "max", "to_abstract"): _transform_thermostat_max_to_abstract,
|
|
||||||
|
|
||||||
# Contact sensor transformations (support both 'contact' and 'contact_sensor' types)
|
|
||||||
("contact_sensor", "zigbee2mqtt", "to_vendor"): _transform_contact_sensor_zigbee2mqtt_to_vendor,
|
|
||||||
("contact_sensor", "zigbee2mqtt", "to_abstract"): _transform_contact_sensor_zigbee2mqtt_to_abstract,
|
|
||||||
("contact_sensor", "max", "to_vendor"): _transform_contact_sensor_max_to_vendor,
|
|
||||||
("contact_sensor", "max", "to_abstract"): _transform_contact_sensor_max_to_abstract,
|
|
||||||
("contact", "zigbee2mqtt", "to_vendor"): _transform_contact_sensor_zigbee2mqtt_to_vendor,
|
|
||||||
("contact", "zigbee2mqtt", "to_abstract"): _transform_contact_sensor_zigbee2mqtt_to_abstract,
|
|
||||||
("contact", "max", "to_vendor"): _transform_contact_sensor_max_to_vendor,
|
|
||||||
("contact", "max", "to_abstract"): _transform_contact_sensor_max_to_abstract,
|
|
||||||
|
|
||||||
# Temperature & humidity sensor transformations (support both type aliases)
|
|
||||||
("temp_humidity_sensor", "zigbee2mqtt", "to_vendor"): _transform_temp_humidity_sensor_zigbee2mqtt_to_vendor,
|
|
||||||
("temp_humidity_sensor", "zigbee2mqtt", "to_abstract"): _transform_temp_humidity_sensor_zigbee2mqtt_to_abstract,
|
|
||||||
("temp_humidity_sensor", "max", "to_vendor"): _transform_temp_humidity_sensor_max_to_vendor,
|
|
||||||
("temp_humidity_sensor", "max", "to_abstract"): _transform_temp_humidity_sensor_max_to_abstract,
|
|
||||||
("temp_humidity", "zigbee2mqtt", "to_vendor"): _transform_temp_humidity_sensor_zigbee2mqtt_to_vendor,
|
|
||||||
("temp_humidity", "zigbee2mqtt", "to_abstract"): _transform_temp_humidity_sensor_zigbee2mqtt_to_abstract,
|
|
||||||
("temp_humidity", "max", "to_vendor"): _transform_temp_humidity_sensor_max_to_vendor,
|
|
||||||
("temp_humidity", "max", "to_abstract"): _transform_temp_humidity_sensor_max_to_abstract,
|
|
||||||
|
|
||||||
# Relay transformations
|
|
||||||
("relay", "zigbee2mqtt", "to_vendor"): _transform_relay_zigbee2mqtt_to_vendor,
|
|
||||||
("relay", "zigbee2mqtt", "to_abstract"): _transform_relay_zigbee2mqtt_to_abstract,
|
|
||||||
("relay", "shelly", "to_vendor"): _transform_relay_shelly_to_vendor,
|
|
||||||
("relay", "shelly", "to_abstract"): _transform_relay_shelly_to_abstract,
|
|
||||||
("relay", "hottis_modbus", "to_vendor"): _transform_relay_hottis_modbus_to_vendor,
|
|
||||||
("relay", "hottis_modbus", "to_abstract"): _transform_relay_hottis_modbus_to_abstract,
|
|
||||||
|
|
||||||
# Three-Phase Powermeter transformations
|
# Register handlers from each vendor module
|
||||||
("three_phase_powermeter", "hottis_modbus", "to_vendor"): _transform_three_phase_powermeter_hottis_modbus_to_vendor,
|
for vendor_name, vendor_module in [
|
||||||
("three_phase_powermeter", "hottis_modbus", "to_abstract"): _transform_three_phase_powermeter_hottis_modbus_to_abstract,
|
("simulator", simulator),
|
||||||
}
|
("zigbee2mqtt", zigbee2mqtt),
|
||||||
|
("max", max),
|
||||||
|
("shelly", shelly),
|
||||||
|
("tasmota", tasmota),
|
||||||
|
("hottis_pv_modbus", hottis_pv_modbus),
|
||||||
|
("hottis_wago_modbus", hottis_wago_modbus),
|
||||||
|
("hottis_wifi_relay", hottis_wifi_relay),
|
||||||
|
("hottis_led_stripe", hottis_led_stripe),
|
||||||
|
]:
|
||||||
|
for (device_type, direction), handler in vendor_module.HANDLERS.items():
|
||||||
|
key = (device_type, vendor_name, direction)
|
||||||
|
TRANSFORM_HANDLERS[key] = handler
|
||||||
|
|
||||||
|
|
||||||
def _get_transform_handler(
|
def _get_transform_handler(
|
||||||
@@ -624,7 +88,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:
|
||||||
@@ -633,7 +97,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}, "
|
||||||
@@ -660,7 +124,7 @@ def transform_vendor_to_abstract(
|
|||||||
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."""
|
||||||
46
apps/abstraction/vendors/hottis_led_stripe.py
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Hottis LED Stripe vendor transformations."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_light_to_vendor(payload: dict[str, Any]) -> str:
|
||||||
|
"""Transform abstract relay payload to Hottis LED Stripe format.
|
||||||
|
|
||||||
|
Hottis LED Stripe expects plain text 'on' or 'off' (not JSON).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Abstract: {'power': 'on'}
|
||||||
|
- Hottis LED Stripe: 'ON'
|
||||||
|
"""
|
||||||
|
|
||||||
|
bri = 89.0 / 254.0
|
||||||
|
r = int(255 * bri)
|
||||||
|
g = int(103 * bri)
|
||||||
|
b = int(25 * bri)
|
||||||
|
|
||||||
|
cmd = f"{r} {g} {b}" if payload.get("power", "off").lower() == "on" else "0 0 0"
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def transform_light_to_abstract(payload: str) -> dict[str, Any]:
|
||||||
|
"""Transform Hottis LED Stripe relay payload to abstract format.
|
||||||
|
|
||||||
|
Hottis LED Stripe sends plain text 'on' or 'off'.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Hottis LED Stripe: 'ON'
|
||||||
|
- Abstract: {'power': 'on'}
|
||||||
|
"""
|
||||||
|
|
||||||
|
power = "on" if payload.strip() != "0 0 0" else "off"
|
||||||
|
return {"power": power}
|
||||||
|
|
||||||
|
|
||||||
|
# Registry of handlers for this vendor
|
||||||
|
HANDLERS = {
|
||||||
|
("light", "to_vendor"): transform_light_to_vendor,
|
||||||
|
("light", "to_abstract"): transform_light_to_abstract,
|
||||||
|
}
|
||||||
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")
|
||||||
@@ -24,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,
|
||||||
@@ -67,6 +69,7 @@ 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=["*"],
|
||||||
@@ -98,30 +101,6 @@ async def get_device_state(device_id: str):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(status_code=404, detail="Device state not found")
|
raise HTTPException(status_code=404, detail="Device state not found")
|
||||||
|
|
||||||
# --- Minimal-invasive: Einzelgerät-Layout-Endpunkt ---
|
|
||||||
@app.get("/devices/{device_id}/layout")
|
|
||||||
async def get_device_layout(device_id: str):
|
|
||||||
"""Gibt die layout-spezifischen Informationen für ein einzelnes Gerät zurück."""
|
|
||||||
layout = load_layout()
|
|
||||||
for room in layout.get("rooms", []):
|
|
||||||
for device in room.get("devices", []):
|
|
||||||
if device.get("device_id") == device_id:
|
|
||||||
# Rückgabe: Layout-Infos + Raumname
|
|
||||||
return {
|
|
||||||
"device_id": device_id,
|
|
||||||
"room": room.get("name"),
|
|
||||||
"title": device.get("title"),
|
|
||||||
"icon": device.get("icon"),
|
|
||||||
"rank": device.get("rank"),
|
|
||||||
}
|
|
||||||
raise HTTPException(status_code=404, detail="Device layout not found")
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
|
||||||
async def startup_event():
|
|
||||||
"""Include routers after app is initialized to avoid circular imports."""
|
|
||||||
from apps.api.routes.groups_scenes import router as groups_scenes_router
|
|
||||||
app.include_router(groups_scenes_router, prefix="")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health() -> dict[str, str]:
|
async def health() -> dict[str, str]:
|
||||||
@@ -187,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")
|
||||||
|
|
||||||
@@ -234,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.
|
||||||
|
|
||||||
@@ -387,6 +360,7 @@ async def get_device(device_id: str) -> DeviceInfo:
|
|||||||
device_id=device["device_id"],
|
device_id=device["device_id"],
|
||||||
type=device["type"],
|
type=device["type"],
|
||||||
name=device.get("name", device["device_id"]),
|
name=device.get("name", device["device_id"]),
|
||||||
|
homekit_aid=device["homekit_aid"],
|
||||||
features=device.get("features", {})
|
features=device.get("features", {})
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -405,6 +379,7 @@ async def get_devices() -> list[DeviceInfo]:
|
|||||||
device_id=device["device_id"],
|
device_id=device["device_id"],
|
||||||
type=device["type"],
|
type=device["type"],
|
||||||
name=device.get("name", device["device_id"]),
|
name=device.get("name", device["device_id"]),
|
||||||
|
homekit_aid=device["homekit_aid"],
|
||||||
features=device.get("features", {})
|
features=device.get("features", {})
|
||||||
)
|
)
|
||||||
for device in devices
|
for device in devices
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
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
|
||||||
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
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -89,7 +133,7 @@ async def rooms(request: Request) -> HTMLResponse:
|
|||||||
"""
|
"""
|
||||||
return templates.TemplateResponse("rooms.html", {
|
return templates.TemplateResponse("rooms.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"api_base": API_BASE
|
"api_base": API_BASE,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -107,7 +151,7 @@ async def room_detail(request: Request, room_name: str) -> HTMLResponse:
|
|||||||
return templates.TemplateResponse("room.html", {
|
return templates.TemplateResponse("room.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"api_base": API_BASE,
|
"api_base": API_BASE,
|
||||||
"room_name": room_name
|
"room_name": room_name,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,301 +0,0 @@
|
|||||||
# Home Automation API Client
|
|
||||||
|
|
||||||
Wiederverwendbare JavaScript-API-Client-Bibliothek für das Home Automation UI.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
Füge die folgenden Script-Tags in deine HTML-Seiten ein:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<script src="/static/types.js"></script>
|
|
||||||
<script src="/static/api-client.js"></script>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Konfiguration
|
|
||||||
|
|
||||||
Der API-Client nutzt `window.API_BASE`, das vom Backend gesetzt wird:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
window.API_BASE = '{{ api_base }}'; // Jinja2 template
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verwendung
|
|
||||||
|
|
||||||
### Globale Instanz
|
|
||||||
|
|
||||||
Der API-Client erstellt automatisch eine globale Instanz `window.apiClient`:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Layout abrufen
|
|
||||||
const layout = await window.apiClient.getLayout();
|
|
||||||
|
|
||||||
// Geräte abrufen
|
|
||||||
const devices = await window.apiClient.getDevices();
|
|
||||||
|
|
||||||
// Gerätestatus abrufen
|
|
||||||
const state = await window.apiClient.getDeviceState('kitchen_light');
|
|
||||||
|
|
||||||
// Gerätesteuerung
|
|
||||||
await window.apiClient.setDeviceState('kitchen_light', 'light', {
|
|
||||||
power: true,
|
|
||||||
brightness: 80
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verfügbare Methoden
|
|
||||||
|
|
||||||
#### `getLayout(): Promise<Layout>`
|
|
||||||
Lädt die Layout-Daten (Räume und ihre Geräte).
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const layout = await window.apiClient.getLayout();
|
|
||||||
// { rooms: [{name: "Küche", devices: ["kitchen_light", ...]}, ...] }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `getDevices(): Promise<Device[]>`
|
|
||||||
Lädt alle Geräte mit ihren Features.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const devices = await window.apiClient.getDevices();
|
|
||||||
// [{device_id: "...", name: "...", type: "light", features: {...}}, ...]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `getDeviceState(deviceId): Promise<DeviceState>`
|
|
||||||
Lädt den aktuellen Status eines Geräts.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const state = await window.apiClient.getDeviceState('kitchen_light');
|
|
||||||
// {power: true, brightness: 80, ...}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `getAllStates(): Promise<Object>`
|
|
||||||
Lädt alle Gerätestatus auf einmal.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const states = await window.apiClient.getAllStates();
|
|
||||||
// {"kitchen_light": {power: true, ...}, "thermostat_1": {...}, ...}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `setDeviceState(deviceId, type, payload): Promise<void>`
|
|
||||||
Sendet einen Befehl an ein Gerät.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Licht einschalten
|
|
||||||
await window.apiClient.setDeviceState('kitchen_light', 'light', {
|
|
||||||
power: true,
|
|
||||||
brightness: 80
|
|
||||||
});
|
|
||||||
|
|
||||||
// Thermostat einstellen
|
|
||||||
await window.apiClient.setDeviceState('thermostat_1', 'thermostat', {
|
|
||||||
target_temp: 22.5
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rollladen steuern
|
|
||||||
await window.apiClient.setDeviceState('cover_1', 'cover', {
|
|
||||||
position: 50
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `getDeviceRoom(deviceId): Promise<{room: string}>`
|
|
||||||
Ermittelt den Raum eines Geräts.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const { room } = await window.apiClient.getDeviceRoom('kitchen_light');
|
|
||||||
// {room: "Küche"}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `getScenes(): Promise<Scene[]>`
|
|
||||||
Lädt alle verfügbaren Szenen.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const scenes = await window.apiClient.getScenes();
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `activateScene(sceneId): Promise<void>`
|
|
||||||
Aktiviert eine Szene.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
await window.apiClient.activateScene('evening');
|
|
||||||
```
|
|
||||||
|
|
||||||
### Realtime-Updates (SSE)
|
|
||||||
|
|
||||||
#### `connectRealtime(onEvent, onError): EventSource`
|
|
||||||
Verbindet sich mit dem SSE-Stream für Live-Updates.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
window.apiClient.connectRealtime(
|
|
||||||
(event) => {
|
|
||||||
console.log('Update:', event.device_id, event.state);
|
|
||||||
// event = {device_id: "...", type: "state", state: {...}}
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
console.error('Connection error:', error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `onDeviceUpdate(deviceId, callback): Function`
|
|
||||||
Registriert einen Listener für spezifische Geräte-Updates.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Für ein bestimmtes Gerät
|
|
||||||
const unsubscribe = window.apiClient.onDeviceUpdate('kitchen_light', (event) => {
|
|
||||||
console.log('Kitchen light changed:', event.state);
|
|
||||||
updateUI(event.state);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Für alle Geräte
|
|
||||||
const unsubscribeAll = window.apiClient.onDeviceUpdate(null, (event) => {
|
|
||||||
console.log('Any device changed:', event.device_id, event.state);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Später: Listener entfernen
|
|
||||||
unsubscribe();
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `disconnectRealtime(): void`
|
|
||||||
Trennt die SSE-Verbindung und entfernt alle Listener.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
window.apiClient.disconnectRealtime();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Helper-Methoden
|
|
||||||
|
|
||||||
#### `findDevice(devices, deviceId): Device|null`
|
|
||||||
Findet ein Gerät in einem Array.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const devices = await window.apiClient.getDevices();
|
|
||||||
const device = window.apiClient.findDevice(devices, 'kitchen_light');
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `findRoom(layout, roomName): Room|null`
|
|
||||||
Findet einen Raum im Layout.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const layout = await window.apiClient.getLayout();
|
|
||||||
const room = window.apiClient.findRoom(layout, 'Küche');
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `getDevicesForRoom(layout, devices, roomName): Device[]`
|
|
||||||
Gibt alle Geräte eines Raums zurück.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const layout = await window.apiClient.getLayout();
|
|
||||||
const devices = await window.apiClient.getDevices();
|
|
||||||
const kitchenDevices = window.apiClient.getDevicesForRoom(layout, devices, 'Küche');
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `api(path): string`
|
|
||||||
Konstruiert eine vollständige API-URL.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const url = window.apiClient.api('/devices');
|
|
||||||
// "http://172.19.1.11:8001/devices"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backward Compatibility
|
|
||||||
|
|
||||||
Die globale `api()` Funktion ist weiterhin verfügbar:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
function api(url) {
|
|
||||||
return window.apiClient.api(url);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Typen (JSDoc)
|
|
||||||
|
|
||||||
Die Datei `types.js` enthält JSDoc-Definitionen für alle API-Typen:
|
|
||||||
|
|
||||||
- `Room` - Raum mit Geräten
|
|
||||||
- `Layout` - Layout-Struktur
|
|
||||||
- `Device` - Gerätedaten
|
|
||||||
- `DeviceFeatures` - Geräte-Features
|
|
||||||
- `DeviceState` - Gerätestatus (Light, Thermostat, Contact, etc.)
|
|
||||||
- `RealtimeEvent` - SSE-Event-Format
|
|
||||||
- `Scene` - Szenen-Definition
|
|
||||||
- `*Payload` - Command-Payloads für verschiedene Gerätetypen
|
|
||||||
|
|
||||||
Diese ermöglichen IDE-Autocomplete und Type-Checking in modernen Editoren (VS Code, WebStorm).
|
|
||||||
|
|
||||||
## Beispiel: Vollständige Seite
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>My Page</title>
|
|
||||||
<script src="/static/types.js"></script>
|
|
||||||
<script src="/static/api-client.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="status"></div>
|
|
||||||
<button id="toggle">Toggle Light</button>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
window.API_BASE = 'http://172.19.1.11:8001';
|
|
||||||
const deviceId = 'kitchen_light';
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
// Load initial state
|
|
||||||
const state = await window.apiClient.getDeviceState(deviceId);
|
|
||||||
updateUI(state);
|
|
||||||
|
|
||||||
// Listen for updates
|
|
||||||
window.apiClient.onDeviceUpdate(deviceId, (event) => {
|
|
||||||
updateUI(event.state);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connect to realtime
|
|
||||||
window.apiClient.connectRealtime((event) => {
|
|
||||||
console.log('Event:', event);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle button clicks
|
|
||||||
document.getElementById('toggle').onclick = async () => {
|
|
||||||
const currentState = await window.apiClient.getDeviceState(deviceId);
|
|
||||||
await window.apiClient.setDeviceState(deviceId, 'light', {
|
|
||||||
power: !currentState.power
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUI(state) {
|
|
||||||
document.getElementById('status').textContent =
|
|
||||||
state.power ? 'ON' : 'OFF';
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
Alle API-Methoden werfen Exceptions bei Fehlern:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
try {
|
|
||||||
const state = await window.apiClient.getDeviceState('invalid_id');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('API error:', error);
|
|
||||||
showErrorMessage(error.message);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Auto-Reconnect
|
|
||||||
|
|
||||||
Der SSE-Client versucht automatisch, nach 5 Sekunden wieder zu verbinden, wenn die Verbindung abbricht.
|
|
||||||
|
|
||||||
## Verwendete Technologien
|
|
||||||
|
|
||||||
- **Fetch API** - Für HTTP-Requests
|
|
||||||
- **EventSource** - Für Server-Sent Events
|
|
||||||
- **JSDoc** - Für Type Definitions
|
|
||||||
- **ES6+** - Modern JavaScript (Class, async/await, etc.)
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -4,6 +4,17 @@
|
|||||||
<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>Gerät - Home Automation</title>
|
<title>Gerät - 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="Gerät">
|
||||||
|
<meta name="theme-color" content="#667eea">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -334,8 +345,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Load API client AFTER API_BASE is set -->
|
<!-- Load API client AFTER API_BASE is set -->
|
||||||
<script src="/static/types.js"></script>
|
<script src="{{ STATIC_BASE }}/types.js"></script>
|
||||||
<script src="/static/api-client.js"></script>
|
<script src="{{ STATIC_BASE }}/api-client.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Get device ID from URL
|
// Get device ID from URL
|
||||||
@@ -347,6 +358,7 @@
|
|||||||
let deviceData = null;
|
let deviceData = null;
|
||||||
let deviceState = {};
|
let deviceState = {};
|
||||||
let roomName = '';
|
let roomName = '';
|
||||||
|
let deviceStateUnknown = false;
|
||||||
|
|
||||||
// Device type icons
|
// Device type icons
|
||||||
const deviceIcons = {
|
const deviceIcons = {
|
||||||
@@ -369,8 +381,19 @@
|
|||||||
// NEW: Use new endpoints for device info and layout
|
// NEW: Use new endpoints for device info and layout
|
||||||
deviceData = await window.apiClient.getDevice(deviceId);
|
deviceData = await window.apiClient.getDevice(deviceId);
|
||||||
console.log("Loaded device data:", deviceData);
|
console.log("Loaded device data:", deviceData);
|
||||||
deviceState = await window.apiClient.getDeviceState(deviceId);
|
|
||||||
console.log("Loaded device state:", deviceState);
|
try {
|
||||||
|
deviceState = await window.apiClient.getDeviceState(deviceId);
|
||||||
|
console.log("Loaded device state:", deviceState);
|
||||||
|
if (!deviceState || Object.keys(deviceState).length === 0) {
|
||||||
|
deviceStateUnknown = true;
|
||||||
|
deviceState = {};
|
||||||
|
}
|
||||||
|
} catch (stateError) {
|
||||||
|
console.warn('No state for device, using unknown state:', stateError);
|
||||||
|
deviceStateUnknown = true;
|
||||||
|
deviceState = {};
|
||||||
|
}
|
||||||
const layoutInfo = await window.apiClient.getDeviceLayout(deviceId);
|
const layoutInfo = await window.apiClient.getDeviceLayout(deviceId);
|
||||||
console.log("Loaded layout info:", layoutInfo);
|
console.log("Loaded layout info:", layoutInfo);
|
||||||
roomName = layoutInfo.room;
|
roomName = layoutInfo.room;
|
||||||
@@ -506,6 +529,14 @@
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deviceStateUnknown) {
|
||||||
|
const hint = document.createElement('div');
|
||||||
|
hint.className = 'device-meta';
|
||||||
|
hint.style.marginTop = '12px';
|
||||||
|
hint.textContent = 'Status unbekannt';
|
||||||
|
card.appendChild(hint);
|
||||||
|
}
|
||||||
|
|
||||||
container.appendChild(card);
|
container.appendChild(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,6 +572,14 @@
|
|||||||
`;
|
`;
|
||||||
card.appendChild(sliderGroup);
|
card.appendChild(sliderGroup);
|
||||||
|
|
||||||
|
if (deviceStateUnknown) {
|
||||||
|
const hint = document.createElement('div');
|
||||||
|
hint.className = 'device-meta';
|
||||||
|
hint.style.marginTop = '12px';
|
||||||
|
hint.textContent = 'Status unbekannt';
|
||||||
|
card.appendChild(hint);
|
||||||
|
}
|
||||||
|
|
||||||
container.appendChild(card);
|
container.appendChild(card);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -569,6 +608,14 @@
|
|||||||
powerGroup.appendChild(powerButton);
|
powerGroup.appendChild(powerButton);
|
||||||
card.appendChild(powerGroup);
|
card.appendChild(powerGroup);
|
||||||
|
|
||||||
|
if (deviceStateUnknown) {
|
||||||
|
const hint = document.createElement('div');
|
||||||
|
hint.className = 'device-meta';
|
||||||
|
hint.style.marginTop = '12px';
|
||||||
|
hint.textContent = 'Status unbekannt';
|
||||||
|
card.appendChild(hint);
|
||||||
|
}
|
||||||
|
|
||||||
container.appendChild(card);
|
container.appendChild(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,6 +634,14 @@
|
|||||||
`;
|
`;
|
||||||
card.appendChild(statusDiv);
|
card.appendChild(statusDiv);
|
||||||
|
|
||||||
|
if (deviceStateUnknown) {
|
||||||
|
const hint = document.createElement('div');
|
||||||
|
hint.className = 'device-meta';
|
||||||
|
hint.style.marginTop = '12px';
|
||||||
|
hint.textContent = 'Status unbekannt';
|
||||||
|
card.appendChild(hint);
|
||||||
|
}
|
||||||
|
|
||||||
container.appendChild(card);
|
container.appendChild(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>Garage - Home Automation</title>
|
<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>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -293,14 +305,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Load API client AFTER API_BASE is set -->
|
<!-- Load API client AFTER API_BASE is set -->
|
||||||
<script src="/static/types.js"></script>
|
<script src="{{ STATIC_BASE }}/types.js"></script>
|
||||||
<script src="/static/api-client.js"></script>
|
<script src="{{ STATIC_BASE }}/api-client.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Device IDs for garage devices
|
// Device IDs for garage devices
|
||||||
const GARAGE_DEVICES = [
|
const GARAGE_DEVICES = [
|
||||||
'power_relay_caroutlet',
|
'power_relay_caroutlet',
|
||||||
'powermeter_caroutlet'
|
'powermeter_caroutlet',
|
||||||
|
'sensor_caroutlet'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Device states
|
// Device states
|
||||||
@@ -398,7 +411,17 @@
|
|||||||
renderOutletControls(controlSection, device);
|
renderOutletControls(controlSection, device);
|
||||||
container.appendChild(controlSection);
|
container.appendChild(controlSection);
|
||||||
|
|
||||||
// 3. Powermeter section
|
// 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');
|
const powermeterDevice = Object.values(devicesData).find(d => d.device_id === 'powermeter_caroutlet');
|
||||||
if (powermeterDevice) {
|
if (powermeterDevice) {
|
||||||
const powermeterSection = document.createElement('div');
|
const powermeterSection = document.createElement('div');
|
||||||
@@ -412,7 +435,6 @@
|
|||||||
function renderOutletControls(container, device) {
|
function renderOutletControls(container, device) {
|
||||||
const controlGroup = document.createElement('div');
|
const controlGroup = document.createElement('div');
|
||||||
controlGroup.style.textAlign = 'center';
|
controlGroup.style.textAlign = 'center';
|
||||||
// controlGroup.style.marginBottom = '8px';
|
|
||||||
|
|
||||||
const state = deviceStates[device.device_id];
|
const state = deviceStates[device.device_id];
|
||||||
const currentPower = state?.power === 'on';
|
const currentPower = state?.power === 'on';
|
||||||
@@ -428,36 +450,36 @@
|
|||||||
label.className = 'toggle-label';
|
label.className = 'toggle-label';
|
||||||
label.textContent = currentPower ? 'Ein' : 'Aus';
|
label.textContent = currentPower ? 'Ein' : 'Aus';
|
||||||
|
|
||||||
// Status display
|
|
||||||
// const stateDisplay = document.createElement('div');
|
|
||||||
// stateDisplay.style.marginTop = '16px';
|
|
||||||
// stateDisplay.style.fontSize = '18px';
|
|
||||||
// stateDisplay.style.fontWeight = '600';
|
|
||||||
// stateDisplay.style.color = currentPower ? '#34c759' : '#666';
|
|
||||||
// stateDisplay.textContent = `Status: ${currentPower ? 'Eingeschaltet' : 'Ausgeschaltet'}`;
|
|
||||||
|
|
||||||
controlGroup.appendChild(toggleSwitch);
|
controlGroup.appendChild(toggleSwitch);
|
||||||
controlGroup.appendChild(label);
|
controlGroup.appendChild(label);
|
||||||
// controlGroup.appendChild(stateDisplay);
|
|
||||||
|
|
||||||
container.appendChild(controlGroup);
|
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) {
|
function renderThreePhasePowerDisplay(container, device) {
|
||||||
const state = deviceStates[device.device_id] || {};
|
const state = deviceStates[device.device_id] || {};
|
||||||
|
|
||||||
// Leistungsmessung Title
|
|
||||||
// const title = document.createElement('h3');
|
|
||||||
// title.style.margin = '0 0 20px 0';
|
|
||||||
// title.style.fontSize = '18px';
|
|
||||||
// title.style.fontWeight = '600';
|
|
||||||
// title.style.color = '#333';
|
|
||||||
// title.textContent = 'Leistungsmessung';
|
|
||||||
// container.appendChild(title);
|
|
||||||
|
|
||||||
// Übersicht
|
|
||||||
const overviewGrid = document.createElement('div');
|
const overviewGrid = document.createElement('div');
|
||||||
overviewGrid.className = 'state-grid';
|
overviewGrid.className = 'state-grid';
|
||||||
overviewGrid.innerHTML = `
|
overviewGrid.innerHTML = `
|
||||||
@@ -472,16 +494,13 @@
|
|||||||
`;
|
`;
|
||||||
container.appendChild(overviewGrid);
|
container.appendChild(overviewGrid);
|
||||||
|
|
||||||
// Phasen Title
|
|
||||||
const phaseTitle = document.createElement('h4');
|
const phaseTitle = document.createElement('h4');
|
||||||
phaseTitle.style.margin = '20px 0 8px 0';
|
phaseTitle.style.margin = '20px 0 8px 0';
|
||||||
phaseTitle.style.fontSize = '16px';
|
phaseTitle.style.fontSize = '16px';
|
||||||
phaseTitle.style.fontWeight = '600';
|
phaseTitle.style.fontWeight = '600';
|
||||||
phaseTitle.style.color = '#333';
|
phaseTitle.style.color = '#333';
|
||||||
// phaseTitle.textContent = 'Phasen';
|
|
||||||
container.appendChild(phaseTitle);
|
container.appendChild(phaseTitle);
|
||||||
|
|
||||||
// Phasen Details
|
|
||||||
const phaseGrid = document.createElement('div');
|
const phaseGrid = document.createElement('div');
|
||||||
phaseGrid.className = 'phase-grid';
|
phaseGrid.className = 'phase-grid';
|
||||||
phaseGrid.innerHTML = `
|
phaseGrid.innerHTML = `
|
||||||
@@ -589,12 +608,14 @@
|
|||||||
const state = deviceStates[deviceId];
|
const state = deviceStates[deviceId];
|
||||||
console.log(`Updating UI for ${deviceId}:`, state);
|
console.log(`Updating UI for ${deviceId}:`, state);
|
||||||
|
|
||||||
switch (device.type) {
|
switch (deviceId) {
|
||||||
case 'relay':
|
case 'power_relay_caroutlet':
|
||||||
case 'outlet':
|
|
||||||
updateOutletUI(deviceId, state);
|
updateOutletUI(deviceId, state);
|
||||||
break;
|
break;
|
||||||
case 'three_phase_powermeter':
|
case 'sensor_caroutlet':
|
||||||
|
updateFeedbackDisplay(deviceId, state);
|
||||||
|
break;
|
||||||
|
case 'powermeter_caroutlet':
|
||||||
updateThreePhasePowerUI(deviceId, state);
|
updateThreePhasePowerUI(deviceId, state);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -625,6 +646,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function updateThreePhasePowerUI(deviceId, state) {
|
||||||
// Update overview
|
// Update overview
|
||||||
const totalPower = document.getElementById(`total-power-${deviceId}`);
|
const totalPower = document.getElementById(`total-power-${deviceId}`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -4,6 +4,17 @@
|
|||||||
<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>{{ room_name }} - Home Automation</title>
|
<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>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -217,8 +228,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Load API client AFTER API_BASE is set -->
|
<!-- Load API client AFTER API_BASE is set -->
|
||||||
<script src="/static/types.js"></script>
|
<script src="{{ STATIC_BASE }}/types.js"></script>
|
||||||
<script src="/static/api-client.js"></script>
|
<script src="{{ STATIC_BASE }}/api-client.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Get room name from URL
|
// Get room name from URL
|
||||||
|
|||||||
@@ -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>Räume - Home Automation</title>
|
<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>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -150,8 +162,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Load API client AFTER API_BASE is set -->
|
<!-- Load API client AFTER API_BASE is set -->
|
||||||
<script src="/static/types.js"></script>
|
<script src="{{ STATIC_BASE }}/types.js"></script>
|
||||||
<script src="/static/api-client.js"></script>
|
<script src="{{ STATIC_BASE }}/api-client.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Room icon mapping
|
// Room icon mapping
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
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
|
name: Semeniere
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
@@ -26,6 +17,7 @@ devices:
|
|||||||
model: "AC10691"
|
model: "AC10691"
|
||||||
vendor: "OSRAM"
|
vendor: "OSRAM"
|
||||||
- device_id: stehlampe_esszimmer_spiegel
|
- device_id: stehlampe_esszimmer_spiegel
|
||||||
|
homekit_aid: 3
|
||||||
name: Stehlampe Spiegel
|
name: Stehlampe Spiegel
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
@@ -37,6 +29,7 @@ devices:
|
|||||||
state: "zigbee2mqtt/0x001788010d06ea09"
|
state: "zigbee2mqtt/0x001788010d06ea09"
|
||||||
set: "zigbee2mqtt/0x001788010d06ea09/set"
|
set: "zigbee2mqtt/0x001788010d06ea09/set"
|
||||||
- device_id: stehlampe_esszimmer_schrank
|
- device_id: stehlampe_esszimmer_schrank
|
||||||
|
homekit_aid: 4
|
||||||
name: Stehlampe Schrank
|
name: Stehlampe Schrank
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
@@ -48,6 +41,7 @@ devices:
|
|||||||
state: "zigbee2mqtt/0x001788010d09176c"
|
state: "zigbee2mqtt/0x001788010d09176c"
|
||||||
set: "zigbee2mqtt/0x001788010d09176c/set"
|
set: "zigbee2mqtt/0x001788010d09176c/set"
|
||||||
- device_id: grosse_lampe_wohnzimmer
|
- device_id: grosse_lampe_wohnzimmer
|
||||||
|
homekit_aid: 5
|
||||||
name: grosse Lampe
|
name: grosse Lampe
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
@@ -63,6 +57,7 @@ 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
|
name: Nähtischchen
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
@@ -78,6 +73,7 @@ devices:
|
|||||||
model: "HG06337"
|
model: "HG06337"
|
||||||
vendor: "Lidl"
|
vendor: "Lidl"
|
||||||
- device_id: kleine_lampe_links_esszimmer
|
- device_id: kleine_lampe_links_esszimmer
|
||||||
|
homekit_aid: 7
|
||||||
name: kleine Lampe
|
name: kleine Lampe
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
@@ -93,6 +89,7 @@ devices:
|
|||||||
model: "AC10691"
|
model: "AC10691"
|
||||||
vendor: "OSRAM"
|
vendor: "OSRAM"
|
||||||
- device_id: leselampe_esszimmer
|
- device_id: leselampe_esszimmer
|
||||||
|
homekit_aid: 8
|
||||||
name: Leselampe
|
name: Leselampe
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
@@ -109,6 +106,7 @@ devices:
|
|||||||
model: "LED1842G3"
|
model: "LED1842G3"
|
||||||
vendor: "IKEA"
|
vendor: "IKEA"
|
||||||
- device_id: medusalampe_schlafzimmer
|
- device_id: medusalampe_schlafzimmer
|
||||||
|
homekit_aid: 9
|
||||||
name: Medusa-Lampe
|
name: Medusa-Lampe
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
@@ -124,6 +122,7 @@ 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
|
name: am Fernseher
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
@@ -141,6 +140,7 @@ devices:
|
|||||||
model: "LED1733G7"
|
model: "LED1733G7"
|
||||||
vendor: "IKEA"
|
vendor: "IKEA"
|
||||||
- device_id: deckenlampe_schlafzimmer
|
- device_id: deckenlampe_schlafzimmer
|
||||||
|
homekit_aid: 11
|
||||||
name: Deckenlampe
|
name: Deckenlampe
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
@@ -157,6 +157,7 @@ devices:
|
|||||||
model: "8718699688882"
|
model: "8718699688882"
|
||||||
vendor: "Philips"
|
vendor: "Philips"
|
||||||
- device_id: bettlicht_wolfgang
|
- device_id: bettlicht_wolfgang
|
||||||
|
homekit_aid: 12
|
||||||
name: Bettlicht Wolfgang
|
name: Bettlicht Wolfgang
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
@@ -173,6 +174,7 @@ devices:
|
|||||||
model: "9290020399"
|
model: "9290020399"
|
||||||
vendor: "Philips"
|
vendor: "Philips"
|
||||||
- device_id: bettlicht_patty
|
- device_id: bettlicht_patty
|
||||||
|
homekit_aid: 13
|
||||||
name: Bettlicht Patty
|
name: Bettlicht Patty
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
@@ -189,6 +191,7 @@ 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
|
name: Schranklicht hinten
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
@@ -205,6 +208,7 @@ 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
|
name: Schranklicht vorne
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
@@ -220,6 +224,7 @@ devices:
|
|||||||
model: "AC10691"
|
model: "AC10691"
|
||||||
vendor: "OSRAM"
|
vendor: "OSRAM"
|
||||||
- device_id: leselampe_patty
|
- device_id: leselampe_patty
|
||||||
|
homekit_aid: 16
|
||||||
name: Leselampe
|
name: Leselampe
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
@@ -236,6 +241,7 @@ devices:
|
|||||||
model: "8718699673147"
|
model: "8718699673147"
|
||||||
vendor: "Philips"
|
vendor: "Philips"
|
||||||
- device_id: deckenlampe_esszimmer
|
- device_id: deckenlampe_esszimmer
|
||||||
|
homekit_aid: 17
|
||||||
name: Deckenlampe
|
name: Deckenlampe
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
@@ -251,23 +257,8 @@ devices:
|
|||||||
ieee_address: "0x0017880108a03e45"
|
ieee_address: "0x0017880108a03e45"
|
||||||
model: "929002241201"
|
model: "929002241201"
|
||||||
vendor: "Philips"
|
vendor: "Philips"
|
||||||
- device_id: haustuer
|
|
||||||
name: Haustür-Lampe
|
|
||||||
type: light
|
|
||||||
cap_version: "light@1.2.0"
|
|
||||||
technology: zigbee2mqtt
|
|
||||||
features:
|
|
||||||
power: true
|
|
||||||
brightness: true
|
|
||||||
topics:
|
|
||||||
state: "zigbee2mqtt/0xec1bbdfffea6a3da"
|
|
||||||
set: "zigbee2mqtt/0xec1bbdfffea6a3da/set"
|
|
||||||
metadata:
|
|
||||||
friendly_name: "Haustür"
|
|
||||||
ieee_address: "0xec1bbdfffea6a3da"
|
|
||||||
model: "LED1842G3"
|
|
||||||
vendor: "IKEA"
|
|
||||||
- device_id: deckenlampe_flur_oben
|
- device_id: deckenlampe_flur_oben
|
||||||
|
homekit_aid: 18
|
||||||
name: Deckenlampe oben
|
name: Deckenlampe oben
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
@@ -285,6 +276,7 @@ devices:
|
|||||||
model: "929003099001"
|
model: "929003099001"
|
||||||
vendor: "Philips"
|
vendor: "Philips"
|
||||||
- device_id: kueche_deckenlampe
|
- device_id: kueche_deckenlampe
|
||||||
|
homekit_aid: 19
|
||||||
name: Deckenlampe
|
name: Deckenlampe
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
@@ -301,6 +293,7 @@ devices:
|
|||||||
model: "929002469202"
|
model: "929002469202"
|
||||||
vendor: "Philips"
|
vendor: "Philips"
|
||||||
- device_id: sportlicht_tisch
|
- device_id: sportlicht_tisch
|
||||||
|
homekit_aid: 20
|
||||||
name: am Tisch
|
name: am Tisch
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
@@ -317,6 +310,7 @@ devices:
|
|||||||
model: "4058075729063"
|
model: "4058075729063"
|
||||||
vendor: "LEDVANCE"
|
vendor: "LEDVANCE"
|
||||||
- device_id: sportlicht_regal
|
- device_id: sportlicht_regal
|
||||||
|
homekit_aid: 21
|
||||||
name: am Regal
|
name: am Regal
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
@@ -333,6 +327,7 @@ 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
|
name: Spiegel
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
@@ -350,6 +345,7 @@ devices:
|
|||||||
model: "LED1732G11"
|
model: "LED1732G11"
|
||||||
vendor: "IKEA"
|
vendor: "IKEA"
|
||||||
- device_id: experimentlabtest
|
- device_id: experimentlabtest
|
||||||
|
homekit_aid: 23
|
||||||
name: Test Lampe
|
name: Test Lampe
|
||||||
type: light
|
type: light
|
||||||
cap_version: "light@1.2.0"
|
cap_version: "light@1.2.0"
|
||||||
@@ -366,6 +362,7 @@ devices:
|
|||||||
model: "4058075208421"
|
model: "4058075208421"
|
||||||
vendor: "LEDVANCE"
|
vendor: "LEDVANCE"
|
||||||
- device_id: thermostat_wolfgang
|
- device_id: thermostat_wolfgang
|
||||||
|
homekit_aid: 24
|
||||||
name: Heizung
|
name: Heizung
|
||||||
type: thermostat
|
type: thermostat
|
||||||
cap_version: "thermostat@1.0.0"
|
cap_version: "thermostat@1.0.0"
|
||||||
@@ -385,6 +382,7 @@ devices:
|
|||||||
model: "GS361A-H04"
|
model: "GS361A-H04"
|
||||||
vendor: "Siterwell"
|
vendor: "Siterwell"
|
||||||
- device_id: thermostat_kueche
|
- device_id: thermostat_kueche
|
||||||
|
homekit_aid: 25
|
||||||
name: Heizung
|
name: Heizung
|
||||||
type: thermostat
|
type: thermostat
|
||||||
cap_version: "thermostat@1.0.0"
|
cap_version: "thermostat@1.0.0"
|
||||||
@@ -404,6 +402,7 @@ devices:
|
|||||||
model: "GS361A-H04"
|
model: "GS361A-H04"
|
||||||
vendor: "Siterwell"
|
vendor: "Siterwell"
|
||||||
- device_id: thermostat_schlafzimmer
|
- device_id: thermostat_schlafzimmer
|
||||||
|
homekit_aid: 26
|
||||||
name: Heizung
|
name: Heizung
|
||||||
type: thermostat
|
type: thermostat
|
||||||
cap_version: "thermostat@1.0.0"
|
cap_version: "thermostat@1.0.0"
|
||||||
@@ -423,6 +422,7 @@ devices:
|
|||||||
peer_id: "42"
|
peer_id: "42"
|
||||||
channel: "1"
|
channel: "1"
|
||||||
- device_id: thermostat_esszimmer
|
- device_id: thermostat_esszimmer
|
||||||
|
homekit_aid: 27
|
||||||
name: Heizung
|
name: Heizung
|
||||||
type: thermostat
|
type: thermostat
|
||||||
cap_version: "thermostat@1.0.0"
|
cap_version: "thermostat@1.0.0"
|
||||||
@@ -442,6 +442,7 @@ devices:
|
|||||||
peer_id: "45"
|
peer_id: "45"
|
||||||
channel: "1"
|
channel: "1"
|
||||||
- device_id: thermostat_wohnzimmer
|
- device_id: thermostat_wohnzimmer
|
||||||
|
homekit_aid: 28
|
||||||
name: Heizung
|
name: Heizung
|
||||||
type: thermostat
|
type: thermostat
|
||||||
cap_version: "thermostat@1.0.0"
|
cap_version: "thermostat@1.0.0"
|
||||||
@@ -461,6 +462,7 @@ devices:
|
|||||||
peer_id: "46"
|
peer_id: "46"
|
||||||
channel: "1"
|
channel: "1"
|
||||||
- device_id: thermostat_patty
|
- device_id: thermostat_patty
|
||||||
|
homekit_aid: 29
|
||||||
name: Heizung
|
name: Heizung
|
||||||
type: thermostat
|
type: thermostat
|
||||||
cap_version: "thermostat@1.0.0"
|
cap_version: "thermostat@1.0.0"
|
||||||
@@ -480,6 +482,7 @@ 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
|
name: Heizung
|
||||||
type: thermostat
|
type: thermostat
|
||||||
cap_version: "thermostat@1.0.0"
|
cap_version: "thermostat@1.0.0"
|
||||||
@@ -499,6 +502,7 @@ 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
|
name: Heizung
|
||||||
type: thermostat
|
type: thermostat
|
||||||
cap_version: "thermostat@1.0.0"
|
cap_version: "thermostat@1.0.0"
|
||||||
@@ -518,6 +522,7 @@ devices:
|
|||||||
peer_id: "48"
|
peer_id: "48"
|
||||||
channel: "1"
|
channel: "1"
|
||||||
- device_id: sterne_wohnzimmer
|
- device_id: sterne_wohnzimmer
|
||||||
|
homekit_aid: 32
|
||||||
name: Sterne
|
name: Sterne
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
@@ -533,6 +538,7 @@ devices:
|
|||||||
model: "AC10691"
|
model: "AC10691"
|
||||||
vendor: "OSRAM"
|
vendor: "OSRAM"
|
||||||
- device_id: kontakt_schlafzimmer_strasse
|
- device_id: kontakt_schlafzimmer_strasse
|
||||||
|
homekit_aid: 33
|
||||||
name: Fenster
|
name: Fenster
|
||||||
type: contact
|
type: contact
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
@@ -541,6 +547,7 @@ devices:
|
|||||||
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: Fenster rechts
|
name: Fenster rechts
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
@@ -549,6 +556,7 @@ devices:
|
|||||||
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
|
name: Fenster links
|
||||||
type: contact
|
type: contact
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
@@ -557,6 +565,7 @@ devices:
|
|||||||
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
|
name: Fenster rechts
|
||||||
type: contact
|
type: contact
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
@@ -565,6 +574,7 @@ devices:
|
|||||||
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
|
name: Fenster links
|
||||||
type: contact
|
type: contact
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
@@ -573,6 +583,7 @@ devices:
|
|||||||
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
|
name: Fenster Garten
|
||||||
type: contact
|
type: contact
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
@@ -581,6 +592,7 @@ devices:
|
|||||||
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: Terrassentür
|
name: Terrassentür
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
@@ -589,6 +601,7 @@ devices:
|
|||||||
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
|
name: Fenster Straße rechts
|
||||||
type: contact
|
type: contact
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
@@ -597,6 +610,7 @@ devices:
|
|||||||
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
|
name: Fenster Straße links
|
||||||
type: contact
|
type: contact
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
@@ -605,6 +619,7 @@ devices:
|
|||||||
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: Fenster Garten rechts
|
name: Fenster Garten rechts
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
@@ -613,6 +628,8 @@ devices:
|
|||||||
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: Fenster Garten links
|
name: Fenster Garten links
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
@@ -621,6 +638,7 @@ devices:
|
|||||||
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: Fenster Straße
|
name: Fenster Straße
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
@@ -629,6 +647,7 @@ devices:
|
|||||||
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: Fenster
|
name: Fenster
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
@@ -637,6 +656,7 @@ devices:
|
|||||||
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: Fenster
|
name: Fenster
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
@@ -645,6 +665,7 @@ devices:
|
|||||||
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: Fenster
|
name: Fenster
|
||||||
cap_version: contact_sensor@1.0.0
|
cap_version: contact_sensor@1.0.0
|
||||||
@@ -653,6 +674,7 @@ devices:
|
|||||||
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: Thermometer
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
@@ -661,6 +683,7 @@ devices:
|
|||||||
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: Thermometer
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
@@ -669,6 +692,7 @@ devices:
|
|||||||
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: Thermometer
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
@@ -677,6 +701,7 @@ devices:
|
|||||||
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: Thermometer
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
@@ -685,6 +710,7 @@ devices:
|
|||||||
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: Thermometer
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
@@ -693,6 +719,7 @@ devices:
|
|||||||
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: Thermometer
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
@@ -701,6 +728,7 @@ devices:
|
|||||||
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: Thermometer
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
@@ -709,6 +737,7 @@ devices:
|
|||||||
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: Thermometer
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
@@ -717,6 +746,7 @@ devices:
|
|||||||
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: Thermometer
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
@@ -725,6 +755,7 @@ devices:
|
|||||||
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: Thermometer
|
name: Thermometer
|
||||||
cap_version: temp_humidity_sensor@1.0.0
|
cap_version: temp_humidity_sensor@1.0.0
|
||||||
@@ -733,6 +764,7 @@ devices:
|
|||||||
state: zigbee2mqtt/0x00158d0009421422
|
state: zigbee2mqtt/0x00158d0009421422
|
||||||
features: {}
|
features: {}
|
||||||
- device_id: licht_spuele_kueche
|
- device_id: licht_spuele_kueche
|
||||||
|
homekit_aid: 58
|
||||||
name: Spüle
|
name: Spüle
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
@@ -740,9 +772,22 @@ devices:
|
|||||||
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
|
name: Schrank
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
@@ -753,6 +798,7 @@ 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
|
name: Regal
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
@@ -762,17 +808,8 @@ 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
|
|
||||||
name: Schrank
|
|
||||||
cap_version: "relay@1.0.0"
|
|
||||||
technology: shelly
|
|
||||||
features:
|
|
||||||
power: true
|
|
||||||
topics:
|
|
||||||
set: "shellies/schrankflur/relay/0/command"
|
|
||||||
state: "shellies/schrankflur/relay/0"
|
|
||||||
- device_id: licht_terasse
|
- device_id: licht_terasse
|
||||||
|
homekit_aid: 62
|
||||||
name: Terrasse
|
name: Terrasse
|
||||||
type: relay
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
@@ -782,24 +819,262 @@ 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
|
||||||
- device_id: power_relay_caroutlet
|
homekit_aid: 63
|
||||||
name: Car Outlet
|
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
|
type: relay
|
||||||
cap_version: "relay@1.0.0"
|
cap_version: "relay@1.0.0"
|
||||||
technology: hottis_modbus
|
technology: tasmota
|
||||||
features:
|
features:
|
||||||
power: true
|
power: true
|
||||||
topics:
|
topics:
|
||||||
set: "caroutlet/cmd"
|
set: "cmnd/tasmota/04/POWER"
|
||||||
state: "caroutlet/state"
|
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
|
- device_id: powermeter_caroutlet
|
||||||
|
homekit_aid: 72
|
||||||
name: Car Outlet
|
name: Car Outlet
|
||||||
type: three_phase_powermeter
|
type: three_phase_powermeter
|
||||||
cap_version: "three_phase_powermeter@1.0.0"
|
cap_version: "three_phase_powermeter@1.0.0"
|
||||||
technology: hottis_modbus
|
technology: hottis_pv_modbus
|
||||||
topics:
|
topics:
|
||||||
state: "caroutlet/powermeter"
|
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"
|
||||||
|
|
||||||
|
- device_id: regallicht_kueche
|
||||||
|
homekit_aid: 83
|
||||||
|
name: Regallicht
|
||||||
|
type: light
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: hottis_led_stripe
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
topics:
|
||||||
|
state: "IoT/RgbLedStripeKitchen/ColorCommand"
|
||||||
|
set: "IoT/RgbLedStripeKitchen/ColorCommand"
|
||||||
|
|
||||||
|
- device_id: regallicht_flur
|
||||||
|
homekit_aid: 84
|
||||||
|
name: Regallicht Flur
|
||||||
|
type: relay
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: hottis_wifi_relay
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
topics:
|
||||||
|
set: "deconzhelper/flurregallist"
|
||||||
|
state: "deconzhelper/flurregallist"
|
||||||
|
|
||||||
|
- device_id: steckdose_strandkorb
|
||||||
|
homekit_aid: 85
|
||||||
|
name: Steckdose Strandkorb
|
||||||
|
type: relay
|
||||||
|
cap_version: "relay@1.0.0"
|
||||||
|
technology: hottis_wago_modbus
|
||||||
|
features:
|
||||||
|
power: true
|
||||||
|
topics:
|
||||||
|
set: "dt1/coil/8"
|
||||||
|
state: "dt1/ci/8"
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
version: 1
|
version: 1
|
||||||
groups:
|
groups:
|
||||||
- id: "kueche_lichter"
|
- id: "kueche_lichter"
|
||||||
name: "Küche – alle Lampen"
|
name: "Küche – alle Lampen ausser Putzlicht"
|
||||||
selector:
|
device_ids:
|
||||||
type: "light"
|
- kueche_deckenlampe
|
||||||
room: "Küche"
|
- licht_spuele_kueche
|
||||||
|
- herdlicht
|
||||||
|
- kueche_fensterbank_licht
|
||||||
|
- regallicht_kueche
|
||||||
capabilities:
|
capabilities:
|
||||||
power: true
|
power: true
|
||||||
brightness: true
|
brightness: true
|
||||||
@@ -16,21 +19,25 @@ 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:
|
||||||
|
power: true
|
||||||
|
brightness: true
|
||||||
|
|
||||||
|
- id: "arbeitslicht_patty"
|
||||||
|
name: "Patty – Arbeitslicht"
|
||||||
|
device_ids:
|
||||||
|
- schranklicht_hinten_patty
|
||||||
|
- schranklicht_vorne_patty
|
||||||
|
- leselampe_patty
|
||||||
|
- kugellampe_patty
|
||||||
|
- licht_schreibtisch_patty
|
||||||
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,10 +45,10 @@ 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: 💡
|
||||||
@@ -55,10 +61,10 @@ rooms:
|
|||||||
title: Stehlampe Esszimmer Schrank
|
title: Stehlampe Esszimmer Schrank
|
||||||
icon: 💡
|
icon: 💡
|
||||||
rank: 82
|
rank: 82
|
||||||
# - device_id: kleine_lampe_rechts_esszimmer
|
- device_id: regallampe_esszimmer
|
||||||
# title: kleine Lampe rechts Esszimmer
|
title: Regallampe Esszimmer
|
||||||
# icon: 💡
|
icon: 💡
|
||||||
# rank: 90
|
rank: 90
|
||||||
- device_id: licht_schrank_esszimmer
|
- device_id: licht_schrank_esszimmer
|
||||||
title: Schranklicht Esszimmer
|
title: Schranklicht Esszimmer
|
||||||
icon: 💡
|
icon: 💡
|
||||||
@@ -75,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
|
||||||
@@ -97,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: 🌡️
|
||||||
@@ -113,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
|
||||||
@@ -123,6 +135,23 @@ 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: regallicht_kueche
|
||||||
|
title: Regallicht Küche
|
||||||
|
icon: 💡
|
||||||
|
rank: 146
|
||||||
- device_id: thermostat_kueche
|
- device_id: thermostat_kueche
|
||||||
title: Kueche
|
title: Kueche
|
||||||
icon: 🌡️
|
icon: 🌡️
|
||||||
@@ -147,22 +176,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
|
||||||
@@ -181,7 +223,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
|
||||||
@@ -199,29 +242,39 @@ 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: regallicht_flur
|
||||||
|
title: Regallicht Flur
|
||||||
|
icon: 💡
|
||||||
|
rank: 233
|
||||||
- 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
|
||||||
@@ -235,11 +288,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
|
||||||
@@ -253,7 +311,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
|
||||||
@@ -267,27 +326,60 @@ 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
|
||||||
- name: Garage
|
- device_id: gartenlicht_vorne
|
||||||
|
title: Gartenlicht vorne
|
||||||
|
icon: 💡
|
||||||
|
rank: 291
|
||||||
|
- device_id: steckdose_strandkorb
|
||||||
|
title: Steckdose Strandkorb
|
||||||
|
icon: 🔌
|
||||||
|
rank: 292
|
||||||
|
- id: garage
|
||||||
|
name: Garage
|
||||||
devices:
|
devices:
|
||||||
- device_id: power_relay_caroutlet
|
- device_id: power_relay_caroutlet
|
||||||
title: Ladestrom
|
title: Ladestrom
|
||||||
icon: ⚡
|
icon: ⚡
|
||||||
rank: 310
|
rank: 310
|
||||||
|
- device_id: sensor_caroutlet
|
||||||
|
title: Schützzustand
|
||||||
|
icon: 🔌
|
||||||
|
rank: 315
|
||||||
- device_id: powermeter_caroutlet
|
- device_id: powermeter_caroutlet
|
||||||
title: Ladestrom
|
title: Messwerte
|
||||||
icon: 📊
|
icon: 📊
|
||||||
rank: 320
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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()
|
||||||
73
deployment/abstraction-deployment.yaml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: abstraction
|
||||||
|
labels:
|
||||||
|
app: abstraction
|
||||||
|
component: home-automation
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: abstraction
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
reloader.stakater.com/auto: "true"
|
||||||
|
configmap.reloader.stakater.com/reload: "home-automation-environment,home-automation-config"
|
||||||
|
labels:
|
||||||
|
app: abstraction
|
||||||
|
component: home-automation
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: abstraction
|
||||||
|
image: %IMAGE%
|
||||||
|
env:
|
||||||
|
- name: MQTT_BROKER
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_MQTT_BROKER
|
||||||
|
- name: MQTT_PORT
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_MQTT_PORT
|
||||||
|
- name: REDIS_HOST
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_REDIS_HOST
|
||||||
|
- name: REDIS_PORT
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_REDIS_PORT
|
||||||
|
- name: REDIS_DB
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_REDIS_DB
|
||||||
|
volumeMounts:
|
||||||
|
- name: config-volume
|
||||||
|
mountPath: /app/config
|
||||||
|
readOnly: true
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- "ps aux | grep -v grep | grep python"
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
volumes:
|
||||||
|
- name: config-volume
|
||||||
|
configMap:
|
||||||
|
name: home-automation-config
|
||||||
97
deployment/api-deployment.yaml
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: api
|
||||||
|
namespace: homea2
|
||||||
|
labels:
|
||||||
|
app: api
|
||||||
|
component: home-automation
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: api
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
reloader.stakater.com/auto: "true"
|
||||||
|
configmap.reloader.stakater.com/reload: "home-automation-environment,home-automation-config"
|
||||||
|
labels:
|
||||||
|
app: api
|
||||||
|
component: home-automation
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: api
|
||||||
|
image: %IMAGE%
|
||||||
|
ports:
|
||||||
|
- containerPort: 8001
|
||||||
|
name: http
|
||||||
|
env:
|
||||||
|
- name: MQTT_BROKER
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_MQTT_BROKER
|
||||||
|
- name: MQTT_PORT
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_MQTT_PORT
|
||||||
|
- name: REDIS_HOST
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_REDIS_HOST
|
||||||
|
- name: REDIS_PORT
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_REDIS_PORT
|
||||||
|
- name: REDIS_DB
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_REDIS_DB
|
||||||
|
volumeMounts:
|
||||||
|
- name: config-volume
|
||||||
|
mountPath: /app/config
|
||||||
|
readOnly: true
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8001
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8001
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 1Gi
|
||||||
|
requests:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 256Mi
|
||||||
|
volumes:
|
||||||
|
- name: config-volume
|
||||||
|
configMap:
|
||||||
|
name: home-automation-config
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: api
|
||||||
|
labels:
|
||||||
|
app: api
|
||||||
|
component: home-automation
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: api
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 8001
|
||||||
|
name: http
|
||||||
|
type: ClusterIP
|
||||||
22
deployment/configmap.yaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: home-automation-environment
|
||||||
|
namespace: homea2
|
||||||
|
data:
|
||||||
|
# Default environment variables
|
||||||
|
SHARED_MQTT_BROKER: "emqx01-anonymous-cluster-internal.broker.svc.cluster.local"
|
||||||
|
SHARED_MQTT_PORT: "1883"
|
||||||
|
SHARED_REDIS_HOST: "redis-master.redis.svc.cluster.local"
|
||||||
|
SHARED_REDIS_PORT: "6379"
|
||||||
|
SHARED_REDIS_DB: "8"
|
||||||
|
|
||||||
|
# UI specific environment variables
|
||||||
|
UI_UI_PORT: "8002"
|
||||||
|
UI_API_BASE: "https://homea2-api.hottis.de"
|
||||||
|
UI_STATIC_BASE: "https://homea2-static.hottis.de"
|
||||||
|
UI_BASE_PATH: "/"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
96
deployment/ingress.yaml
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: Certificate
|
||||||
|
metadata:
|
||||||
|
name: homea2-cert
|
||||||
|
spec:
|
||||||
|
secretName: homea2-cert
|
||||||
|
issuerRef:
|
||||||
|
name: letsencrypt-production-http
|
||||||
|
kind: ClusterIssuer
|
||||||
|
commonName: homea2.hottis.de
|
||||||
|
dnsNames:
|
||||||
|
- homea2.hottis.de
|
||||||
|
- homea2-api.hottis.de
|
||||||
|
- homea2-static.hottis.de
|
||||||
|
---
|
||||||
|
apiVersion: traefik.containo.us/v1alpha1
|
||||||
|
kind: TLSOption
|
||||||
|
metadata:
|
||||||
|
name: mtls-required
|
||||||
|
spec:
|
||||||
|
clientAuth:
|
||||||
|
clientAuthType: RequireAndVerifyClientCert
|
||||||
|
secretNames:
|
||||||
|
- mtls-ca-cert
|
||||||
|
---
|
||||||
|
apiVersion: traefik.containo.us/v1alpha1
|
||||||
|
kind: IngressRoute
|
||||||
|
metadata:
|
||||||
|
name: ui
|
||||||
|
spec:
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
tls:
|
||||||
|
secretName: homea2-cert
|
||||||
|
options:
|
||||||
|
name: mtls-required
|
||||||
|
namespace: homea2
|
||||||
|
routes:
|
||||||
|
- match: Host(`homea2.hottis.de`)
|
||||||
|
kind: Rule
|
||||||
|
services:
|
||||||
|
- name: ui
|
||||||
|
port: 80
|
||||||
|
---
|
||||||
|
apiVersion: traefik.containo.us/v1alpha1
|
||||||
|
kind: IngressRoute
|
||||||
|
metadata:
|
||||||
|
name: api
|
||||||
|
spec:
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
tls:
|
||||||
|
secretName: homea2-cert
|
||||||
|
options:
|
||||||
|
name: mtls-required
|
||||||
|
namespace: homea2
|
||||||
|
routes:
|
||||||
|
- match: Host(`homea2-api.hottis.de`)
|
||||||
|
kind: Rule
|
||||||
|
services:
|
||||||
|
- name: api
|
||||||
|
port: 80
|
||||||
|
---
|
||||||
|
apiVersion: traefik.containo.us/v1alpha1
|
||||||
|
kind: IngressRoute
|
||||||
|
metadata:
|
||||||
|
name: static
|
||||||
|
spec:
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
tls:
|
||||||
|
secretName: homea2-cert
|
||||||
|
routes:
|
||||||
|
- match: Host(`homea2-static.hottis.de`)
|
||||||
|
kind: Rule
|
||||||
|
services:
|
||||||
|
- name: static
|
||||||
|
port: 80
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: api-internal
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik-internal
|
||||||
|
rules:
|
||||||
|
- host: homea2-api-internal.hottis.de
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: api
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
51
deployment/pulsegen-deployment.yaml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: pulsegen
|
||||||
|
namespace: homea2
|
||||||
|
labels:
|
||||||
|
app: pulsegen
|
||||||
|
component: home-automation
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: pulsegen
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
reloader.stakater.com/auto: "true"
|
||||||
|
configmap.reloader.stakater.com/reload: "home-automation-environment"
|
||||||
|
labels:
|
||||||
|
app: pulsegen
|
||||||
|
component: home-automation
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: pulsegen
|
||||||
|
image: %IMAGE%
|
||||||
|
env:
|
||||||
|
- name: MQTT_BROKER
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_MQTT_BROKER
|
||||||
|
- name: MQTT_PORT
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_MQTT_PORT
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 1Gi
|
||||||
|
requests:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 256Mi
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- "ps aux | grep -v grep | grep python"
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
73
deployment/rules-deployment.yaml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: rules
|
||||||
|
labels:
|
||||||
|
app: rules
|
||||||
|
component: home-automation
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: rules
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
reloader.stakater.com/auto: "true"
|
||||||
|
configmap.reloader.stakater.com/reload: "home-automation-environment,home-automation-config"
|
||||||
|
labels:
|
||||||
|
app: rules
|
||||||
|
component: home-automation
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: rules
|
||||||
|
image: %IMAGE%
|
||||||
|
env:
|
||||||
|
- name: MQTT_BROKER
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_MQTT_BROKER
|
||||||
|
- name: MQTT_PORT
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_MQTT_PORT
|
||||||
|
- name: REDIS_HOST
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_REDIS_HOST
|
||||||
|
- name: REDIS_PORT
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_REDIS_PORT
|
||||||
|
- name: REDIS_DB
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: SHARED_REDIS_DB
|
||||||
|
volumeMounts:
|
||||||
|
- name: config-volume
|
||||||
|
mountPath: /app/config
|
||||||
|
readOnly: true
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- "ps aux | grep -v grep | grep python"
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
volumes:
|
||||||
|
- name: config-volume
|
||||||
|
configMap:
|
||||||
|
name: home-automation-config
|
||||||
61
deployment/static-deployment.yaml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: static
|
||||||
|
namespace: homea2
|
||||||
|
labels:
|
||||||
|
app: static
|
||||||
|
component: home-automation
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: static
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: static
|
||||||
|
component: home-automation
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: static
|
||||||
|
image: %IMAGE%
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
name: http
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 30
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 80
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 128Mi
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: static
|
||||||
|
namespace: homea2
|
||||||
|
labels:
|
||||||
|
app: static
|
||||||
|
component: home-automation
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: static
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 80
|
||||||
|
name: http
|
||||||
|
type: ClusterIP
|
||||||
84
deployment/ui-deployment.yaml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: ui
|
||||||
|
namespace: homea2
|
||||||
|
labels:
|
||||||
|
app: ui
|
||||||
|
component: home-automation
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: ui
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
reloader.stakater.com/auto: "true"
|
||||||
|
configmap.reloader.stakater.com/reload: "home-automation-environment"
|
||||||
|
labels:
|
||||||
|
app: ui
|
||||||
|
component: home-automation
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: ui
|
||||||
|
image: %IMAGE%
|
||||||
|
ports:
|
||||||
|
- containerPort: 8002
|
||||||
|
name: http
|
||||||
|
env:
|
||||||
|
- name: UI_PORT
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: UI_UI_PORT
|
||||||
|
- name: API_BASE
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: UI_API_BASE
|
||||||
|
- name: STATIC_BASE
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: UI_STATIC_BASE
|
||||||
|
- name: BASE_PATH
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: home-automation-environment
|
||||||
|
key: UI_BASE_PATH
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 8002
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 8002
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: ui
|
||||||
|
labels:
|
||||||
|
app: ui
|
||||||
|
component: home-automation
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: ui
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 8002
|
||||||
|
name: http
|
||||||
|
type: ClusterIP
|
||||||
59
icon-generator.html
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Icon Generator</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="homeCanvas" width="180" height="180" style="border: 1px solid #ccc;"></canvas>
|
||||||
|
<canvas id="garageCanvas" width="180" height="180" style="border: 1px solid #ccc;"></canvas>
|
||||||
|
<br><br>
|
||||||
|
<button onclick="downloadHome()">Download Home Icon</button>
|
||||||
|
<button onclick="downloadGarage()">Download Garage Icon</button>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Home Icon
|
||||||
|
const homeCanvas = document.getElementById('homeCanvas');
|
||||||
|
const homeCtx = homeCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Fill background
|
||||||
|
homeCtx.fillStyle = '#667EEA';
|
||||||
|
homeCtx.fillRect(0, 0, 180, 180);
|
||||||
|
|
||||||
|
// Add house emoji
|
||||||
|
homeCtx.font = '80px Arial';
|
||||||
|
homeCtx.fillStyle = 'white';
|
||||||
|
homeCtx.textAlign = 'center';
|
||||||
|
homeCtx.textBaseline = 'middle';
|
||||||
|
homeCtx.fillText('🏡', 90, 90);
|
||||||
|
|
||||||
|
// Garage Icon
|
||||||
|
const garageCanvas = document.getElementById('garageCanvas');
|
||||||
|
const garageCtx = garageCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Fill background
|
||||||
|
garageCtx.fillStyle = '#667EEA';
|
||||||
|
garageCtx.fillRect(0, 0, 180, 180);
|
||||||
|
|
||||||
|
// Add car emoji
|
||||||
|
garageCtx.font = '80px Arial';
|
||||||
|
garageCtx.fillStyle = 'white';
|
||||||
|
garageCtx.textAlign = 'center';
|
||||||
|
garageCtx.textBaseline = 'middle';
|
||||||
|
garageCtx.fillText('🚗', 90, 90);
|
||||||
|
|
||||||
|
function downloadHome() {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = 'apple-touch-icon.png';
|
||||||
|
link.href = homeCanvas.toDataURL();
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadGarage() {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = 'garage-icon.png';
|
||||||
|
link.href = garageCanvas.toDataURL();
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
77
icon-test.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Icon Test</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.icon-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.icon-test {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.icon-test img {
|
||||||
|
display: block;
|
||||||
|
margin: 10px auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.icon-sizes {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Apple Touch Icon Test</h1>
|
||||||
|
|
||||||
|
<div class="icon-container">
|
||||||
|
<div class="icon-test">
|
||||||
|
<h3>Home Icon</h3>
|
||||||
|
<img src="apps/ui/static/apple-touch-icon.png" alt="Home Icon" width="120" height="120">
|
||||||
|
<p>Haupticon für die Home Automation App</p>
|
||||||
|
<div class="icon-sizes">
|
||||||
|
<img src="apps/ui/static/apple-touch-icon-76x76.png" alt="Home 76px" width="76" height="76">
|
||||||
|
<img src="apps/ui/static/apple-touch-icon-60x60.png" alt="Home 60px" width="60" height="60">
|
||||||
|
<img src="apps/ui/static/apple-touch-icon-32x32.png" alt="Home 32px" width="32" height="32">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="icon-test">
|
||||||
|
<h3>Garage Icon</h3>
|
||||||
|
<img src="apps/ui/static/garage-icon.png" alt="Garage Icon" width="120" height="120">
|
||||||
|
<p>Icon für die Garage-Seite</p>
|
||||||
|
<div class="icon-sizes">
|
||||||
|
<img src="apps/ui/static/garage-icon-76x76.png" alt="Garage 76px" width="76" height="76">
|
||||||
|
<img src="apps/ui/static/garage-icon-60x60.png" alt="Garage 60px" width="60" height="60">
|
||||||
|
<img src="apps/ui/static/garage-icon-32x32.png" alt="Garage 32px" width="32" height="32">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>iPhone Homescreen Test</h2>
|
||||||
|
<p>Um die Icons auf dem iPhone zu testen:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Öffnen Sie Ihre Home Automation App im Safari</li>
|
||||||
|
<li>Tippen Sie auf das Teilen-Symbol</li>
|
||||||
|
<li>Wählen Sie "Zum Home-Bildschirm hinzufügen"</li>
|
||||||
|
<li>Das Icon sollte jetzt als Haus-Symbol erscheinen</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p><strong>Hinweis:</strong> Falls das alte Icon noch angezeigt wird, löschen Sie die bestehende App vom Homescreen und fügen Sie sie neu hinzu.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -18,6 +18,7 @@ class DeviceTile(BaseModel):
|
|||||||
title: Display title for the device
|
title: Display title for the device
|
||||||
icon: Icon name or emoji for the device
|
icon: Icon name or emoji for the device
|
||||||
rank: Sort order within the room (lower = first)
|
rank: Sort order within the room (lower = first)
|
||||||
|
excluded: Optional flag to exclude device from certain operations
|
||||||
"""
|
"""
|
||||||
|
|
||||||
device_id: str = Field(
|
device_id: str = Field(
|
||||||
@@ -40,16 +41,27 @@ class DeviceTile(BaseModel):
|
|||||||
ge=0,
|
ge=0,
|
||||||
description="Sort order (lower values appear first)"
|
description="Sort order (lower values appear first)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
excluded: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Exclude device from bulk operations"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Room(BaseModel):
|
class Room(BaseModel):
|
||||||
"""Represents a room containing devices.
|
"""Represents a room containing devices.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
|
id: Unique room identifier (used for API endpoints)
|
||||||
name: Room name (e.g., "Wohnzimmer", "Küche")
|
name: Room name (e.g., "Wohnzimmer", "Küche")
|
||||||
devices: List of device tiles in this room
|
devices: List of device tiles in this room
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
id: str = Field(
|
||||||
|
...,
|
||||||
|
description="Unique room identifier"
|
||||||
|
)
|
||||||
|
|
||||||
name: str = Field(
|
name: str = Field(
|
||||||
...,
|
...,
|
||||||
description="Room name"
|
description="Room name"
|
||||||
|
|||||||
29
test-icons.sh
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script to test icon accessibility over HTTPS/mTLS
|
||||||
|
|
||||||
|
echo "Testing Apple Touch Icon accessibility..."
|
||||||
|
|
||||||
|
# Test base icons
|
||||||
|
echo "1. Testing main apple-touch-icon.png:"
|
||||||
|
curl -I "https://your-domain.com/static/apple-touch-icon-180x180.png" || echo "FAILED"
|
||||||
|
|
||||||
|
echo "2. Testing garage icon:"
|
||||||
|
curl -I "https://your-domain.com/static/garage-icon-180x180.png" || echo "FAILED"
|
||||||
|
|
||||||
|
echo "3. Testing manifest:"
|
||||||
|
curl -I "https://your-domain.com/manifest.json" || echo "FAILED"
|
||||||
|
|
||||||
|
echo "4. Testing favicon route:"
|
||||||
|
curl -I "https://your-domain.com/favicon.ico" || echo "FAILED"
|
||||||
|
|
||||||
|
echo "5. Testing apple-touch-icon route:"
|
||||||
|
curl -I "https://your-domain.com/apple-touch-icon.png" || echo "FAILED"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Testing mTLS with client certificate:"
|
||||||
|
echo "6. Testing with client cert:"
|
||||||
|
curl -I --cert client.crt --key client.key "https://your-domain.com/static/apple-touch-icon-180x180.png" || echo "FAILED"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Note: Replace 'your-domain.com' with your actual domain"
|
||||||
|
echo "Note: Use actual client certificate files if testing mTLS"
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
# Device Simulator
|
|
||||||
|
|
||||||
Unified MQTT device simulator für das Home Automation System.
|
|
||||||
|
|
||||||
## Übersicht
|
|
||||||
|
|
||||||
Dieser Simulator ersetzt die einzelnen Simulatoren (`sim_test_lampe.py`, `sim_thermo.py`) und vereint alle Device-Typen in einer einzigen Anwendung.
|
|
||||||
|
|
||||||
## Unterstützte Geräte
|
|
||||||
|
|
||||||
### Lampen (3 Geräte)
|
|
||||||
- `test_lampe_1` - Mit Power und Brightness
|
|
||||||
- `test_lampe_2` - Mit Power und Brightness
|
|
||||||
- `test_lampe_3` - Mit Power und Brightness
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- `power`: "on" oder "off"
|
|
||||||
- `brightness`: 0-100
|
|
||||||
|
|
||||||
### Thermostaten (1 Gerät)
|
|
||||||
- `test_thermo_1` - Vollständiger Thermostat mit Temperatur-Simulation
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- `mode`: "off", "heat", oder "auto"
|
|
||||||
- `target`: Soll-Temperatur (5.0-30.0°C)
|
|
||||||
- `current`: Ist-Temperatur (wird simuliert)
|
|
||||||
- `battery`: Batteriestand (90%)
|
|
||||||
- `window_open`: Fensterstatus (false)
|
|
||||||
|
|
||||||
**Temperatur-Simulation:**
|
|
||||||
- Alle 5 Sekunden wird die Ist-Temperatur angepasst
|
|
||||||
- **HEAT/AUTO Mode**: Drift zu `target` (+0.2°C pro Intervall)
|
|
||||||
- **OFF Mode**: Drift zu Ambient-Temperatur 18°C (-0.2°C pro Intervall)
|
|
||||||
|
|
||||||
## MQTT-Konfiguration
|
|
||||||
|
|
||||||
- **Broker**: 172.16.2.16:1883 (konfigurierbar via ENV)
|
|
||||||
- **QoS**: 1 für alle Publishes
|
|
||||||
- **Retained**: Ja für alle State-Messages
|
|
||||||
- **Client ID**: device_simulator
|
|
||||||
|
|
||||||
### Topics
|
|
||||||
|
|
||||||
Für jedes Gerät:
|
|
||||||
- Subscribe: `vendor/{device_id}/set` (QoS 1)
|
|
||||||
- Publish: `vendor/{device_id}/state` (QoS 1, retained)
|
|
||||||
|
|
||||||
## Verwendung
|
|
||||||
|
|
||||||
### Starten
|
|
||||||
|
|
||||||
```bash
|
|
||||||
poetry run python tools/device_simulator.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Oder im Hintergrund:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
poetry run python tools/device_simulator.py > /tmp/simulator.log 2>&1 &
|
|
||||||
```
|
|
||||||
|
|
||||||
### Umgebungsvariablen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export MQTT_BROKER="172.16.2.16" # MQTT Broker Host
|
|
||||||
export MQTT_PORT="1883" # MQTT Broker Port
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testen
|
|
||||||
|
|
||||||
Ein umfassendes Test-Skript ist verfügbar:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./tools/test_device_simulator.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Das Test-Skript:
|
|
||||||
1. Stoppt alle laufenden Services
|
|
||||||
2. Startet Abstraction Layer, API und Simulator
|
|
||||||
3. Testet alle Lampen-Operationen
|
|
||||||
4. Testet alle Thermostat-Operationen
|
|
||||||
5. Verifiziert MQTT State Messages
|
|
||||||
6. Zeigt Simulator-Logs
|
|
||||||
|
|
||||||
## Beispiele
|
|
||||||
|
|
||||||
### Lampe einschalten
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8001/devices/test_lampe_1/set \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"type":"light","payload":{"power":"on"}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Helligkeit setzen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8001/devices/test_lampe_1/set \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"type":"light","payload":{"brightness":75}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Thermostat Mode setzen
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8001/devices/test_thermo_1/set \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"type":"thermostat","payload":{"mode":"heat","target":22.5}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### State abfragen via MQTT
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Lampe
|
|
||||||
mosquitto_sub -h 172.16.2.16 -t 'vendor/test_lampe_1/state' -C 1
|
|
||||||
|
|
||||||
# Thermostat
|
|
||||||
mosquitto_sub -h 172.16.2.16 -t 'vendor/test_thermo_1/state' -C 1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architektur
|
|
||||||
|
|
||||||
```
|
|
||||||
Browser/API
|
|
||||||
↓ POST /devices/{id}/set
|
|
||||||
API Server (Port 8001)
|
|
||||||
↓ MQTT: home/{type}/{id}/set
|
|
||||||
Abstraction Layer
|
|
||||||
↓ MQTT: vendor/{id}/set
|
|
||||||
Device Simulator
|
|
||||||
↓ MQTT: vendor/{id}/state (retained)
|
|
||||||
Abstraction Layer
|
|
||||||
↓ MQTT: home/{type}/{id}/state (retained)
|
|
||||||
↓ Redis Pub/Sub: ui:updates
|
|
||||||
UI / Dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
## Logs
|
|
||||||
|
|
||||||
Der Simulator loggt alle Aktivitäten:
|
|
||||||
- Startup und MQTT-Verbindung
|
|
||||||
- Empfangene SET-Commands
|
|
||||||
- State-Änderungen
|
|
||||||
- Temperature-Drift (Thermostaten)
|
|
||||||
- Publizierte State-Messages
|
|
||||||
|
|
||||||
Log-Level: INFO
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Simulator startet nicht
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Prüfe ob Port bereits belegt
|
|
||||||
lsof -ti:1883
|
|
||||||
|
|
||||||
# Prüfe MQTT Broker
|
|
||||||
mosquitto_sub -h 172.16.2.16 -t '#' -C 1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Keine State-Updates
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Prüfe Simulator-Log
|
|
||||||
tail -f /tmp/simulator.log
|
|
||||||
|
|
||||||
# Prüfe MQTT Topics
|
|
||||||
mosquitto_sub -h 172.16.2.16 -t 'vendor/#' -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### API antwortet nicht
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Prüfe ob API läuft
|
|
||||||
curl http://localhost:8001/devices
|
|
||||||
|
|
||||||
# Prüfe API-Log
|
|
||||||
tail -f /tmp/api.log
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration
|
|
||||||
|
|
||||||
Der Simulator integriert sich nahtlos in das Home Automation System:
|
|
||||||
|
|
||||||
1. **Abstraction Layer** empfängt Commands und sendet sie an Simulator
|
|
||||||
2. **Simulator** reagiert und publiziert neuen State
|
|
||||||
3. **Abstraction Layer** empfängt State und publiziert zu Redis
|
|
||||||
4. **UI** empfängt Updates via SSE und aktualisiert Dashboard
|
|
||||||
|
|
||||||
Alle Komponenten arbeiten vollständig asynchron über MQTT.
|
|
||||||