window contact first try

This commit is contained in:
2025-11-10 19:41:08 +01:00
parent e728dd58e4
commit 19a6a603d5
8 changed files with 360 additions and 14 deletions

View File

@@ -15,7 +15,7 @@ import uuid
from aiomqtt import Client
from pydantic import ValidationError
from packages.home_capabilities import LightState, ThermostatState
from packages.home_capabilities import LightState, ThermostatState, ContactState
from apps.abstraction.transformation import (
transform_abstract_to_vendor,
transform_vendor_to_abstract
@@ -89,11 +89,12 @@ def validate_devices(devices: list[dict[str, Any]]) -> None:
if "topics" not in device:
raise ValueError(f"Device {device_id} missing 'topics'")
if "set" not in device["topics"]:
raise ValueError(f"Device {device_id} missing 'topics.set'")
# 'state' topic is required for all devices
if "state" not in device["topics"]:
raise ValueError(f"Device {device_id} missing 'topics.state'")
# 'set' topic is optional (read-only devices like contact sensors don't have it)
# No validation needed for topics.set
# Log loaded devices
device_ids = [d["device_id"] for d in devices]
@@ -166,6 +167,10 @@ async def handle_abstract_set(
# Validate against ThermostatState (current/battery/window_open are optional)
ThermostatState.model_validate(abstract_payload)
elif device_type in {"contact", "contact_sensor"}:
# Contact sensors are read-only - SET commands should not occur
logger.warning(f"Contact sensor {device_id} received SET command - ignoring (read-only device)")
return
except ValidationError as e:
logger.error(f"Validation failed for {device_type} SET {device_id}: {e}")
return
@@ -214,12 +219,18 @@ async def handle_vendor_state(
elif device_type == "thermostat":
# Validate thermostat state: mode, target, current (required), battery, window_open
ThermostatState.model_validate(abstract_payload)
elif device_type in {"contact", "contact_sensor"}:
# Validate contact sensor state
ContactState.model_validate(abstract_payload)
except ValidationError as e:
logger.error(f"Validation failed for {device_type} STATE {device_id}: {e}")
return
# Normalize device type for topic (use 'contact' for both 'contact' and 'contact_sensor')
topic_type = "contact" if device_type in {"contact", "contact_sensor"} else device_type
# Publish to abstract state topic (retained)
abstract_topic = f"home/{device_type}/{device_id}/state"
abstract_topic = f"home/{topic_type}/{device_id}/state"
abstract_message = json.dumps(abstract_payload)
logger.info(f"← abstract STATE {device_id}: {abstract_topic}{abstract_message}")
@@ -273,15 +284,22 @@ async def mqtt_worker(config: dict[str, Any], redis_client: aioredis.Redis) -> N
keepalive=keepalive,
timeout=10.0 # Add explicit timeout for operations
) as client:
logger.info(f"Connected to MQTT broker as {client_id}")
logger.info(f"Connected to MQTT broker as {unique_client_id}")
# Subscribe to abstract SET topics for all devices
# Subscribe to topics for all devices
for device in devices.values():
abstract_set_topic = f"home/{device['type']}/{device['device_id']}/set"
await client.subscribe(abstract_set_topic)
logger.info(f"Subscribed to abstract SET: {abstract_set_topic}")
device_id = device['device_id']
device_type = device['type']
# Subscribe to vendor STATE topics
# Subscribe to abstract SET topic only if device has a SET topic (not read-only)
if "set" in device["topics"]:
abstract_set_topic = f"home/{device_type}/{device_id}/set"
await client.subscribe(abstract_set_topic)
logger.info(f"Subscribed to abstract SET: {abstract_set_topic}")
else:
logger.info(f"Skipping SET subscription for read-only device: {device_id}")
# Subscribe to vendor STATE topics (all devices have state)
vendor_state_topic = device["topics"]["state"]
await client.subscribe(vendor_state_topic)
logger.info(f"Subscribed to vendor STATE: {vendor_state_topic}")

View File

@@ -159,6 +159,112 @@ def _transform_thermostat_zigbee2mqtt_to_abstract(payload: dict[str, Any]) -> di
return abstract_payload
# ============================================================================
# HANDLER FUNCTIONS: contact_sensor - zigbee2mqtt technology
# ============================================================================
def _transform_contact_sensor_zigbee2mqtt_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract contact sensor payload to zigbee2mqtt format.
Contact sensors are read-only, so this should not be called for SET commands.
Returns payload as-is for compatibility.
"""
logger.warning("Contact sensors are read-only - SET commands should not be used")
return payload
def _transform_contact_sensor_zigbee2mqtt_to_abstract(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform zigbee2mqtt contact sensor payload to abstract format.
Transformations:
- contact: bool -> "open" | "closed"
- zigbee2mqtt semantics: False = OPEN, True = CLOSED (inverted!)
- battery: pass through (already 0-100)
- linkquality: pass through
- device_temperature: pass through (if present)
- voltage: pass through (if present)
Example:
- zigbee2mqtt: {"contact": false, "battery": 100, "linkquality": 87}
- Abstract: {"contact": "open", "battery": 100, "linkquality": 87}
"""
abstract_payload = {}
# Transform contact state (inverted logic!)
if "contact" in payload:
contact_bool = payload["contact"]
# zigbee2mqtt: False = OPEN, True = CLOSED
abstract_payload["contact"] = "closed" if contact_bool else "open"
# Pass through optional fields
if "battery" in payload:
abstract_payload["battery"] = payload["battery"]
if "linkquality" in payload:
abstract_payload["linkquality"] = payload["linkquality"]
if "device_temperature" in payload:
abstract_payload["device_temperature"] = payload["device_temperature"]
if "voltage" in payload:
abstract_payload["voltage"] = payload["voltage"]
return abstract_payload
# ============================================================================
# HANDLER FUNCTIONS: contact_sensor - max technology (Homegear MAX!)
# ============================================================================
def _transform_contact_sensor_max_to_vendor(payload: dict[str, Any]) -> dict[str, Any]:
"""Transform abstract contact sensor payload to MAX! format.
Contact sensors are read-only, so this should not be called for SET commands.
Returns payload as-is for compatibility.
"""
logger.warning("Contact sensors are read-only - SET commands should not be used")
return payload
def _transform_contact_sensor_max_to_abstract(payload: str | bool | dict[str, Any]) -> dict[str, Any]:
"""Transform MAX! (Homegear) contact sensor payload to abstract format.
MAX! sends "true"/"false" (string or bool) on STATE topic.
Transformations:
- "true" or True -> "open" (window/door open)
- "false" or False -> "closed" (window/door closed)
Example:
- MAX!: "true" or True
- Abstract: {"contact": "open"}
"""
try:
# Handle string, bool, or dict input
if isinstance(payload, dict):
# If already a dict, extract contact field
contact_value = payload.get("contact", False)
elif isinstance(payload, str):
# Parse string to bool
contact_value = payload.strip().lower() == "true"
elif isinstance(payload, bool):
# Use bool directly
contact_value = payload
else:
logger.warning(f"MAX! contact sensor unexpected payload type: {type(payload)}, value: {payload}")
contact_value = False
# MAX! semantics: True = OPEN, False = CLOSED
return {
"contact": "open" if contact_value else "closed"
}
except (ValueError, TypeError) as e:
logger.error(f"MAX! contact sensor failed to parse: {payload}, error: {e}")
return {
"contact": "closed" # Default to closed on error
}
# ============================================================================
# HANDLER FUNCTIONS: max technology (Homegear MAX!)
# ============================================================================
@@ -252,6 +358,16 @@ TRANSFORM_HANDLERS: dict[tuple[str, str, str], TransformHandler] = {
("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,
}

View File

@@ -15,7 +15,14 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ValidationError
from packages.home_capabilities import LIGHT_VERSION, THERMOSTAT_VERSION, LightState, ThermostatState
from packages.home_capabilities import (
LIGHT_VERSION,
THERMOSTAT_VERSION,
CONTACT_SENSOR_VERSION,
LightState,
ThermostatState,
ContactState
)
logger = logging.getLogger(__name__)
@@ -137,7 +144,8 @@ async def spec() -> dict[str, dict[str, str]]:
return {
"capabilities": {
"light": LIGHT_VERSION,
"thermostat": THERMOSTAT_VERSION
"thermostat": THERMOSTAT_VERSION,
"contact": CONTACT_SENSOR_VERSION
}
}
@@ -331,6 +339,13 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str
detail=f"Device {device_id} not found"
)
# Check if device is read-only (contact sensors, etc.)
if "topics" in device and "set" not in device["topics"]:
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Device is read-only"
)
# Validate payload based on device type
if request.type == "light":
try:
@@ -356,6 +371,12 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid payload for thermostat: {e}"
)
elif request.type in {"contact", "contact_sensor"}:
# Contact sensors are read-only
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Contact sensors are read-only devices"
)
else:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,

