Compare commits

...

23 commits

Author SHA1 Message Date
7d67b6c91b Introduced concept of pollables 2024-11-16 05:06:14 +01:00
b3106e8bae Continued implementing hue bridge and first entities 2024-11-09 04:22:22 +01:00
Max
b9d8c5312c Remove unused imports 2024-11-08 11:52:16 +01:00
4848c06448 Todos updated 2024-10-13 17:44:11 +02:00
866b717b27 Bett example moved to different file 2024-10-07 12:11:08 +02:00
69465c8edf Removed and restructured old unused snippets 2024-08-07 03:30:20 +02:00
1a3bcb88f0 Restructured bridges 2024-08-07 03:26:47 +02:00
Max
2b17367313 Finished matrix clock client 2024-08-06 20:36:14 +02:00
0330e49ec0 Working on matrix clock 2024-08-06 19:13:23 +02:00
3709dc10bd Documenting some architecture 2024-08-05 01:51:27 +02:00
0749f72c60 Implementing more bridges 2024-07-31 04:13:55 +02:00
c1a5b61e30 Removed background jobs from bridges 2024-07-31 04:13:35 +02:00
91552bafb6 Added documentation 2024-07-22 18:13:33 +02:00
ff7b23b716 Implemented fritz box bridge 2024-07-17 21:02:05 +02:00
fd414c2cd1 Added documentation 2024-07-17 21:01:54 +02:00
c2aa2f5adc Implemented Z2M bridge 2024-07-16 20:15:04 +02:00
f2c8d6885d Getting started with bridges 2024-07-09 03:25:14 +02:00
a1f992856b First full version of config 2024-07-08 23:34:40 +02:00
Max
72e680a515 Removed device object from database for simplification 2024-07-05 23:57:19 +02:00
Max
836e286178 MQTT client test app 2024-07-05 20:15:42 +02:00
56bae56bdf Some config inspiration 2024-06-23 04:07:50 +02:00
1b49d4e023 Added basic database models 2024-06-19 02:38:17 +02:00
Max
7bc79139f0 Starting in db storage 2024-06-12 01:21:29 +02:00
47 changed files with 783 additions and 306 deletions

View file

@ -8,7 +8,51 @@ Should be a very simple **server** implementation of what is required in Max's s
## ToDo
- Energy-saving/Off mode (Only one light slighty on to deal with the state) (How should power plugs be handled?)
- Daylight Adjustment (E.g. No ceiling lights during daytime)
- Daylight Adjustment
- No ceiling lights during daytime
- Color Temperature match to outside
- Save scene when turning off, to reapply same scene when turning on
- Detect fast flickering of light state, indicating an issue, and disable the system for a few minutes
- Only close blinds completely if window is closed
- Door-bell implemented with people counter and clock as indicator
- Wardrobe light based on contact sensor
- Reminder to close window after lüften for a while/to open with bad CO2 values
- Implement climate reminders on clock
- Reminder/Notifications on clock in general
- Bettwaage
- Trigger sleep mode
- Log weight
- Log calmness of sleep
## Architecture
Example Hue Light:
```
[HueLight]
- implementes [LightEntity]
- uses [HueBridge]
```
Example with ZigBee2Mqtt Temperature and Matrix Clock Temperature:
```
[Z2mClimate]
- implements [ClimateEntity]
- uses [Z2mBridge]
[MatrixClock]
- implements [ClimateEntity]
- uses no bridge, works on a per-device connection
```
### Device types
G: Get
S: Set
- Hue [GS]
- Brightness [GS]
- Saturation [GS]
- Temperature [G]
- Humidity [G]
- Message [S]
- Contact [G]

File diff suppressed because one or more lines are too long

View file

