seems to work
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -61,3 +61,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# Poetry
|
# Poetry
|
||||||
poetry.lock
|
poetry.lock
|
||||||
|
|
||||||
|
apps/homekit/homekit.state
|
||||||
|
|
||||||
|
|||||||
5
apps/homekit/accessories/__init__.py
Normal file
5
apps/homekit/accessories/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
HomeKit Accessories Package
|
||||||
|
|
||||||
|
This package contains HomeKit accessory implementations for different device types.
|
||||||
|
"""
|
||||||
48
apps/homekit/accessories/contact.py
Normal file
48
apps/homekit/accessories/contact.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
Contact Sensor Accessory Implementation for HomeKit
|
||||||
|
|
||||||
|
Implements contact sensor (window/door sensors):
|
||||||
|
- ContactSensorState (read-only): 0=Detected, 1=Not Detected
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pyhap.accessory import Accessory
|
||||||
|
from pyhap.const import CATEGORY_SENSOR
|
||||||
|
|
||||||
|
|
||||||
|
class ContactAccessory(Accessory):
|
||||||
|
"""Contact sensor for doors and windows."""
|
||||||
|
|
||||||
|
category = CATEGORY_SENSOR
|
||||||
|
|
||||||
|
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize the contact sensor accessory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
driver: HAP driver instance
|
||||||
|
device: Device object from DeviceRegistry
|
||||||
|
api_client: ApiClient for sending commands
|
||||||
|
display_name: Optional display name (defaults to device.friendly_name)
|
||||||
|
"""
|
||||||
|
name = display_name or device.friendly_name or device.name
|
||||||
|
super().__init__(driver, name, *args, **kwargs)
|
||||||
|
self.device = device
|
||||||
|
self.api_client = api_client
|
||||||
|
|
||||||
|
# Add ContactSensor service
|
||||||
|
self.contact_service = self.add_preload_service('ContactSensor')
|
||||||
|
|
||||||
|
# Get ContactSensorState characteristic
|
||||||
|
self.contact_state_char = self.contact_service.get_characteristic('ContactSensorState')
|
||||||
|
|
||||||
|
# Initialize with "not detected" (closed)
|
||||||
|
self.contact_state_char.set_value(1)
|
||||||
|
|
||||||
|
def update_state(self, state_payload):
|
||||||
|
"""Update state from API event."""
|
||||||
|
if "contact" in state_payload:
|
||||||
|
# API sends: "open" or "closed"
|
||||||
|
# HomeKit: 0=Contact Detected (closed), 1=Contact Not Detected (open)
|
||||||
|
is_open = state_payload["contact"] == "open"
|
||||||
|
homekit_state = 1 if is_open else 0
|
||||||
|
self.contact_state_char.set_value(homekit_state)
|
||||||
177
apps/homekit/accessories/light.py
Normal file
177
apps/homekit/accessories/light.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"""
|
||||||
|
Light Accessory Implementations for HomeKit
|
||||||
|
|
||||||
|
Implements different light types:
|
||||||
|
- OnOffLightAccessory: Simple on/off light
|
||||||
|
- DimmableLightAccessory: Light with brightness control
|
||||||
|
- ColorLightAccessory: RGB light with full color control
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pyhap.accessory import Accessory
|
||||||
|
from pyhap.const import CATEGORY_LIGHTBULB
|
||||||
|
|
||||||
|
|
||||||
|
class OnOffLightAccessory(Accessory):
|
||||||
|
"""Simple On/Off Light without dimming or color."""
|
||||||
|
|
||||||
|
category = CATEGORY_LIGHTBULB
|
||||||
|
|
||||||
|
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize the light accessory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
driver: HAP driver instance
|
||||||
|
device: Device object from DeviceRegistry
|
||||||
|
api_client: ApiClient for sending commands
|
||||||
|
display_name: Optional display name (defaults to device.friendly_name)
|
||||||
|
"""
|
||||||
|
name = display_name or device.friendly_name or device.name
|
||||||
|
super().__init__(driver, name, *args, **kwargs)
|
||||||
|
self.device = device
|
||||||
|
self.api_client = api_client
|
||||||
|
|
||||||
|
# Add Lightbulb service with On characteristic
|
||||||
|
self.lightbulb_service = self.add_preload_service('Lightbulb')
|
||||||
|
|
||||||
|
# Get the On characteristic and set callback
|
||||||
|
self.on_char = self.lightbulb_service.get_characteristic('On')
|
||||||
|
self.on_char.setter_callback = self.set_on
|
||||||
|
|
||||||
|
def set_on(self, value):
|
||||||
|
"""Called when HomeKit wants to turn light on/off."""
|
||||||
|
power_state = "on" if value else "off"
|
||||||
|
payload = {
|
||||||
|
"type": "light",
|
||||||
|
"payload": {"power": power_state}
|
||||||
|
}
|
||||||
|
self.api_client.post_device_set(self.device.device_id, payload["type"], payload["payload"])
|
||||||
|
|
||||||
|
def update_state(self, state_payload):
|
||||||
|
"""Update state from API event."""
|
||||||
|
if "power" in state_payload:
|
||||||
|
is_on = state_payload["power"] == "on"
|
||||||
|
self.on_char.set_value(is_on)
|
||||||
|
|
||||||
|
|
||||||
|
class DimmableLightAccessory(OnOffLightAccessory):
|
||||||
|
"""Dimmable Light with brightness control."""
|
||||||
|
|
||||||
|
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
||||||
|
# Don't call super().__init__() yet - we need to set up service first
|
||||||
|
name = display_name or device.friendly_name or device.name
|
||||||
|
Accessory.__init__(self, driver, name, *args, **kwargs)
|
||||||
|
self.device = device
|
||||||
|
self.api_client = api_client
|
||||||
|
self.category = CATEGORY_LIGHTBULB
|
||||||
|
|
||||||
|
# Create Lightbulb service with all characteristics at once
|
||||||
|
from pyhap.loader import Loader
|
||||||
|
loader = Loader()
|
||||||
|
|
||||||
|
# Create the service
|
||||||
|
lightbulb_service = loader.get_service('Lightbulb')
|
||||||
|
|
||||||
|
# Add On characteristic
|
||||||
|
on_char = lightbulb_service.get_characteristic('On')
|
||||||
|
on_char.setter_callback = self.set_on
|
||||||
|
self.on_char = on_char
|
||||||
|
|
||||||
|
# Add Brightness characteristic
|
||||||
|
brightness_char = loader.get_char('Brightness')
|
||||||
|
brightness_char.set_value(0)
|
||||||
|
brightness_char.setter_callback = self.set_brightness
|
||||||
|
lightbulb_service.add_characteristic(brightness_char)
|
||||||
|
self.brightness_char = brightness_char
|
||||||
|
|
||||||
|
# Now add the complete service to the accessory
|
||||||
|
self.add_service(lightbulb_service)
|
||||||
|
self.lightbulb_service = lightbulb_service
|
||||||
|
|
||||||
|
def set_brightness(self, value):
|
||||||
|
"""Called when HomeKit wants to change brightness."""
|
||||||
|
payload = {
|
||||||
|
"type": "light",
|
||||||
|
"payload": {"brightness": int(value)}
|
||||||
|
}
|
||||||
|
self.api_client.post_device_set(self.device.device_id, payload["type"], payload["payload"])
|
||||||
|
|
||||||
|
def update_state(self, state_payload):
|
||||||
|
"""Update state from API event."""
|
||||||
|
super().update_state(state_payload)
|
||||||
|
if "brightness" in state_payload:
|
||||||
|
self.brightness_char.set_value(state_payload["brightness"])
|
||||||
|
|
||||||
|
|
||||||
|
class ColorLightAccessory(DimmableLightAccessory):
|
||||||
|
"""RGB Light with full color control."""
|
||||||
|
|
||||||
|
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
||||||
|
# Don't call super().__init__() - build everything from scratch
|
||||||
|
name = display_name or device.friendly_name or device.name
|
||||||
|
Accessory.__init__(self, driver, name, *args, **kwargs)
|
||||||
|
self.device = device
|
||||||
|
self.api_client = api_client
|
||||||
|
self.category = CATEGORY_LIGHTBULB
|
||||||
|
|
||||||
|
# Create Lightbulb service with all characteristics at once
|
||||||
|
from pyhap.loader import Loader
|
||||||
|
loader = Loader()
|
||||||
|
|
||||||
|
# Create the service
|
||||||
|
lightbulb_service = loader.get_service('Lightbulb')
|
||||||
|
|
||||||
|
# Add On characteristic
|
||||||
|
on_char = lightbulb_service.get_characteristic('On')
|
||||||
|
on_char.setter_callback = self.set_on
|
||||||
|
self.on_char = on_char
|
||||||
|
|
||||||
|
# Add Brightness characteristic
|
||||||
|
brightness_char = loader.get_char('Brightness')
|
||||||
|
brightness_char.set_value(0)
|
||||||
|
brightness_char.setter_callback = self.set_brightness
|
||||||
|
lightbulb_service.add_characteristic(brightness_char)
|
||||||
|
self.brightness_char = brightness_char
|
||||||
|
|
||||||
|
# Add Hue characteristic
|
||||||
|
hue_char = loader.get_char('Hue')
|
||||||
|
hue_char.set_value(0)
|
||||||
|
hue_char.setter_callback = self.set_hue
|
||||||
|
lightbulb_service.add_characteristic(hue_char)
|
||||||
|
self.hue_char = hue_char
|
||||||
|
|
||||||
|
# Add Saturation characteristic
|
||||||
|
saturation_char = loader.get_char('Saturation')
|
||||||
|
saturation_char.set_value(0)
|
||||||
|
saturation_char.setter_callback = self.set_saturation
|
||||||
|
lightbulb_service.add_characteristic(saturation_char)
|
||||||
|
self.saturation_char = saturation_char
|
||||||
|
|
||||||
|
# Now add the complete service to the accessory
|
||||||
|
self.add_service(lightbulb_service)
|
||||||
|
self.lightbulb_service = lightbulb_service
|
||||||
|
|
||||||
|
def set_hue(self, value):
|
||||||
|
"""Called when HomeKit wants to change hue."""
|
||||||
|
payload = {
|
||||||
|
"type": "light",
|
||||||
|
"payload": {"hue": int(value)}
|
||||||
|
}
|
||||||
|
self.api_client.post_device_set(self.device.device_id, payload["type"], payload["payload"])
|
||||||
|
|
||||||
|
def set_saturation(self, value):
|
||||||
|
"""Called when HomeKit wants to change saturation."""
|
||||||
|
payload = {
|
||||||
|
"type": "light",
|
||||||
|
"payload": {"sat": int(value)}
|
||||||
|
}
|
||||||
|
self.api_client.post_device_set(self.device.device_id, payload["type"], payload["payload"])
|
||||||
|
|
||||||
|
def update_state(self, state_payload):
|
||||||
|
"""Update state from API event."""
|
||||||
|
super().update_state(state_payload)
|
||||||
|
if "hue" in state_payload:
|
||||||
|
self.hue_char.set_value(state_payload["hue"])
|
||||||
|
if "sat" in state_payload:
|
||||||
|
self.saturation_char.set_value(state_payload["sat"])
|
||||||
|
|
||||||
57
apps/homekit/accessories/outlet.py
Normal file
57
apps/homekit/accessories/outlet.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
Outlet/Relay Accessory Implementation for HomeKit
|
||||||
|
|
||||||
|
Implements simple relay/outlet (on/off switch):
|
||||||
|
- On (read/write)
|
||||||
|
- OutletInUse (always true)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pyhap.accessory import Accessory
|
||||||
|
from pyhap.const import CATEGORY_OUTLET
|
||||||
|
|
||||||
|
|
||||||
|
class OutletAccessory(Accessory):
|
||||||
|
"""Relay/Outlet for simple on/off control."""
|
||||||
|
|
||||||
|
category = CATEGORY_OUTLET
|
||||||
|
|
||||||
|
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize the outlet accessory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
driver: HAP driver instance
|
||||||
|
device: Device object from DeviceRegistry
|
||||||
|
api_client: ApiClient for sending commands
|
||||||
|
display_name: Optional display name (defaults to device.friendly_name)
|
||||||
|
"""
|
||||||
|
name = display_name or device.friendly_name or device.name
|
||||||
|
super().__init__(driver, name, *args, **kwargs)
|
||||||
|
self.device = device
|
||||||
|
self.api_client = api_client
|
||||||
|
|
||||||
|
# Add Outlet service
|
||||||
|
self.outlet_service = self.add_preload_service('Outlet')
|
||||||
|
|
||||||
|
# Get On characteristic and set callback
|
||||||
|
self.on_char = self.outlet_service.get_characteristic('On')
|
||||||
|
self.on_char.setter_callback = self.set_on
|
||||||
|
|
||||||
|
# OutletInUse is always true (relay is always functional)
|
||||||
|
self.in_use_char = self.outlet_service.get_characteristic('OutletInUse')
|
||||||
|
self.in_use_char.set_value(True)
|
||||||
|
|
||||||
|
def set_on(self, value):
|
||||||
|
"""Called when HomeKit wants to turn outlet on/off."""
|
||||||
|
power_state = "on" if value else "off"
|
||||||
|
payload = {
|
||||||
|
"type": "relay",
|
||||||
|
"payload": {"power": power_state}
|
||||||
|
}
|
||||||
|
self.api_client.post_device_set(self.device.device_id, payload["type"], payload["payload"])
|
||||||
|
|
||||||
|
def update_state(self, state_payload):
|
||||||
|
"""Update state from API event."""
|
||||||
|
if "power" in state_payload:
|
||||||
|
is_on = state_payload["power"] == "on"
|
||||||
|
self.on_char.set_value(is_on)
|
||||||
46
apps/homekit/accessories/sensor.py
Normal file
46
apps/homekit/accessories/sensor.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Temperature & Humidity Sensor Accessory Implementation for HomeKit
|
||||||
|
|
||||||
|
Implements combined temperature and humidity sensor:
|
||||||
|
- CurrentTemperature (read-only)
|
||||||
|
- CurrentRelativeHumidity (read-only)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pyhap.accessory import Accessory
|
||||||
|
from pyhap.const import CATEGORY_SENSOR
|
||||||
|
|
||||||
|
|
||||||
|
class TempHumidityAccessory(Accessory):
|
||||||
|
"""Combined temperature and humidity sensor."""
|
||||||
|
|
||||||
|
category = CATEGORY_SENSOR
|
||||||
|
|
||||||
|
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize the temp/humidity sensor accessory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
driver: HAP driver instance
|
||||||
|
device: Device object from DeviceRegistry
|
||||||
|
api_client: ApiClient for sending commands
|
||||||
|
display_name: Optional display name (defaults to device.friendly_name)
|
||||||
|
"""
|
||||||
|
name = display_name or device.friendly_name or device.name
|
||||||
|
super().__init__(driver, name, *args, **kwargs)
|
||||||
|
self.device = device
|
||||||
|
self.api_client = api_client
|
||||||
|
|
||||||
|
# Add TemperatureSensor service
|
||||||
|
self.temp_service = self.add_preload_service('TemperatureSensor')
|
||||||
|
self.current_temp_char = self.temp_service.get_characteristic('CurrentTemperature')
|
||||||
|
|
||||||
|
# Add HumiditySensor service
|
||||||
|
self.humidity_service = self.add_preload_service('HumiditySensor')
|
||||||
|
self.current_humidity_char = self.humidity_service.get_characteristic('CurrentRelativeHumidity')
|
||||||
|
|
||||||
|
def update_state(self, state_payload):
|
||||||
|
"""Update state from API event."""
|
||||||
|
if "temperature" in state_payload:
|
||||||
|
self.current_temp_char.set_value(float(state_payload["temperature"]))
|
||||||
|
if "humidity" in state_payload:
|
||||||
|
self.current_humidity_char.set_value(float(state_payload["humidity"]))
|
||||||
72
apps/homekit/accessories/thermostat.py
Normal file
72
apps/homekit/accessories/thermostat.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
Thermostat Accessory Implementation for HomeKit
|
||||||
|
|
||||||
|
Implements thermostat control according to homekit_mapping.md:
|
||||||
|
- CurrentTemperature (read-only)
|
||||||
|
- TargetTemperature (read/write)
|
||||||
|
- CurrentHeatingCoolingState (fixed: 1 = heating)
|
||||||
|
- TargetHeatingCoolingState (fixed: 3 = auto)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pyhap.accessory import Accessory
|
||||||
|
from pyhap.const import CATEGORY_THERMOSTAT
|
||||||
|
|
||||||
|
|
||||||
|
class ThermostatAccessory(Accessory):
|
||||||
|
"""Thermostat with temperature control."""
|
||||||
|
|
||||||
|
category = CATEGORY_THERMOSTAT
|
||||||
|
|
||||||
|
def __init__(self, driver, device, api_client, display_name=None, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize the thermostat accessory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
driver: HAP driver instance
|
||||||
|
device: Device object from DeviceRegistry
|
||||||
|
api_client: ApiClient for sending commands
|
||||||
|
display_name: Optional display name (defaults to device.friendly_name)
|
||||||
|
"""
|
||||||
|
name = display_name or device.friendly_name or device.name
|
||||||
|
super().__init__(driver, name, *args, **kwargs)
|
||||||
|
self.device = device
|
||||||
|
self.api_client = api_client
|
||||||
|
|
||||||
|
# Add Thermostat service
|
||||||
|
self.thermostat_service = self.add_preload_service('Thermostat')
|
||||||
|
|
||||||
|
# Get characteristics
|
||||||
|
self.current_temp_char = self.thermostat_service.get_characteristic('CurrentTemperature')
|
||||||
|
self.target_temp_char = self.thermostat_service.get_characteristic('TargetTemperature')
|
||||||
|
self.current_heating_cooling_char = self.thermostat_service.get_characteristic('CurrentHeatingCoolingState')
|
||||||
|
self.target_heating_cooling_char = self.thermostat_service.get_characteristic('TargetHeatingCoolingState')
|
||||||
|
|
||||||
|
# Set callback for target temperature
|
||||||
|
self.target_temp_char.setter_callback = self.set_target_temperature
|
||||||
|
|
||||||
|
# Set fixed heating/cooling states (mode is always "auto")
|
||||||
|
# CurrentHeatingCoolingState: 0=Off, 1=Heat, 2=Cool
|
||||||
|
self.current_heating_cooling_char.set_value(1) # Always heating
|
||||||
|
|
||||||
|
# TargetHeatingCoolingState: 0=Off, 1=Heat, 2=Cool, 3=Auto
|
||||||
|
self.target_heating_cooling_char.set_value(3) # Always auto
|
||||||
|
|
||||||
|
# Set temperature range (5-30°C as per UI)
|
||||||
|
self.target_temp_char.properties['minValue'] = 5
|
||||||
|
self.target_temp_char.properties['maxValue'] = 30
|
||||||
|
self.target_temp_char.properties['minStep'] = 0.5
|
||||||
|
|
||||||
|
def set_target_temperature(self, value):
|
||||||
|
"""Called when HomeKit wants to change target temperature."""
|
||||||
|
payload = {
|
||||||
|
"type": "thermostat",
|
||||||
|
"payload": {"target": float(value)}
|
||||||
|
}
|
||||||
|
self.api_client.post_device_set(self.device.device_id, payload["type"], payload["payload"])
|
||||||
|
|
||||||
|
def update_state(self, state_payload):
|
||||||
|
"""Update state from API event."""
|
||||||
|
if "current" in state_payload:
|
||||||
|
self.current_temp_char.set_value(float(state_payload["current"]))
|
||||||
|
if "target" in state_payload:
|
||||||
|
self.target_temp_char.set_value(float(state_payload["target"]))
|
||||||
161
apps/homekit/api_client.py
Normal file
161
apps/homekit/api_client.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
API Client for HomeKit Bridge
|
||||||
|
|
||||||
|
Handles all HTTP communication with the REST API backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict, List, Iterator, Optional
|
||||||
|
import httpx
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiClient:
|
||||||
|
"""HTTP client for communicating with the home automation API."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, token: Optional[str] = None, timeout: int = 5):
|
||||||
|
"""
|
||||||
|
Initialize API client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Base URL of the API (e.g., "http://192.168.1.100:8001")
|
||||||
|
token: Optional API token for authentication
|
||||||
|
timeout: Request timeout in seconds
|
||||||
|
"""
|
||||||
|
self.base_url = base_url.rstrip('/')
|
||||||
|
self.timeout = timeout
|
||||||
|
self.headers = {}
|
||||||
|
|
||||||
|
if token:
|
||||||
|
self.headers['Authorization'] = f'Bearer {token}'
|
||||||
|
|
||||||
|
def get_devices(self) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Get list of all devices.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of device dictionaries
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = httpx.get(
|
||||||
|
f'{self.base_url}/devices',
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get devices: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_layout(self) -> Dict:
|
||||||
|
"""
|
||||||
|
Get layout information (rooms and device assignments).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Layout dictionary with room structure
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = httpx.get(
|
||||||
|
f'{self.base_url}/layout',
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get layout: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_device_state(self, device_id: str) -> Dict:
|
||||||
|
"""
|
||||||
|
Get current state of a specific device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: Device identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Device state dictionary
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = httpx.get(
|
||||||
|
f'{self.base_url}/devices/{device_id}/state',
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get state for {device_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def post_device_set(self, device_id: str, device_type: str, payload: Dict) -> None:
|
||||||
|
"""
|
||||||
|
Send command to a device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: Device identifier
|
||||||
|
device_type: Device type (e.g., "light", "thermostat")
|
||||||
|
payload: Command payload (e.g., {"power": "on", "brightness": 75})
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = {
|
||||||
|
"type": device_type,
|
||||||
|
"payload": payload
|
||||||
|
}
|
||||||
|
response = httpx.post(
|
||||||
|
f'{self.base_url}/devices/{device_id}/set',
|
||||||
|
headers=self.headers,
|
||||||
|
json=data,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logger.debug(f"Set {device_id}: {payload}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to set {device_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def stream_realtime(self, reconnect_delay: int = 5) -> Iterator[Dict]:
|
||||||
|
"""
|
||||||
|
Stream real-time events from the API using Server-Sent Events (SSE).
|
||||||
|
|
||||||
|
Automatically reconnects on connection loss.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reconnect_delay: Seconds to wait before reconnecting
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Event dictionaries: {"type": "state", "device_id": "...", "payload": {...}, "ts": ...}
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
logger.info("Connecting to realtime event stream...")
|
||||||
|
with httpx.stream(
|
||||||
|
'GET',
|
||||||
|
f'{self.base_url}/realtime',
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=None # No timeout for streaming
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
logger.info("Connected to realtime event stream")
|
||||||
|
|
||||||
|
for line in response.iter_lines():
|
||||||
|
if line.startswith('data: '):
|
||||||
|
data_str = line[6:] # Remove 'data: ' prefix
|
||||||
|
try:
|
||||||
|
event = json.loads(data_str)
|
||||||
|
yield event
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"Failed to parse SSE event: {e}")
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.error(f"Realtime stream error: {e}")
|
||||||
|
logger.info(f"Reconnecting in {reconnect_delay} seconds...")
|
||||||
|
time.sleep(reconnect_delay)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in realtime stream: {e}")
|
||||||
|
logger.info(f"Reconnecting in {reconnect_delay} seconds...")
|
||||||
|
time.sleep(reconnect_delay)
|
||||||
138
apps/homekit/device_registry.py
Normal file
138
apps/homekit/device_registry.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""
|
||||||
|
Device Registry for HomeKit Bridge
|
||||||
|
|
||||||
|
Loads devices from API and joins with layout information.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Device:
|
||||||
|
"""Represents a device with combined info from /devices and /layout."""
|
||||||
|
|
||||||
|
device_id: str
|
||||||
|
type: str # "light", "thermostat", "relay", "contact", "temp_humidity", "cover"
|
||||||
|
name: str # Short name from /devices
|
||||||
|
friendly_name: str # Display title from /layout (fallback to name)
|
||||||
|
room: Optional[str] # Room name from layout
|
||||||
|
features: Dict[str, bool] # Feature flags (e.g., {"power": true, "brightness": true})
|
||||||
|
read_only: bool # True for sensors that don't accept commands
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceRegistry:
|
||||||
|
"""Registry of all devices loaded from the API."""
|
||||||
|
|
||||||
|
def __init__(self, devices: List[Device]):
|
||||||
|
"""
|
||||||
|
Initialize registry with devices.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
devices: List of Device objects
|
||||||
|
"""
|
||||||
|
self._devices = devices
|
||||||
|
self._by_id = {d.device_id: d for d in devices}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_from_api(cls, api_client) -> 'DeviceRegistry':
|
||||||
|
"""
|
||||||
|
Load devices from API and join with layout information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_client: ApiClient instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DeviceRegistry with all devices
|
||||||
|
"""
|
||||||
|
# Get devices and layout
|
||||||
|
devices_data = api_client.get_devices()
|
||||||
|
layout_data = api_client.get_layout()
|
||||||
|
|
||||||
|
# Build lookup: device_id -> (room_name, title)
|
||||||
|
layout_map = {}
|
||||||
|
if isinstance(layout_data, dict) and 'rooms' in layout_data:
|
||||||
|
rooms_list = layout_data['rooms']
|
||||||
|
if isinstance(rooms_list, list):
|
||||||
|
for room in rooms_list:
|
||||||
|
if isinstance(room, dict):
|
||||||
|
room_name = room.get('name', 'Unknown')
|
||||||
|
devices_in_room = room.get('devices', [])
|
||||||
|
for device_info in devices_in_room:
|
||||||
|
if isinstance(device_info, dict):
|
||||||
|
device_id = device_info.get('device_id')
|
||||||
|
title = device_info.get('title', '')
|
||||||
|
if device_id:
|
||||||
|
layout_map[device_id] = (room_name, title)
|
||||||
|
|
||||||
|
# Create Device objects
|
||||||
|
devices = []
|
||||||
|
for dev_data in devices_data:
|
||||||
|
device_id = dev_data.get('device_id')
|
||||||
|
if not device_id:
|
||||||
|
logger.warning(f"Device without device_id: {dev_data}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get layout info
|
||||||
|
room_name, title = layout_map.get(device_id, (None, ''))
|
||||||
|
|
||||||
|
# Determine if read-only (sensors don't accept set commands)
|
||||||
|
device_type = dev_data.get('type', '')
|
||||||
|
read_only = device_type in ['contact', 'temp_humidity', 'motion', 'smoke']
|
||||||
|
|
||||||
|
device = Device(
|
||||||
|
device_id=device_id,
|
||||||
|
type=device_type,
|
||||||
|
name=dev_data.get('name', device_id),
|
||||||
|
friendly_name=title or dev_data.get('name', device_id),
|
||||||
|
room=room_name,
|
||||||
|
features=dev_data.get('features', {}),
|
||||||
|
read_only=read_only
|
||||||
|
)
|
||||||
|
devices.append(device)
|
||||||
|
|
||||||
|
logger.info(f"Loaded {len(devices)} devices from API")
|
||||||
|
return cls(devices)
|
||||||
|
|
||||||
|
def get_all(self) -> List[Device]:
|
||||||
|
"""Get all devices."""
|
||||||
|
return self._devices.copy()
|
||||||
|
|
||||||
|
def get_by_id(self, device_id: str) -> Optional[Device]:
|
||||||
|
"""
|
||||||
|
Get device by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: Device identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Device or None if not found
|
||||||
|
"""
|
||||||
|
return self._by_id.get(device_id)
|
||||||
|
|
||||||
|
def get_by_type(self, device_type: str) -> List[Device]:
|
||||||
|
"""
|
||||||
|
Get all devices of a specific type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_type: Device type (e.g., "light", "thermostat")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching devices
|
||||||
|
"""
|
||||||
|
return [d for d in self._devices if d.type == device_type]
|
||||||
|
|
||||||
|
def get_by_room(self, room: str) -> List[Device]:
|
||||||
|
"""
|
||||||
|
Get all devices in a specific room.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room: Room name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of devices in the room
|
||||||
|
"""
|
||||||
|
return [d for d in self._devices if d.room == room]
|
||||||
@@ -1,8 +1,34 @@
|
|||||||
|
"""
|
||||||
|
HomeKit Bridge Main Module
|
||||||
|
|
||||||
|
Implementiert eine HAP-Python Bridge, die Geräte über die REST-API lädt
|
||||||
|
und über HomeKit verfügbar macht.
|
||||||
|
|
||||||
|
Für detaillierte Implementierungsanweisungen, Tests und Deployment-Informationen
|
||||||
|
siehe README.md in diesem Verzeichnis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pyhap.accessory_driver import AccessoryDriver
|
||||||
|
from pyhap.accessory import Bridge
|
||||||
|
|
||||||
from .accessories.light import (
|
from .accessories.light import (
|
||||||
OnOffLightAccessory,
|
OnOffLightAccessory,
|
||||||
DimmableLightAccessory,
|
DimmableLightAccessory,
|
||||||
ColorLightAccessory,
|
ColorLightAccessory,
|
||||||
)
|
)
|
||||||
|
from .accessories.thermostat import ThermostatAccessory
|
||||||
|
from .accessories.contact import ContactAccessory
|
||||||
|
from .accessories.sensor import TempHumidityAccessory
|
||||||
|
from .accessories.outlet import OutletAccessory
|
||||||
|
from .api_client import ApiClient
|
||||||
|
from .device_registry import DeviceRegistry
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -45,9 +71,14 @@ 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)
|
||||||
|
if device.room:
|
||||||
|
# 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})")
|
logger.info(f"Added accessory: {device.friendly_name} ({device.type}) in room: {device.room or 'Unknown'}")
|
||||||
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:
|
||||||
@@ -60,6 +91,22 @@ def build_bridge(driver: AccessoryDriver, api_client: ApiClient) -> Bridge:
|
|||||||
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.
|
||||||
@@ -68,36 +115,38 @@ 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)
|
return ColorLightAccessory(driver, device, api_client, display_name=display_name)
|
||||||
elif features.get("brightness"):
|
elif features.get("brightness"):
|
||||||
return DimmableLightAccessory(driver, device, api_client)
|
return DimmableLightAccessory(driver, device, api_client, display_name=display_name)
|
||||||
else:
|
else:
|
||||||
return OnOffLightAccessory(driver, device, api_client)
|
return OnOffLightAccessory(driver, device, api_client, display_name=display_name)
|
||||||
|
|
||||||
# Thermostat
|
# Thermostat
|
||||||
elif device_type == "thermostat":
|
elif device_type == "thermostat":
|
||||||
return ThermostatAccessory(driver, device, api_client)
|
return ThermostatAccessory(driver, device, api_client, display_name=display_name)
|
||||||
|
|
||||||
# Contact sensor
|
# Contact sensor
|
||||||
elif device_type == "contact":
|
elif device_type == "contact":
|
||||||
return ContactAccessory(driver, device, api_client)
|
return ContactAccessory(driver, device, api_client, display_name=display_name)
|
||||||
|
|
||||||
# Temperature/Humidity sensor
|
# Temperature/Humidity sensor
|
||||||
elif device_type == "temp_humidity":
|
elif device_type == "temp_humidity_sensor":
|
||||||
return TempHumidityAccessory(driver, device, api_client)
|
return TempHumidityAccessory(driver, device, api_client, display_name=display_name)
|
||||||
|
|
||||||
# Outlet/Switch
|
# Relay/Outlet
|
||||||
elif device_type == "outlet":
|
elif device_type == "relay":
|
||||||
return OutletAccessory(driver, device, api_client)
|
return OutletAccessory(driver, device, api_client, display_name=display_name)
|
||||||
|
|
||||||
# Cover/Blinds (optional)
|
# Cover/Blinds (optional)
|
||||||
elif device_type == "cover":
|
elif device_type == "cover":
|
||||||
# TODO: Implement CoverAccessory based on homekit_mapping.md
|
# TODO: Implement CoverAccessory based on homekit_mapping.md
|
||||||
return CoverAccessory(driver, device, api_client)
|
logger.warning(f"Cover accessory not yet implemented for {device.name}")
|
||||||
|
return None
|
||||||
|
|
||||||
# TODO: Add more device types as needed (lock, motion, etc.)
|
# TODO: Add more device types as needed (lock, motion, etc.)
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,17 @@
|
|||||||
|
|
||||||
set -e # Exit on error
|
set -e # Exit on error
|
||||||
|
|
||||||
# Change to script directory
|
# Determine script directory (apps/homekit)
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
|
# Navigate to workspace root (two levels up from apps/homekit)
|
||||||
|
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
cd "$WORKSPACE_ROOT"
|
||||||
|
|
||||||
echo "🏠 HomeKit Bridge Startup"
|
echo "🏠 HomeKit Bridge Startup"
|
||||||
echo "========================="
|
echo "========================="
|
||||||
|
echo " Working dir: $WORKSPACE_ROOT"
|
||||||
|
echo ""
|
||||||
|
|
||||||
# Virtual environment path
|
# Virtual environment path
|
||||||
VENV_DIR="$SCRIPT_DIR/venv"
|
VENV_DIR="$SCRIPT_DIR/venv"
|
||||||
@@ -18,7 +23,19 @@ VENV_DIR="$SCRIPT_DIR/venv"
|
|||||||
# Check if virtual environment exists
|
# Check if virtual environment exists
|
||||||
if [ ! -d "$VENV_DIR" ]; then
|
if [ ! -d "$VENV_DIR" ]; then
|
||||||
echo "📦 Virtual environment not found. Creating..."
|
echo "📦 Virtual environment not found. Creating..."
|
||||||
python3 -m venv "$VENV_DIR"
|
# Try to use Python 3.12 or 3.13 (3.14 has compatibility issues with HAP-Python)
|
||||||
|
if command -v python3.13 &> /dev/null; then
|
||||||
|
PYTHON_CMD=python3.13
|
||||||
|
elif command -v python3.12 &> /dev/null; then
|
||||||
|
PYTHON_CMD=python3.12
|
||||||
|
elif command -v python3.11 &> /dev/null; then
|
||||||
|
PYTHON_CMD=python3.11
|
||||||
|
else
|
||||||
|
PYTHON_CMD=python3
|
||||||
|
echo "⚠️ Warning: Using default python3. HAP-Python may not work with Python 3.14+"
|
||||||
|
fi
|
||||||
|
echo " Using: $PYTHON_CMD"
|
||||||
|
$PYTHON_CMD -m venv "$VENV_DIR"
|
||||||
echo "✅ Virtual environment created at $VENV_DIR"
|
echo "✅ Virtual environment created at $VENV_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -29,7 +46,7 @@ source "$VENV_DIR/bin/activate"
|
|||||||
# Install/update dependencies
|
# Install/update dependencies
|
||||||
echo "📥 Installing dependencies from requirements.txt..."
|
echo "📥 Installing dependencies from requirements.txt..."
|
||||||
pip install --upgrade pip -q
|
pip install --upgrade pip -q
|
||||||
pip install -r requirements.txt -q
|
pip install -r "$SCRIPT_DIR/requirements.txt" -q
|
||||||
echo "✅ Dependencies installed"
|
echo "✅ Dependencies installed"
|
||||||
|
|
||||||
# Set environment variables (with defaults)
|
# Set environment variables (with defaults)
|
||||||
@@ -55,5 +72,5 @@ echo "🚀 Starting HomeKit Bridge..."
|
|||||||
echo " (Press Ctrl+C to stop)"
|
echo " (Press Ctrl+C to stop)"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Run the bridge using Python module syntax
|
# Run the bridge from workspace root with correct module path
|
||||||
python -m apps.homekit.main
|
python -m apps.homekit.main
|
||||||
|
|||||||
Reference in New Issue
Block a user