View File

@@ -386,7 +386,45 @@
transform: scale(0.95);
}
/* Contact Sensor Styles */
.contact-status {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
margin: 1rem 0;
}
.contact-badge {
padding: 0.5rem 1rem;
border-radius: 6px;
font-weight: 600;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.contact-badge.open {
background: #dc3545;
color: white;
}
.contact-badge.closed {
background: #28a745;
color: white;
}
.contact-info {
font-size: 0.75rem;
color: #999;
margin-top: 1rem;
padding: 0.5rem;
background: #f8f9fa;
border-radius: 4px;
text-align: center;
}
.events {
margin-top: 2rem;
@@ -483,6 +521,8 @@
{% if device.features.brightness %}• Dimmbar{% endif %}
{% elif device.type == "thermostat" %}
Thermostat
{% elif device.type == "contact" or device.type == "contact_sensor" %}
Contact Sensor • Read-Only
{% else %}
{{ device.type or "Unknown" }}
{% endif %}
@@ -555,6 +595,18 @@
+1.0
</button>
</div>
{% elif device.type == "contact" or device.type == "contact_sensor" %}
<div class="contact-status">
<span class="contact-badge closed" id="state-{{ device.device_id }}">
Geschlossen
</span>
</div>
<div class="contact-info">
🔒 Nur-Lesen Gerät • Keine Steuerung möglich
</div>
{% endif %}
</div>
{% endfor %}
@@ -852,6 +904,24 @@
}
}
// Update contact sensor UI
function updateContactUI(deviceId, contactState) {
const badge = document.getElementById(`state-${deviceId}`);
if (!badge) {
console.warn(`No contact badge found for device ${deviceId}`);
return;
}
// contactState is either "open" or "closed"
if (contactState === "open") {
badge.textContent = "Geöffnet";
badge.className = "contact-badge open";
} else if (contactState === "closed") {
badge.textContent = "Geschlossen";
badge.className = "contact-badge closed";
}
}
// Add event to list
function addEvent(event) {
const eventList = document.getElementById('event-list');
@@ -917,6 +987,11 @@
data.payload.target
);
}
// Check if it's a contact sensor
if (data.payload.contact !== undefined) {
updateContactUI(data.device_id, data.payload.contact);
}
}
};
@@ -1035,6 +1110,9 @@
// It's a thermostat
if (state.target) thermostatTargets[deviceId] = state.target;
updateThermostatUI(deviceId, state.current, state.target);
} else if (state.contact !== undefined) {
// It's a contact sensor
updateContactUI(deviceId, state.contact);
}
}
} catch (error) {

View File

@@ -518,3 +518,12 @@ devices:
ieee_address: "0xf0d1b80000155fc2"
model: "AC10691"
vendor: "OSRAM"
- device_id: fenster_wohnzimmer
type: contact
name: Fenster Wohnzimmer
cap_version: contact_sensor@1.0.0
technology: zigbee2mqtt
topics:
state: zigbee2mqtt/0x540f57fffe123456
features: {}

View File

@@ -69,6 +69,10 @@ rooms:
title: Thermostat Wohnzimmer
icon: 🌡️
rank: 135
- device_id: fenster_wohnzimmer
title: Fenster Wohnzimmer
icon: 🪟
rank: 140
- name: Küche
devices:
- device_id: kueche_deckenlampe

View File

@@ -4,6 +4,8 @@ from packages.home_capabilities.light import CAP_VERSION as LIGHT_VERSION
from packages.home_capabilities.light import LightState
from packages.home_capabilities.thermostat import CAP_VERSION as THERMOSTAT_VERSION
from packages.home_capabilities.thermostat import ThermostatState
from packages.home_capabilities.contact_sensor import CAP_VERSION as CONTACT_SENSOR_VERSION
from packages.home_capabilities.contact_sensor import ContactState
from packages.home_capabilities.layout import DeviceTile, Room, UiLayout, load_layout
__all__ = [
@@ -11,6 +13,8 @@ __all__ = [
"LIGHT_VERSION",
"ThermostatState",
"THERMOSTAT_VERSION",
"ContactState",
"CONTACT_SENSOR_VERSION",
"DeviceTile",
"Room",
"UiLayout",

View File

@@ -0,0 +1,96 @@
"""Contact Sensor Capability - Fensterkontakt (read-only).
This module defines the ContactState model for door/window contact sensors.
These sensors report their open/closed state and are read-only devices.
Capability Version: contact_sensor@1.0.0
"""
from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel, Field, field_validator
# Capability metadata
CAP_VERSION = "contact_sensor@1.0.0"
DISPLAY_NAME = "Contact Sensor"
class ContactState(BaseModel):
"""State model for contact sensors (door/window sensors).
Contact sensors are read-only devices that report whether a door or window
is open or closed. They typically also report battery level and signal quality.
Attributes:
contact: Current state of the contact ("open" or "closed")
battery: Battery level percentage (0-100), optional
linkquality: MQTT link quality indicator, optional
device_temperature: Internal device temperature in °C, optional
voltage: Battery voltage in mV, optional
ts: Timestamp of the state reading, optional
Examples:
>>> ContactState(contact="open")
ContactState(contact='open', battery=None, ...)
>>> ContactState(contact="closed", battery=95, linkquality=87)
ContactState(contact='closed', battery=95, linkquality=87, ...)
"""
contact: Literal["open", "closed"] = Field(
...,
description="Contact state: 'open' for open door/window, 'closed' for closed"
)
battery: Annotated[int, Field(ge=0, le=100)] | None = Field(
None,
description="Battery level in percent (0-100)"
)
linkquality: int | None = Field(
None,
description="Link quality indicator (typically 0-255)"
)
device_temperature: float | None = Field(
None,
description="Internal device temperature in degrees Celsius"
)
voltage: int | None = Field(
None,
description="Battery voltage in millivolts"
)
ts: datetime | None = Field(
None,
description="Timestamp of the state reading"
)
@staticmethod
def normalize_bool(is_open: bool) -> "ContactState":
"""Convert boolean to ContactState.
Helper method to convert a boolean value to a ContactState instance.
Useful when integrating with systems that use True/False for contact state.
Args:
is_open: True if contact is open, False if closed
Returns:
ContactState instance with appropriate contact value
Examples:
>>> ContactState.normalize_bool(True)
ContactState(contact='open', ...)
>>> ContactState.normalize_bool(False)
ContactState(contact='closed', ...)
"""
return ContactState(contact="open" if is_open else "closed")
# Public API
__all__ = ["ContactState", "CAP_VERSION", "DISPLAY_NAME"]