@ -1,19 +1,30 @@
features:
hue:
hue-bridge:
ip: 192.168.178.23
matrixclock:
clock:
ip: 192.168.178.23
database: mash_database.sqlite
bridges:
- &hue id: hue
type: hue
ip: 192.168.178.23
- &z2m id: z2m
type: zigbee2mqtt
ip: 192.168.178.115
port: 1883
topic: zigbee2mqtt
- &fritz id: fritz
type: fritzbox
ip: 192.168.178.1
people:
max:
name: Max
devices:
- id: 5e79788b-85dc-47f3-8aa5-e6a3d81a0bff
name: max-iphone-12-mini
type: smartphone
mac: B2:06:77:EE:A9:0F
home:
latitude: 52.51860
longitude: 13.37565
beds:
- id: max-bed
name: Bettwaage
room: *max
rooms:
- id : &hw hallway
@ -23,14 +34,97 @@ home:
- to: *bath
- to: *kit
- to: *living
- id : &balcony balcony
outside: True
name: Balkon
- id : &bath bath
name: Badezimmer
- id : &kit kitchen
name: Küche
- id : &living living
name: Wohnzimmer
doors:
- to: *max
- to: *balcony
devices:
- id: 786eba96-1acf-4e59-a7d3-4faf13fca196
name: living-bat-signal
connection:
bridge: *hue
light_id: 2
- id : &max max
name: Max' Zimmer
devices:
- id: 2d57d563-8c47-4a0a-bfc0-0ecfdb5e39e1
name: max-desk-ambient
connection:
bridge: *hue
light_id: 1
- id: 2380e6ee-edec-4a15-b6a9-a5e9f5e67150
name: max-desk-light
connection:
bridge: *hue
light_id: 3
- id: cdf719f6-45f7-4d9c-8789-5841a9264348
name: max-window-light
connection:
bridge: *hue
light_id: 4
- id: 6125b4d8-7bc4-4802-bd7b-2b4880ff0525
name: max-bed-ambient
connection:
bridge: *hue
light_id: 5
- id: 2e954398-60b1-459a-8398-fd61ebae54af
name: max-wardrobe-light
connection:
bridge: *hue
light_id: 6
- id: 786eba96-1acf-4e59-a7d3-4faf13fca196
name: max-bedside-light
connection:
bridge: *hue
light_id: 7
- id: 37105247-4e3b-499a-9e9c-2ac930171d41
name: max-matrix-clock
connection:
type: matrixclock
ip: 192.168.178.84
port: 8000
- id: 6254093e-5a6b-4181-9c6d-41bd8b4f1a56
name: max-bed-scale
connection:
type: bedscale
ip: 192.168.178.110
port: 80
frequency: 1
- id: 8ab7765e-4ba7-4466-bf7b-8e1011e56933
name: max-window-contact
connection:
bridge: *z2m
ieee_address: 0x00124b002fa0b731
- id: e3fe5988-6455-402e-8aab-07f0e777b931
name: max-window-rollershade
connection:
bridge: *z2m
ieee_address: 0x54ef441000b5b6c4
- id: 85b098de-692d-4961-b8f0-39314071f2db
name: balcony-climate
connection:
bridge: *z2m
ieee_address: 0x3410f4fffefac498
- id: 300c4933-3071-4414-96f0-9b06243fb7cb
name: max-wardrobe-door
connection:
bridge: *z2m
ieee_address: 0x00124b002931b904

View file

@ -5,12 +5,15 @@ phue
fritzconnection
# API
requests
fastapi
uvicorn[standard]
# Clients
# Bridges
requests
paho-mqtt
# Config file
pyyaml
# Database
peewee

View file

@ -1,58 +0,0 @@
import asyncio
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi import APIRouter
import os
import csv
from .handlers.bett import file_path, local_history, log_bed_weights
router = APIRouter()
asyncio.create_task(log_bed_weights())
@router.get("/file", tags=["file"])
async def get_file():
with open(file_path, "r", encoding="UTF-8") as fp:
return HTMLResponse("\n".join(fp.readlines()))
@router.get("/history")
async def get_history(count: int = None) -> list[dict]:
points = []
with open(file_path, "r", encoding="UTF-8") as fp:
reader = csv.DictReader(fp, delimiter=";")
for row in reader:
if not row:
continue
points.append(
{
"timestamp": row["timestamp"],
"total": float(row["total"]),
"tl": float(row["tl"]),
"tr": float(row["tr"]),
"bl": float(row["bl"]),
"br": float(row["br"]),
}
)
if count:
return points[-count]
else:
return points
@router.get("/latest")
async def get_latest():
if len(local_history) == 0:
return HTMLResponse(status_code=200, content="No data given yet")
return JSONResponse(local_history[-1])
@router.delete("/delete", tags=["file"])
async def delete_file():
os.remove(file_path)
return "Deleted file"

View file

