window contact first try
This commit is contained in:
@@ -15,7 +15,7 @@ import uuid
|
|||||||
from aiomqtt import Client
|
from aiomqtt import Client
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from packages.home_capabilities import LightState, ThermostatState
|
from packages.home_capabilities import LightState, ThermostatState, ContactState
|
||||||
from apps.abstraction.transformation import (
|
from apps.abstraction.transformation import (
|
||||||
transform_abstract_to_vendor,
|
transform_abstract_to_vendor,
|
||||||
transform_vendor_to_abstract
|
transform_vendor_to_abstract
|
||||||
@@ -89,12 +89,13 @@ def validate_devices(devices: list[dict[str, Any]]) -> None:
|
|||||||
if "topics" not in device:
|
if "topics" not in device:
|
||||||
raise ValueError(f"Device {device_id} missing 'topics'")
|
raise ValueError(f"Device {device_id} missing 'topics'")
|
||||||
|
|
||||||
if "set" not in device["topics"]:
|
# 'state' topic is required for all devices
|
||||||
raise ValueError(f"Device {device_id} missing 'topics.set'")
|
|
||||||
|
|
||||||
if "state" not in device["topics"]:
|
if "state" not in device["topics"]:
|
||||||
raise ValueError(f"Device {device_id} missing 'topics.state'")
|
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
|
# Log loaded devices
|
||||||
device_ids = [d["device_id"] for d in devices]
|
device_ids = [d["device_id"] for d in devices]
|
||||||
logger.info(f"Loaded {len(devices)} device(s): {', '.join(device_ids)}")
|
logger.info(f"Loaded {len(devices)} device(s): {', '.join(device_ids)}")
|
||||||
@@ -166,6 +167,10 @@ async def handle_abstract_set(
|
|||||||
|
|
||||||
# Validate against ThermostatState (current/battery/window_open are optional)
|
# Validate against ThermostatState (current/battery/window_open are optional)
|
||||||
ThermostatState.model_validate(abstract_payload)
|
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:
|
except ValidationError as e:
|
||||||
logger.error(f"Validation failed for {device_type} SET {device_id}: {e}")
|
logger.error(f"Validation failed for {device_type} SET {device_id}: {e}")
|
||||||
return
|
return
|
||||||
@@ -214,12 +219,18 @@ async def handle_vendor_state(
|
|||||||
elif device_type == "thermostat":
|
elif device_type == "thermostat":
|
||||||
# Validate thermostat state: mode, target, current (required), battery, window_open
|
# Validate thermostat state: mode, target, current (required), battery, window_open
|
||||||
ThermostatState.model_validate(abstract_payload)
|
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:
|
except ValidationError as e:
|
||||||
logger.error(f"Validation failed for {device_type} STATE {device_id}: {e}")
|
logger.error(f"Validation failed for {device_type} STATE {device_id}: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 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)
|
# 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)
|
abstract_message = json.dumps(abstract_payload)
|
||||||
|
|
||||||
logger.info(f"← abstract STATE {device_id}: {abstract_topic} → {abstract_message}")
|
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,
|
keepalive=keepalive,
|
||||||
timeout=10.0 # Add explicit timeout for operations
|
timeout=10.0 # Add explicit timeout for operations
|
||||||
) as client:
|
) 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():
|
for device in devices.values():
|
||||||
abstract_set_topic = f"home/{device['type']}/{device['device_id']}/set"
|
device_id = device['device_id']
|
||||||
await client.subscribe(abstract_set_topic)
|
device_type = device['type']
|
||||||
logger.info(f"Subscribed to abstract SET: {abstract_set_topic}")
|
|
||||||
|
|
||||||
# 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"]
|
vendor_state_topic = device["topics"]["state"]
|
||||||
await client.subscribe(vendor_state_topic)
|
await client.subscribe(vendor_state_topic)
|
||||||
logger.info(f"Subscribed to vendor STATE: {vendor_state_topic}")
|
logger.info(f"Subscribed to vendor STATE: {vendor_state_topic}")
|
||||||
|
|||||||
@@ -159,6 +159,112 @@ def _transform_thermostat_zigbee2mqtt_to_abstract(payload: dict[str, Any]) -> di
|
|||||||
return abstract_payload
|
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!)
|
# 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", "zigbee2mqtt", "to_abstract"): _transform_thermostat_zigbee2mqtt_to_abstract,
|
||||||
("thermostat", "max", "to_vendor"): _transform_thermostat_max_to_vendor,
|
("thermostat", "max", "to_vendor"): _transform_thermostat_max_to_vendor,
|
||||||
("thermostat", "max", "to_abstract"): _transform_thermostat_max_to_abstract,
|
("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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, ValidationError
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -137,7 +144,8 @@ async def spec() -> dict[str, dict[str, str]]:
|
|||||||
return {
|
return {
|
||||||
"capabilities": {
|
"capabilities": {
|
||||||
"light": LIGHT_VERSION,
|
"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"
|
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
|
# Validate payload based on device type
|
||||||
if request.type == "light":
|
if request.type == "light":
|
||||||
try:
|
try:
|
||||||
@@ -356,6 +371,12 @@ async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str
|
|||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=f"Invalid payload for thermostat: {e}"
|
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:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
|||||||
@@ -386,7 +386,45 @@
|
|||||||
transform: scale(0.95);
|
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 {
|
.events {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
@@ -483,6 +521,8 @@
|
|||||||
{% if device.features.brightness %}• Dimmbar{% endif %}
|
{% if device.features.brightness %}• Dimmbar{% endif %}
|
||||||
{% elif device.type == "thermostat" %}
|
{% elif device.type == "thermostat" %}
|
||||||
Thermostat
|
Thermostat
|
||||||
|
{% elif device.type == "contact" or device.type == "contact_sensor" %}
|
||||||
|
Contact Sensor • Read-Only
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ device.type or "Unknown" }}
|
{{ device.type or "Unknown" }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -555,6 +595,18 @@
|
|||||||
+1.0
|
+1.0
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% 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
|
// Add event to list
|
||||||
function addEvent(event) {
|
function addEvent(event) {
|
||||||
const eventList = document.getElementById('event-list');
|
const eventList = document.getElementById('event-list');
|
||||||
@@ -917,6 +987,11 @@
|
|||||||
data.payload.target
|
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
|
// It's a thermostat
|
||||||
if (state.target) thermostatTargets[deviceId] = state.target;
|
if (state.target) thermostatTargets[deviceId] = state.target;
|
||||||
updateThermostatUI(deviceId, state.current, state.target);
|
updateThermostatUI(deviceId, state.current, state.target);
|
||||||
|
} else if (state.contact !== undefined) {
|
||||||
|
// It's a contact sensor
|
||||||
|
updateContactUI(deviceId, state.contact);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -518,3 +518,12 @@ devices:
|
|||||||
ieee_address: "0xf0d1b80000155fc2"
|
ieee_address: "0xf0d1b80000155fc2"
|
||||||
model: "AC10691"
|
model: "AC10691"
|
||||||
vendor: "OSRAM"
|
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: {}
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ rooms:
|
|||||||
title: Thermostat Wohnzimmer
|
title: Thermostat Wohnzimmer
|
||||||
icon: 🌡️
|
icon: 🌡️
|
||||||
rank: 135
|
rank: 135
|
||||||
|
- device_id: fenster_wohnzimmer
|
||||||
|
title: Fenster Wohnzimmer
|
||||||
|
icon: 🪟
|
||||||
|
rank: 140
|
||||||
- name: Küche
|
- name: Küche
|
||||||
devices:
|
devices:
|
||||||
- device_id: kueche_deckenlampe
|
- device_id: kueche_deckenlampe
|
||||||
|
|||||||
@@ -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.light import LightState
|
||||||
from packages.home_capabilities.thermostat import CAP_VERSION as THERMOSTAT_VERSION
|
from packages.home_capabilities.thermostat import CAP_VERSION as THERMOSTAT_VERSION
|
||||||
from packages.home_capabilities.thermostat import ThermostatState
|
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
|
from packages.home_capabilities.layout import DeviceTile, Room, UiLayout, load_layout
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -11,6 +13,8 @@ __all__ = [
|
|||||||
"LIGHT_VERSION",
|
"LIGHT_VERSION",
|
||||||
"ThermostatState",
|
"ThermostatState",
|
||||||
"THERMOSTAT_VERSION",
|
"THERMOSTAT_VERSION",
|
||||||
|
"ContactState",
|
||||||
|
"CONTACT_SENSOR_VERSION",
|
||||||
"DeviceTile",
|
"DeviceTile",
|
||||||
"Room",
|
"Room",
|
||||||
"UiLayout",
|
"UiLayout",
|
||||||
|
|||||||
96
packages/home_capabilities/contact_sensor.py
Normal file
96
packages/home_capabilities/contact_sensor.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""Contact Sensor Capability - Fensterkontakt (read-only).
|
||||||
|
|
||||||
|
This module defines the ContactState model for door/window contact sensors.
|
||||||
|
These sensors report their open/closed state and are read-only devices.
|
||||||
|
|
||||||
|
Capability Version: contact_sensor@1.0.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
# Capability metadata
|
||||||
|
CAP_VERSION = "contact_sensor@1.0.0"
|
||||||
|
DISPLAY_NAME = "Contact Sensor"
|
||||||
|
|
||||||
|
|
||||||
|
class ContactState(BaseModel):
|
||||||
|
"""State model for contact sensors (door/window sensors).
|
||||||
|
|
||||||
|
Contact sensors are read-only devices that report whether a door or window
|
||||||
|
is open or closed. They typically also report battery level and signal quality.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
contact: Current state of the contact ("open" or "closed")
|
||||||
|
battery: Battery level percentage (0-100), optional
|
||||||
|
linkquality: MQTT link quality indicator, optional
|
||||||
|
device_temperature: Internal device temperature in °C, optional
|
||||||
|
voltage: Battery voltage in mV, optional
|
||||||
|
ts: Timestamp of the state reading, optional
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> ContactState(contact="open")
|
||||||
|
ContactState(contact='open', battery=None, ...)
|
||||||
|
|
||||||
|
>>> ContactState(contact="closed", battery=95, linkquality=87)
|
||||||
|
ContactState(contact='closed', battery=95, linkquality=87, ...)
|
||||||
|
"""
|
||||||
|
|
||||||
|
contact: Literal["open", "closed"] = Field(
|
||||||
|
...,
|
||||||
|
description="Contact state: 'open' for open door/window, 'closed' for closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
battery: Annotated[int, Field(ge=0, le=100)] | None = Field(
|
||||||
|
None,
|
||||||
|
description="Battery level in percent (0-100)"
|
||||||
|
)
|
||||||
|
|
||||||
|
linkquality: int | None = Field(
|
||||||
|
None,
|
||||||
|
description="Link quality indicator (typically 0-255)"
|
||||||
|
)
|
||||||
|
|
||||||
|
device_temperature: float | None = Field(
|
||||||
|
None,
|
||||||
|
description="Internal device temperature in degrees Celsius"
|
||||||
|
)
|
||||||
|
|
||||||
|
voltage: int | None = Field(
|
||||||
|
None,
|
||||||
|
description="Battery voltage in millivolts"
|
||||||
|
)
|
||||||
|
|
||||||
|
ts: datetime | None = Field(
|
||||||
|
None,
|
||||||
|
description="Timestamp of the state reading"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize_bool(is_open: bool) -> "ContactState":
|
||||||
|
"""Convert boolean to ContactState.
|
||||||
|
|
||||||
|
Helper method to convert a boolean value to a ContactState instance.
|
||||||
|
Useful when integrating with systems that use True/False for contact state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
is_open: True if contact is open, False if closed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ContactState instance with appropriate contact value
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> ContactState.normalize_bool(True)
|
||||||
|
ContactState(contact='open', ...)
|
||||||
|
|
||||||
|
>>> ContactState.normalize_bool(False)
|
||||||
|
ContactState(contact='closed', ...)
|
||||||
|
"""
|
||||||
|
return ContactState(contact="open" if is_open else "closed")
|
||||||
|
|
||||||
|
|
||||||
|
# Public API
|
||||||
|
__all__ = ["ContactState", "CAP_VERSION", "DISPLAY_NAME"]
|
||||||
Reference in New Issue
Block a user