@ -1,78 +0,0 @@
import asyncio
import logging
from fritzconnection import FritzConnection
from datetime import datetime
from ..hue import hue
refresh_every_seconds: int = 60 # Every x seconds devices are polled again
trigger_away_after_seconds: int = (
3 * 60
) # After all away-devices are gone for x seconds
away_triggered = False
away_devices = ["B2:06:77:EE:A9:0F"] # Max' iPhone
macaddresses_to_track = ["B2:06:77:EE:A9:0F"] # Max' iPhone
fritz_api = FritzConnection(address="192.168.178.1")
# Referenced documentation: https://avm.de/fileadmin/user_upload/Global/Service/Schnittstellen/hostsSCPD.pdf
devices_last_online = {}
def get_all_devices() -> list:
numberOfDevices = fritz_api.call_action("Hosts", "GetHostNumberOfEntries")[
"NewHostNumberOfEntries"
]
devices = []
for i in range(numberOfDevices):
devices.append(
fritz_api.call_action("Hosts", "GetGenericHostEntry", NewIndex=i)
)
return devices
def get_specific_device(mac_address: str) -> dict:
return fritz_api.call_action(
"Hosts", "GetSpecificHostEntry", NewMACAddress=mac_address
)
def check_for_change():
# Check if devices are away for away-mode
all_away = True
for device in away_devices:
last_online = devices_last_online[device]
if (datetime.now() - last_online).total_seconds() < trigger_away_after_seconds:
all_away = False
break
# Execute away mode
global away_triggered
if all_away:
if not away_triggered:
away_triggered = True
hue.in_room_deactivate_lights("Max Zimmer")
else:
away_triggered = False
async def track_network_devices():
global devices_last_online
# Initial values to avoid None
for macaddress in macaddresses_to_track:
devices_last_online[macaddress] = datetime(1970, 1, 1, 0, 0, 0)
while True:
try:
for macaddress in macaddresses_to_track:
is_online = get_specific_device(macaddress)["NewActive"]
if is_online:
devices_last_online[macaddress] = datetime.now()
check_for_change()
except Exception as ex:
logging.exception(ex)
finally:
await asyncio.sleep(refresh_every_seconds)

View file

@ -1,86 +0,0 @@
from time import sleep
from phue import Bridge
from pathlib import Path
class HueAdapter:
"""Handler for Hue API calls."""
registered_ips_file = "hue_bridge_registered.txt"
def __init__(self, bridge_ip: str):
"""Initialize the HueHandler."""
self.bridge = None
self.connect(bridge_ip)
def connect(self, bridge_ip: str):
if bridge_ip in self.get_registered_ips():
self.bridge = Bridge(bridge_ip)
self.bridge.connect()
return
# Connect loop
while True:
try:
self.bridge = Bridge(bridge_ip)
self.bridge.connect()
break
except Exception as e:
print(f"Failed to connect to bridge: {bridge_ip}")
print(e)
print("Trying again in 5 seconds..")
sleep(5)
self.register_bridge(bridge_ip)
def get_registered_ips(self) -> list:
"""Get a list of registered bridge IPs."""
if not Path(HueAdapter.registered_ips_file).is_file():
return []
with open(HueAdapter.registered_ips_file, "r") as f:
return [ad.strip() for ad in f.readlines()]
def register_bridge(self, bridge_ip: str):
"""Register a bridge IP."""
with open(HueAdapter.registered_ips_file, "a") as f:
f.write(bridge_ip + "\n")
def list_scenes(self) -> dict:
return self.bridge.get_scene()
def get_scene_by_name(self, name):
for key, scene in self.list_scenes().items():
if scene["name"] == name:
scene["id"] = key
return scene
return None
def in_room_activate_scene(self, room_name: str, scene_name: str):
"""Activate a scene in a room.
Args:
scene (str): The name of the scene to activate.
room (str): The name of the room to activate the scene in.
"""
scene_id = self.get_scene_by_name(scene_name)["id"]
if scene_id is None:
raise "Scene not found."
self.bridge.set_group(room_name, {"scene": scene_id})
def in_room_deactivate_lights(self, room_name: str):
"""Deactivate all lights in a room.
Args:
room_name (str): The name of the room to deactivate the lights in.
"""
self.bridge.set_group(room_name, {"on": False})
def in_room_activate_lights(self, room_name: str):
"""Activate all lights in a room.
Args:
room_name (str): The name of the room to activate the lights in.
"""
self.bridge.set_group(room_name, {"on": True})

View file

@ -1,60 +0,0 @@
from fastapi import FastAPI, APIRouter
from hue.hue_adapter import HueAdapter
from ..mash.feature import Feature
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
router = APIRouter(tags=["hue"])
hue = HueAdapter("192.168.178.85")
########## Integration ##########
class HueIntegration(Feature):
def __init__(self) -> None:
super().__init__("hue")
def add_routes(self, server: FastAPI) -> None:
server.include_router(router, prefix="/hue")
########## Routes ##########
@router.get("/scenes", tags=["scene"])
async def get_scenes():
return hue.list_scenes()
@router.post(
"/room/{room_name}/scene/{scene_name}",
tags=["room", "scene"],
)
async def activate_scene(room_name: str, scene_name: str):
try:
hue.in_room_activate_scene(room_name, scene_name)
except Exception as e:
return HTMLResponse(status_code=400, content=str(e))
@router.post(
"/room/{room_name}/off",
tags=["room"],
)
async def deactivate_room(room_name: str):
try:
hue.in_room_deactivate_lights(room_name)
except Exception as e:
return HTMLResponse(status_code=400, content=str(e))
@router.post(
"/room/{room_name}/on",
tags=["room"],
)
async def activate_room(room_name: str):
try:
hue.in_room_activate_lights(room_name)
except Exception as e:
return HTMLResponse(status_code=400, content=str(e))

View file

@ -1,7 +1,7 @@
from fastapi import FastAPI, APIRouter
from hue.hue_adapter import HueAdapter
from ..mash.feature import Feature
from ..mash.core.feature import Feature
from fastapi import APIRouter
from fastapi.responses import HTMLResponse

View file

@ -1,4 +1,4 @@
from mash.feature import Feature
from mash.core.feature import Feature
class MatrixClockIntegration(Feature):

1
src/mash/__init__.py Normal file
View file

@ -0,0 +1 @@
from .mash import MaSH

View file

@ -0,0 +1 @@
from .fritzbox_bridge import FritzBoxBridge

View file

@ -0,0 +1,101 @@
import logging
from typing import Optional
from mash.core.bridge import Bridge
from fritzconnection import FritzConnection
EXCEPTION_RECONNECT_TIMEOUT_SEC = 60
class FritzDeviceState:
"""Holds the state of a fritzbox device and implements comparisons."""
def __init__(self, mac_address: str, raw_state: dict) -> None:
"""
Args:
mac_address (str): Mac Address of fritzbox device.
raw_state (dict): Raw device state containing NewActive, NewIPAddress, NewAddressSource, NewLeaseTimeRemaining, NewInterfaceType & NewHostName.
"""
logging.debug(
f"Fritz raw device state to mac [{mac_address}]: {raw_state}",
extra=raw_state,
)
self.mac_address = mac_address
self.active: bool = raw_state["NewActive"]
self.ip_address: str = raw_state["NewIPAddress"]
self.address_source: str = raw_state["NewAddressSource"]
self.lease_time_remaining = raw_state["NewLeaseTimeRemaining"]
self.interface_type: str = raw_state["NewInterfaceType"]
self.host_name: str = raw_state["NewHostName"]
def __eq__(self, value: object) -> bool:
return (
type(value) is FritzDeviceState
and self.mac_address == value.mac_address
and self.ip_address == value.ip_address
and self.address_source == value.address_source
and self.lease_time_remaining == value.lease_time_remaining
and self.interface_type == value.interface_type
and self.host_name == value.host_name
and self.active == value.active
)
def __str__(self) -> str:
return f"[{self.mac_address} | {self.host_name}] {'Active' if self.active else 'Inactive'} - {self.ip_address} - {self.address_source} - {self.interface_type} - {self.lease_time_remaining}"
class FritzBoxBridge(Bridge):
def __init__(
self,
*,
id: str,
ip: str,
port: Optional[int] = None,
) -> None:
"""
Args:
id (str): Id of fritzbox bridge.
ip (str): IP Address of fritzbox bridge in network to connect to.
port (Optional[int], optional): Port of fritzbox bridge in network to connect to. Defaults to None.
"""
super().__init__(id=id, type="fritzbox")
self._ip = ip
self._port = port
self._fritz_api: FritzConnection = None
def connect(self) -> None:
if self._fritz_api:
self.disconnect()
self._fritz_api = FritzConnection(address=self._ip, port=self._port)
logging.info("Connected")
def disconnect(self) -> None:
logging.info("Disconnected")
self._fritz_api = None
@property
def is_connected(self) -> bool | None:
return self._fritz_api is not None
@Bridge.requires_connection
def get_known_devices(self) -> list[dict]:
numberOfDevices = self._fritz_api.call_action(
"Hosts", "GetHostNumberOfEntries"
)["NewHostNumberOfEntries"]
devices = []
for i in range(numberOfDevices):
devices.append(
self._fritz_api.call_action("Hosts", "GetGenericHostEntry", NewIndex=i)
)
return devices
@Bridge.requires_connection
def get_device_state(self, mac_address: str) -> FritzDeviceState:
return FritzDeviceState(
mac_address=mac_address,
raw_state=self._fritz_api.call_action(
"Hosts", "GetSpecificHostEntry", NewMACAddress=mac_address
),
)

View file

@ -0,0 +1 @@
from .hue_bridge import HueBridge

View file

@ -0,0 +1,92 @@
import logging
from mash.core.bridge import Bridge
from phue import Bridge as phueBridge
from time import sleep
class HueBridge(Bridge):
def __init__(
self,
*,
id: str,
ip: str,
retry_limit: int = 10,
retry_timeout_seconds: int = 5,
) -> None:
super().__init__(id=id, type="hue")
self._retry_limit = retry_limit
self._retry_timeout_seconds = retry_timeout_seconds
self._hue: phueBridge = phueBridge(ip)
def disconnect(self) -> None:
self._hue = None
logging.info(f"Disconnected from Hue Bridge [{self.id}].")
def connect(self) -> None:
for _ in range(self._retry_limit):
try:
self._hue.connect()
logging.info(f"Connected to Hue Bridge [{self.id}].")
return
except Exception as e:
logging.exception(
f"Failed to connect to Hue bridge [ip {self._hue.ip}]. Retrying in [{self._retry_timeout_seconds}] seconds.",
exc_info=e,
)
sleep(self._retry_timeout_seconds)
logging.error(
f"Unable to connect to Hue bridge [{self.id}]. Retry count exceeded [{self._retry_limit}]."
)
@property
def is_connected(self) -> bool | None:
return self._hue is not None
def list_api(self) -> dict:
return self._hue.get_api()
def list_scenes(self) -> dict:
return self._hue.get_scene()
def get_scene_by_name(self, name) -> dict | None:
for key, scene in self.list_scenes().items():
if scene["name"] == name:
scene["id"] = key
return scene
return None
def set_light(self, lights, command):
return self._hue.set_light(lights, command)
def get_light(self, id, command=None):
return self._hue.get_light(id, command)
def set_group(self, groups, command):
return self._hue.set_group(groups, command)
def get_group(self, id, command=None):
return self._hue.get_group(id, command)
def in_room_activate_scene(self, room_name: str, scene_name: str):
"""Activate a scene in a room.
Args:
scene (str): The name of the scene to activate.
room (str): The name of the room to activate the scene in.
"""
scene_id = self.get_scene_by_name(scene_name)["id"]
if scene_id is None:
raise "Scene not found."
self._hue.set_group(room_name, {"scene": scene_id})
def in_room_set_lights_active(self, room_name: str, active: bool):
"""Deactivate all lights in a room.
Args:
room_name (str): The name of the room to deactivate the lights in.
active (bool): True, to turn all lights on, or False to turn them off.
"""
self._hue.set_group(room_name, {"on": active})

View file

@ -0,0 +1,13 @@
from mash.core.entities.light import Light
from mash.core.utilities.glow import Glow
class HueLight(Light):
def __init__(
self, *, id: str, name: str, room: str, groups: list[str] = ...
) -> None:
super().__init__(id=id, name=name, room=room, groups=groups)
def __on_change__(self, current_on: bool, current_glow: Glow):
pass
# TODO: Requires reference to bridge

View file

@ -0,0 +1 @@
from .matrixclock_entity import MatrixClockEntity

View file

@ -0,0 +1,48 @@
from mash.core.entity import Entity
import requests as r
class MatrixClockEntity(Entity):
def __init__(self, *, id: str, name: str, room: str, ip: str, port: int) -> None:
super().__init__(id=id, name=name, room=room, device_type="matrixclock")
self._ip = ip
self._port = port
self._base_path = ""
def __post_request__(self, endpoint: str, content: dict = {}) -> r.Response:
url: str = f"http://{self._ip}:{self._port}{self._base_path}/{endpoint}"
return r.post(url=url, json=content)
def display_off(self):
self.__post_request__("off")
def display_time(self):
self.__post_request__("time")
def display_full(self):
self.__post_request__("full")
def display_pattern(self, *, pattern: str = "01", step_ms: int = 500):
self.__post_request__(f"pattern?pattern={pattern}&step_ms={step_ms}")
def get_climate_and_show_temperature(self) -> dict[str, float]:
return self.__post_request__("temperature").json()
def get_climate_and_show_humidity(self) -> dict[str, float]:
return self.__post_request__("humidity").json()
def flash_display(self, *, count: int = 1, contrast: int = None):
path = f"flash?count={count}"
if contrast:
path += "&contrast={contrast}"
self.__post_request__(path)
def set_contrast(self, *, contrast: int):
self.__post_request__(f"contrast?contrast={contrast}")
def get_contrast(self) -> int:
return self.__post_request__("contrast").json()["contrast"]
def show_message(self, *, message: str):
self.__post_request__("message", {"message": message})

View file

@ -0,0 +1 @@
from .restapi_bridge import RestApiBridge

View file

@ -0,0 +1,6 @@
from mash.core.bridge import Bridge
class RestApiBridge(Bridge):
def __init__(self, *, id: str) -> None:
super().__init__(id=id, type="restapi")

View file

@ -0,0 +1 @@
from .zigbee2mqtt_bridge import Z2mBridge

View file

@ -0,0 +1,5 @@
from mash.core.entities.contact_sensor import ContactSensor
class ContactSensorZ2M(ContactSensor):

View file

@ -0,0 +1,90 @@
import logging
from typing import Optional
from mash.core.bridge import Bridge
import paho.mqtt.client as mqtt
import json
class Z2mBridge(Bridge):
def __init__(
self,
*,
id: str,
ip: str,
port: int = 1883,
keepalive: int = 60,
topic: str = "zigbee2mqtt",
) -> None:
"""
Args:
id (str): Unique identifier of this bridge instance.
ip (str): IP-Address of MQTT broker.
port (int, optional): Port of MQTT broker. Defaults to 1883.
keepalive (int, optional): MQTT keepalive delay in seconds. Defaults to 60.
topic (str, optional): Base topic for Zigbee2MQTT interface. Defaults to "zigbee2mqtt".
"""
super().__init__(id=id, type="zigbee2mqtt")
self._ip = ip
self._port = port
self._keepalive = keepalive
self._device_callbacks: dict[str, list] = {}
self._topic = topic.strip("/")
self._client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
self._client.on_connect = lambda client, userdata, flags, reason_code, properties: self.__on_connect__(
client, userdata, flags, reason_code, properties
)
self._client.on_message = lambda client, userdata, msg: self.__on_message__(
client, userdata, msg
)
def disconnect(self) -> None:
self._client.loop_stop()
self._client.disconnect()
logging.info(f"Disconnected from Zigbee2MQTT broker [{self.id}].")
def connect(self) -> None:
self._client.connect(self._ip, self._port, self._keepalive)
self._client.loop_start()
logging.info(f"Connect to Zigbee2MQTT broker [{self.id}].")
@property
def is_connected(self) -> bool | None:
return self._client.is_connected()
def __on_connect__(self, client, userdata, flags, reason_code, properties):
self._client.subscribe(f"{self._topic}/#")
def __on_message__(self, client, userdata, msg: any):
device_name = msg.topic.split(self._topic + "/", 2)[-1].split("/", 2)[0]
if device_name not in self._device_callbacks.keys():
return
for callback in self._device_callbacks[device_name]:
callback(device_name, json.loads(msg.payload))
@Bridge.requires_connection
def set_device(self, ieee_address: str, *, content: dict = {}) -> None:
self._client.publish(f"{self._topic}/{ieee_address}/set", json.dumps(content))
@Bridge.requires_connection
def get_device(self, ieee_address: str) -> None:
self._client.publish(
f"{self._topic}/{ieee_address}/get", json.dumps({"state": ""})
)
def subscribe_device(
self,
callback,
*,
ieee_address: Optional[str] = None,
friendly_name: Optional[str] = None,
) -> None:
for id in [ieee_address, friendly_name]:
if id not in self._device_callbacks.keys():
self._device_callbacks[id] = []
self._device_callbacks[id].append(callback)

View file

@ -0,0 +1,3 @@
from .entities import *
from .bridge import Bridge, BridgeException
from .feature import Feature

46
src/mash/core/bridge.py Normal file
View file

@ -0,0 +1,46 @@
class BridgeException(Exception):
def __init__(self, id: str, type: str, message: str) -> None:
super().__init__(f"Bridge [{type} | {id}] has thrown an exception: {message}")
class Bridge:
def __init__(self, *, id: str, type: str) -> None:
self._id = id
self._type = type
@property
def id(self) -> str:
return self._id
@property
def type(self) -> str:
return self._type
@property
def is_connected(self) -> bool | None:
return None
def __del__(self) -> None:
self.disconnect()
def connect(self) -> None:
pass
def disconnect(self) -> None:
pass
def requires_connection(func, *, auto_connect=True):
def inner(self: Bridge, *args, **kwargs):
if self.is_connected is False: # Neither True, nor None
if not auto_connect:
raise BridgeException(
self.id,
self.type,
f"Bridge must be manually connected before executing method [{func.__name__}].",
)
self.connect()
return func(*args, **kwargs)
return inner

View file

@ -0,0 +1,6 @@
from .entity import Entity
from .group import Group
from .home import Home
from .contact_sensor import ContactSensor
from .light import Light
from .device_type import DeviceTypes

View file

@ -0,0 +1,32 @@
from mash.core.entities.device_type import DeviceType
from mash.core.entities.entity import Entity
class ContactSensor(Entity):
def __init__(
self, *, id: str, name: str, room: str, groups: list[str] = ...
) -> None:
super().__init__(
id=id,
name=name,
room=room,
device_type=DeviceType.CONTACT_SENSOR,
groups=groups,
)
self._has_contact: bool = False
def is_closed(self) -> bool:
return self._has_contact
def is_open(self) -> bool:
return not self._has_contact
def is_closed_for_seconds(self, duration_in_seconds: float) -> bool:
# TODO
pass
def is_open_for_seconds(self, duration_in_seconds: float) -> bool:
# TODO
pass
# TODO: Update state

View file

@ -0,0 +1,6 @@
from enum import Enum
class DeviceType(Enum):
LIGHT = "light"
CONTACT_SENSOR = "contact_sensor"

View file

@ -1,12 +1,22 @@
from mash.core.entities.device_type import DeviceType
class Entity:
def __init__(
self, *, id: str, name: str, room: str, device_type: str, groups: list[str] = []
self,
*,
id: str,
name: str,
room: str,
device_type: DeviceType,
groups: list[str] = [],
) -> None:
self._id = id
self._name = name
self._room = room
self._device_type = device_type
self._groups = set(groups)
self._pollable = False
@property
def id(self) -> str:
@ -21,12 +31,20 @@ class Entity:
return self._room
@property
def device_type(self) -> str:
def device_type(self) -> DeviceType:
return self._device_type
@property
def groups(self) -> set[str]:
return self._groups
@property
def is_pollable(self) -> bool:
return self._pollable
def _poll_(self) -> None:
"""Polls the bridge for the latest state."""
pass
def __str__(self) -> str:
return f"{self.name} [{self.id}, type {self.device_type}, room {self.room}, in {len(self.groups)} groups]"

View file

@ -1,4 +1,4 @@
from mash.entities.entity import Entity
from mash.core.entities.entity import Entity
from fnmatch import fnmatch

View file

@ -1,5 +1,5 @@
from mash.entities.entity import Entity
from mash.entities.group import Group
from mash.core.entities.entity import Entity
from mash.core.entities.group import Group
class Home(Group):

View file

@ -0,0 +1,66 @@
from mash.core.entities.device_type import DeviceType
from mash.core.entities.entity import Entity
from mash.core.utilities.glow import Glow
from mash.core.utilities.validation import clip_int
class Light(Entity):
def __init__(
self, *, id: str, name: str, room: str, groups: list[str] = ...
) -> None:
super().__init__(
id=id, name=name, room=room, device_type=DeviceType.LIGHT, groups=groups
)
self._glow: Glow = Glow()
self._on: bool = False
def __on_change__(self, current_on: bool, current_glow: Glow):
pass
def __check_for_change__(self, obj, prop, new_value):
old_value = getattr(obj, prop)
if old_value == new_value:
return
setattr(obj, prop, new_value)
self.__on_change__(current_on=self.on, current_glow=self._glow)
@property
def on(self) -> bool:
"""True, if light is emitting glow. False, if light is off."""
return self._on
@on.setter
def on(self, value: bool):
"""True, if light is emitting glow. False, if light is off."""
self.__check_for_change__(self, "_on", value)
@property
def brightness(self) -> int:
"""Brightness in the range [0, 254]."""
return self._glow.brightness
@brightness.setter
def brightness(self, value: int):
"""Brightness in the range [0, 254]. Value will be clipped."""
self.__check_for_change__(self._glow, "brightness", clip_int(value, 0, 254))
@property
def saturation(self) -> int:
"""Saturation in the range [0, 254]."""
return self._glow.saturation
@saturation.setter
def saturation(self, value: int):
"""Saturation in the range [0, 254]. Value will be clipped."""
self.__check_for_change__(self._glow, "saturation", clip_int(value, 0, 254))
@property
def hue(self) -> int:
"""Hue in the range [0, 65535]."""
return self._glow.hue
@hue.setter
def hue(self, value: int):
"""Hue in the range [0, 65535]. Value will be clipped."""
self.__check_for_change__(self._glow, "hue", clip_int(value, 0, 65535))

View file

@ -0,0 +1,2 @@
from .glow import Glow
from .validation import clip_int

View file

@ -0,0 +1,39 @@
class Glow:
"""Concept of colored light-rays."""
def __init__(
self, *, brightness: int = 0, saturation: int = 0, hue: int = 0
) -> None:
self._brightness: int = brightness
self._saturation: int = saturation
self._hue: int = hue
@property
def brightness(self) -> int:
"""Brightness in the range [0, 254]."""
return self._brightness
@brightness.setter
def brightness(self, value: int):
"""Brightness in the range [0, 254]."""
self._brightness = value
@property
def saturation(self) -> int:
"""Saturation in the range [0, 254]."""
return self._saturation
@saturation.setter
def saturation(self, value: int):
"""Saturation in the range [0, 254]."""
self._saturation = value
@property
def hue(self) -> int:
"""Hue in the range [0, 65535]."""
return self._hue
@hue.setter
def hue(self, value: int):
"""Hue in the range [0, 65535]."""
self._hue = value

View file

@ -0,0 +1,2 @@
def clip_int(value, min_val: int, max_val: int) -> int:
return min([max([int(value), min_val]), max_val])

View file

@ -1,7 +1,7 @@
import yaml
from fastapi import FastAPI
from mash.feature import Feature
from mash.core.feature import Feature
class MaSH:

View file

View file

@ -0,0 +1,3 @@
from peewee import SqliteDatabase
database = SqliteDatabase("mash_database.sqlite")

View file

@ -0,0 +1,7 @@
from .database import database
from .models import *
def create_tables():
with database:
database.create_tables([Device, Feature, StateLog])

View file

@ -0,0 +1,3 @@
from base_model import BaseModel
from device import Device, Feature
from logs import StateLog

View file

@ -0,0 +1,11 @@
from ..database import database
from peewee import AutoField
class BaseModel():
id = AutoField(
primary_key=True,
unique=True,
)
class Meta:
database = database

View file

@ -0,0 +1,12 @@
from .base_model import BaseModel
from peewee import DateTimeField, ForeignKeyField, DecimalField, CharField
class StateLog(BaseModel):
timestamp = DateTimeField()
property = CharField(max_length=200, index=True)
device_id = CharField(max_length=200, index=True)
device_name = CharField(max_length=200, index=True)
char_value = DecimalField(5, 2, auto_round=True)
numeric_value = DecimalField(12, 3, auto_round=True)
previous_state = ForeignKeyField("StateLog")