Compare commits

..

21 commits

Author SHA1 Message Date
Max
7164d6e2fd Slimming stuff down 2025-01-21 18:52:53 +01:00
1d46b570f6 Hmmmm, more or less working hue stuff 2025-01-21 06:45:21 +01:00
5e8ad4f400 Expanded color datatype 2025-01-21 05:03:47 +01:00
668e8e78b1 Removed default implementation for "on" property 2025-01-21 05:03:39 +01:00
c3ec52005e Small improvement 2025-01-20 06:41:18 +01:00
Max
c78546ffcf More hue implementation 2025-01-20 01:10:30 +01:00
Max
db5e826aea Some unfinished hue stuff 2025-01-19 17:38:25 +01:00
Max
7b9ab32db1 Implemented new fritzbox 2025-01-17 16:41:16 +01:00
Max
e131f58849 Reimplemented group entities 2025-01-16 20:52:55 +01:00
Max
08c4372c4e Fixed wrong url 2025-01-16 20:49:27 +01:00
Max
5defaba45f Background services running properly; New Bedscale Entity implemented 2025-01-16 19:14:34 +01:00
9aba0279d7 bescale api 2025-01-12 17:39:28 +01:00
04e44849ee Normalizing device type a bit 2025-01-12 17:18:02 +01:00
8a2773a97c Momentary handling for background services 2025-01-12 17:15:12 +01:00
f5b4a6c30f Added missing parameter to poetry conf 2025-01-12 17:13:58 +01:00
Max
142439b7c3 Sync commit 2025-01-11 01:17:12 +01:00
Max
f9fdffbdbd Refactor + documentation 2025-01-10 11:51:29 +01:00
Max
32853a0e7c Added some comments 2025-01-10 11:46:49 +01:00
Max
51e446710b Fixing task cancellation in main 2025-01-09 21:49:43 +01:00
Max
003284ccba Merging new concepts with current version 2025-01-09 19:06:11 +01:00
Max
0beaab9549 Switched to Poetry for dependency management 2025-01-09 16:43:13 +01:00
62 changed files with 2573 additions and 757 deletions

View file

@ -1,6 +1,6 @@
# Max' Smart Home - MaSH # Max' Smart Home - MaSH
Should be a very simple **server** implementation of what is required in Max's smart home. Trying not to overcomplicate things and thereby ruin motivation to work on this. Should be a (very simple?) **server** implementation of what is required in Max's smart home. Trying not to overcomplicate things and thereby ruin motivation to work on this.
## Sensors ## Sensors
@ -8,51 +8,16 @@ Should be a very simple **server** implementation of what is required in Max's s
## ToDo ## ToDo
- Daylight Adjustment - Energy-saving/Off mode (Only one light slighty on to deal with the state) (How should power plugs be handled?)
- No ceiling lights during daytime - Daylight Adjustment (E.g. No ceiling lights during daytime)
- Color Temperature match to outside
- Save scene when turning off, to reapply same scene when turning on - 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 - 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 ## Structure
Example Hue Light: - `src/` - All code
``` - `main.py` - Entry point for execution
[HueLight] - `new_syntax_example.py` - Not in use, just noting some ideas about a potential syntax
- implementes [LightEntity] - `core/` - Contains more abstract framework definitions
- uses [HueBridge] - `bridges/` - Contains latest code that manages connections to other services/devices
``` - `endpoints/` - Contains API routes and older handlers/bridges
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,31 +1,20 @@
database: mash_database.sqlite features:
hue:
bridges: hue-bridge:
- &hue id: hue ip: 192.168.178.23
type: hue matrixclock:
clock:
ip: 192.168.178.23 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: home:
latitude: 52.51860 latitude: 52.51860
longitude: 13.37565 longitude: 13.37565
beds:
- id: max-bed
name: Bettwaage
room: *max
rooms: rooms:
- id : &hw hallway - id : &hw hallway
name: Flur name: Flur
@ -34,97 +23,14 @@ home:
- to: *bath - to: *bath
- to: *kit - to: *kit
- to: *living - to: *living
- id : &balcony balcony
outside: True
name: Balkon
- id : &bath bath - id : &bath bath
name: Badezimmer name: Badezimmer
- id : &kit kitchen - id : &kit kitchen
name: Küche name: Küche
- id : &living living - id : &living living
name: Wohnzimmer name: Wohnzimmer
doors: doors:
- to: *max - to: *max
- to: *balcony
devices:
- id: 786eba96-1acf-4e59-a7d3-4faf13fca196
name: living-bat-signal
connection:
bridge: *hue
light_id: 2
- id : &max max - id : &max max
name: Max' Zimmer 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

350
poetry.lock generated Normal file
View file

@ -0,0 +1,350 @@
[[package]]
name = "annotated-types"
version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated"
category = "main"
optional = false
python-versions = ">=3.8"
[[package]]
name = "anyio"
version = "4.8.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
category = "main"
optional = false
python-versions = ">=3.9"
[package.dependencies]
exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""}
idna = ">=2.8"
sniffio = ">=1.1"
typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
[package.extras]
trio = ["trio (>=0.26.1)"]
test = ["anyio", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"]
doc = ["packaging", "Sphinx (>=7.4,<8.0)", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"]
[[package]]
name = "certifi"
version = "2024.12.14"
description = "Python package for providing Mozilla's CA Bundle."
category = "main"
optional = false
python-versions = ">=3.6"
[[package]]
name = "charset-normalizer"
version = "3.4.1"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "click"
version = "8.1.8"
description = "Composable command line interface toolkit"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
[[package]]
name = "exceptiongroup"
version = "1.2.2"
description = "Backport of PEP 654 (exception groups)"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "fastapi"
version = "0.115.6"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
category = "main"
optional = false
python-versions = ">=3.8"
[package.dependencies]
pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
starlette = ">=0.40.0,<0.42.0"
typing-extensions = ">=4.8.0"
[package.extras]
standard = ["fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "email-validator (>=2.0.0)", "uvicorn[standard] (>=0.12.0)"]
all = ["fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "itsdangerous (>=1.1.0)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "orjson (>=3.2.1)", "email-validator (>=2.0.0)", "uvicorn[standard] (>=0.12.0)", "pydantic-settings (>=2.0.0)", "pydantic-extra-types (>=2.0.0)"]
[[package]]
name = "fritzconnection"
version = "1.14.0"
description = "Communicate with the AVM FRITZ!Box"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
requests = ">=2.22.0"
[package.extras]
qr = ["segno (>=1.4.1)"]
[[package]]
name = "h11"
version = "0.14.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "httptools"
version = "0.6.4"
description = "A collection of framework independent HTTP protocol utils."
category = "main"
optional = false
python-versions = ">=3.8.0"
[package.extras]
test = ["Cython (>=0.29.24)"]
[[package]]
name = "idna"
version = "3.10"
description = "Internationalized Domain Names in Applications (IDNA)"
category = "main"
optional = false
python-versions = ">=3.6"
[package.extras]
all = ["ruff (>=0.6.2)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "flake8 (>=7.1.1)"]
[[package]]
name = "paho-mqtt"
version = "2.1.0"
description = "MQTT version 5.0/3.1.1 client class"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
proxy = ["pysocks"]
[[package]]
name = "phue"
version = "1.1"
description = "A Philips Hue Python library"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "pydantic"
version = "2.10.5"
description = "Data validation using Python type hints"
category = "main"
optional = false
python-versions = ">=3.8"
[package.dependencies]
annotated-types = ">=0.6.0"
pydantic-core = "2.27.2"
typing-extensions = ">=4.12.2"
[package.extras]
email = ["email-validator (>=2.0.0)"]
timezone = ["tzdata"]
[[package]]
name = "pydantic-core"
version = "2.27.2"
description = "Core functionality for Pydantic validation and serialization"
category = "main"
optional = false
python-versions = ">=3.8"
[package.dependencies]
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]]
name = "python-dotenv"
version = "1.0.1"
description = "Read key-value pairs from a .env file and set them as environment variables"
category = "main"
optional = false
python-versions = ">=3.8"
[package.extras]
cli = ["click (>=5.0)"]
[[package]]
name = "pyyaml"
version = "6.0.2"
description = "YAML parser and emitter for Python"
category = "main"
optional = false
python-versions = ">=3.8"
[[package]]
name = "requests"
version = "2.32.3"
description = "Python HTTP for Humans."
category = "main"
optional = false
python-versions = ">=3.8"
[package.dependencies]
certifi = ">=2017.4.17"
charset-normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.21.1,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "sniffio"
version = "1.3.1"
description = "Sniff out which async library your code is running under"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "starlette"
version = "0.41.3"
description = "The little ASGI library that shines."
category = "main"
optional = false
python-versions = ">=3.8"
[package.dependencies]
anyio = ">=3.4.0,<5"
[package.extras]
full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"]
[[package]]
name = "typing-extensions"
version = "4.12.2"
description = "Backported and Experimental Type Hints for Python 3.8+"
category = "main"
optional = false
python-versions = ">=3.8"
[[package]]
name = "urllib3"
version = "2.3.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main"
optional = false
python-versions = ">=3.9"
[package.extras]
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "uvicorn"
version = "0.34.0"
description = "The lightning-fast ASGI server."
category = "main"
optional = false
python-versions = ">=3.9"
[package.dependencies]
click = ">=7.0"
colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""}
h11 = ">=0.8"
httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""}
python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""}
uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
[package.extras]
standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"]
[[package]]
name = "uvloop"
version = "0.21.0"
description = "Fast implementation of asyncio event loop on top of libuv"
category = "main"
optional = false
python-versions = ">=3.8.0"
[package.extras]
dev = ["setuptools (>=60)", "Cython (>=3.0,<4.0)"]
docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)"]
test = ["aiohttp (>=3.10.5)", "flake8 (>=5.0,<6.0)", "psutil", "pycodestyle (>=2.9.0,<2.10.0)", "pyOpenSSL (>=23.0.0,<23.1.0)", "mypy (>=0.800)"]
[[package]]
name = "watchfiles"
version = "1.0.3"
description = "Simple, modern and high performance file watching and code reload in python."
category = "main"
optional = false
python-versions = ">=3.9"
[package.dependencies]
anyio = ">=3.0.0"
[[package]]
name = "websockets"
version = "14.1"
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
category = "main"
optional = false
python-versions = ">=3.9"
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
content-hash = "470a473cadc51beaadf9c37d81c5ddbde315022964b11d596949a69d3905c583"
[metadata.files]
annotated-types = []
anyio = []
certifi = []
charset-normalizer = []
click = []
colorama = []
exceptiongroup = []
fastapi = []
fritzconnection = []
h11 = []
httptools = []
idna = []
paho-mqtt = []
phue = []
pydantic = []
pydantic-core = []
python-dotenv = []
pyyaml = []
requests = []
sniffio = []
starlette = []
typing-extensions = []
urllib3 = []
uvicorn = []
uvloop = []
watchfiles = []
websockets = []

24
pyproject.toml Normal file
View file

@ -0,0 +1,24 @@
[tool.poetry]
name = "mash-server"
version = "0.1.0"
description = "Max' Smart Home"
authors = ["Max <m.giller.dev@gmail.com>"]
package-mode = false
[tool.poetry.scripts]
start = "main:app"
[tool.poetry.dependencies]
python = "^3.10"
phue = "^1.1"
fritzconnection = "^1.14.0"
fastapi = "^0.115.6"
requests = "^2.32.3"
paho-mqtt = "^2.1.0"
uvicorn = {extras = ["standard"], version = "^0.34.0"}
[tool.poetry.dev-dependencies]
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

View file

@ -1,19 +0,0 @@
# For Philips Hue
phue
# For Fritz.Box API
fritzconnection
# API
fastapi
uvicorn[standard]
# Bridges
requests
paho-mqtt
# Config file
pyyaml
# Database
peewee

View file

@ -0,0 +1 @@
from .bedscale_entity import BedscaleEntity

View file

@ -0,0 +1,67 @@
import requests
import asyncio
from core.entity import Entity
class BedscaleWeightResult:
def __init__(self, *, tr: float, tl: float, br: float, bl: float):
self.top_right = tr
self.top_left = tl
self.bottom_right = br
self.bottom_left = bl
# Calculate total if all values available
self.total: float | None = None
values = [bl, br, tl, tr]
if None not in values:
self.total = sum(values)
class BedscaleEntity(Entity):
def __init__(
self,
*,
ip_address: str,
id: str,
name: str,
room: str,
history_length: int = 60 * 5,
):
super().__init__(id=id, name=name, room=room, device_type="bedscale")
self._ip_address = ip_address
self._history: list[BedscaleWeightResult] = []
self._history_length = history_length
self.latest_weight: BedscaleWeightResult = None
async def update(self):
loop = asyncio.get_event_loop()
tr_request = loop.run_in_executor(None, self.__poll_scale__, "tr")
tl_request = loop.run_in_executor(None, self.__poll_scale__, "tl")
br_request = loop.run_in_executor(None, self.__poll_scale__, "br")
bl_request = loop.run_in_executor(None, self.__poll_scale__, "bl")
new_result = BedscaleWeightResult(
tr=await tr_request,
tl=await tl_request,
br=await br_request,
bl=await bl_request,
)
# TODO: Sanity checks
# TODO: Keep track of empty-bed weight
self._history.append(new_result)
if len(self._history) > self._history_length:
self._history = self._history[-self._history_length :]
def __poll_scale__(self, leg: str) -> float | None:
try:
return requests.get(f"{self._ip_address}/sensor/{leg}/").json()["value"]
except:
return None
def get_history(self) -> list[BedscaleWeightResult]:
return self._history

View file

@ -5,7 +5,7 @@ import os
from statistics import median from statistics import median
from typing import Optional from typing import Optional
import requests as r import requests as r
from ..hue import hue from ...endpoints.hue import hue_bridge
import logging import logging
file_path: str = "bettwaage.csv" file_path: str = "bettwaage.csv"
@ -126,9 +126,9 @@ def check_for_change():
# Make room sexy # Make room sexy
if sexy_mode_detection: if sexy_mode_detection:
if number_of_people >= 2 and weight_increased: if number_of_people >= 2 and weight_increased:
hue.in_room_activate_scene("Max Zimmer", "Sexy") hue_bridge.in_room_activate_scene("Max Zimmer", "Sexy")
elif number_of_people == 1 and not weight_increased: elif number_of_people == 1 and not weight_increased:
hue.in_room_activate_scene("Max Zimmer", "Tageslicht") hue_bridge.in_room_activate_scene("Max Zimmer", "Tageslicht")
def add_line_to_bed_history(line: str) -> None: def add_line_to_bed_history(line: str) -> None:
@ -166,6 +166,6 @@ async def log_bed_weights():
add_weights_to_log(tl, tr, bl, br) add_weights_to_log(tl, tr, bl, br)
check_for_change() check_for_change()
except Exception as ex: except Exception as ex:
logging.exception(ex) pass
finally: finally:
await asyncio.sleep(1) await asyncio.sleep(1)

View file

@ -1,6 +1,5 @@
import logging import logging
from typing import Optional from core.bridge import Bridge
from mash.core.bridge import Bridge
from fritzconnection import FritzConnection from fritzconnection import FritzConnection
@ -51,15 +50,14 @@ class FritzBoxBridge(Bridge):
*, *,
id: str, id: str,
ip: str, ip: str,
port: Optional[int] = None, port: int | None = None,
) -> None: ) -> None:
""" """
Args: Args:
id (str): Id of fritzbox bridge. id (str): Id of fritzbox bridge.
ip (str): IP Address of fritzbox bridge in network to connect to. 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. port (int, optional): Port of fritzbox bridge in network to connect to. Defaults to None.
""" """
super().__init__(id=id, type="fritzbox")
self._ip = ip self._ip = ip
self._port = port self._port = port
self._fritz_api: FritzConnection = None self._fritz_api: FritzConnection = None

View file

@ -0,0 +1,78 @@
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 +1,2 @@
from .hue_bridge import HueBridge from .hue_bridge import HueBridge
from .hue_light import HueLight

View file

@ -1,5 +1,6 @@
import logging import logging
from mash.core.bridge import Bridge from .hue_light import HueLight
from core import Bridge, Group
from phue import Bridge as phueBridge from phue import Bridge as phueBridge
from time import sleep from time import sleep
@ -9,15 +10,15 @@ class HueBridge(Bridge):
def __init__( def __init__(
self, self,
*, *,
ip_address: str,
id: str, id: str,
ip: str,
retry_limit: int = 10, retry_limit: int = 10,
retry_timeout_seconds: int = 5, retry_timeout_seconds: int = 5,
) -> None: ) -> None:
super().__init__(id=id, type="hue") super().__init__(id=id, type="hue")
self._retry_limit = retry_limit self._retry_limit = retry_limit
self._retry_timeout_seconds = retry_timeout_seconds self._retry_timeout_seconds = retry_timeout_seconds
self._hue: phueBridge = phueBridge(ip) self._hue: phueBridge = phueBridge(ip_address)
def disconnect(self) -> None: def disconnect(self) -> None:
self._hue = None self._hue = None
@ -47,6 +48,37 @@ class HueBridge(Bridge):
def list_api(self) -> dict: def list_api(self) -> dict:
return self._hue.get_api() return self._hue.get_api()
def get_all_lights(self) -> Group:
rooms = {
r["name"]: [int(l) for l in r["lights"]]
for r in self._hue.get_api()["groups"].values()
if r["type"] == "Room"
}
lights = []
for l in self._hue.get_light_objects():
if l.colormode != "xy":
# TODO: Implement light to handle all color modes
continue
room = "Unknown"
for room_name, room_lights in rooms.items():
if l.light_id in room_lights:
room = room_name
break
lights.append(
HueLight(
hue_bridge=self,
hue_light=l,
id=f"hue-light-{l.light_id}",
name=l.name,
room=room,
)
)
return Group(entities=lights, id="all-hue-lights", name="All Hue Lights")
def list_scenes(self) -> dict: def list_scenes(self) -> dict:
return self._hue.get_scene() return self._hue.get_scene()
@ -57,7 +89,7 @@ class HueBridge(Bridge):
return scene return scene
return None return None
def set_light(self, lights, command): def set_light(self, lights: int | list[int], command):
return self._hue.set_light(lights, command) return self._hue.set_light(lights, command)
def get_light(self, id, command=None): def get_light(self, id, command=None):

View file

@ -0,0 +1,129 @@
import asyncio
from core import Entity, Color
from phue import Light
class HueLight(Entity):
MAX_HUE_RANGE: int = 65535
MAX_SAT_RANGE: int = 254
MAX_BRI_RANGE: int = 254
TRANSITION_TIME_SCALE: float = 0.1
def __init__(
self,
*,
hue_bridge: "HueBridge",
hue_light: Light,
id: str,
name: str,
room: str,
groups: list[str] = [],
) -> None:
super().__init__(
id=id, name=name, room=room, groups=groups, device_type="light"
)
self._bridge = hue_bridge
self._hue_light = hue_light
self._on = False
self._color: Color = Color()
self._transition_duration_sec = 0
asyncio.run(self.update())
async def __commit_to_device__(self):
self._hue_light.transitiontime = (
self._transition_duration_sec * HueLight.TRANSITION_TIME_SCALE
)
self._hue_light.on = self._on
self._hue_light.hue = self._color.hue * HueLight.MAX_HUE_RANGE
self._hue_light.saturation = self._color.saturation * HueLight.MAX_SAT_RANGE
self._hue_light.brightness = self._color.brightness * HueLight.MAX_BRI_RANGE
async def update(self):
def __internal_update__():
self._on = self._hue_light.on
# TODO: Update Color instead of overwriting to better track change
self._color = Color(
hue=self._hue_light.hue / HueLight.MAX_HUE_RANGE,
saturation=self._hue_light.saturation / HueLight.MAX_SAT_RANGE,
brightness=self._hue_light.brightness / HueLight.MAX_BRI_RANGE,
)
await asyncio.get_running_loop().run_in_executor(None, __internal_update__)
@property
def color(self) -> Color:
return self._color
@property
def on(self) -> bool:
return self._on
@property
def transition_time(self) -> float:
return self._transition_duration_sec
async def set_brightness(self, brightness: float):
if self._color.brightness == brightness:
return
self._color.brightness = brightness
if self._on:
await self.__commit_to_device__()
async def set_hue(self, hue: float):
if self._color.hue == hue:
return
self._color.hue = hue
if self._on:
await self.__commit_to_device__()
async def set_saturation(self, saturation: float):
if self._color.saturation == saturation:
return
self._color.saturation = saturation
if self._on:
await self.__commit_to_device__()
async def set_color(self, color: Color):
if self._color == color:
return
# TODO: Avoid overwriting to better track change
self._color = color
if self._on:
await self.__commit_to_device__()
async def set_transition_duration(self, seconds: float):
if seconds < 0:
raise ValueError(
f"Given transition duration in seconds must be greater 0. Instead [{str(seconds)}] was provided."
)
self._transition_duration_sec = seconds
async def turn_on(self):
if self._on:
return
self._on = True
await self.__commit_to_device__()
async def turn_off(self):
if not self._on:
return
self._on = False
await self.__commit_to_device__()

View file

@ -1,4 +1,4 @@
from mash.core.entity import Entity from core import Entity
import requests as r import requests as r

View file

@ -0,0 +1,2 @@
from .zigbee2mqtt_bridge import Z2mBridge
from .contact_sensor_z2m import ContactSensorZ2M

View file

@ -0,0 +1,5 @@
from core import Entity
class ContactSensorZ2M(Entity):
pass

View file

@ -1,6 +1,6 @@
import logging import logging
from typing import Optional from typing import Optional
from mash.core.bridge import Bridge from core import Bridge
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
import json import json

5
src/core/__init__.py Normal file
View file

@ -0,0 +1,5 @@
from .bridge import Bridge, BridgeException
from .entity import Entity
from .group import Group
from .room import Room
from .color import Color

88
src/core/color.py Normal file
View file

@ -0,0 +1,88 @@
class Color:
def __init__(self, *, hue: float = 0, saturation: float = 0, brightness: float = 0):
self.hue = hue
self.saturation = saturation
self.brightness = brightness
@property
def hue(self) -> float:
return self._hue
@hue.setter
def hue(self, value: float):
if 0 <= value <= 1:
self._hue = value
else:
raise ValueError("Hue must be between 0 and 1")
@property
def saturation(self) -> float:
return self._saturation
@saturation.setter
def saturation(self, value: float):
if 0 <= value <= 1:
self._saturation = value
else:
raise ValueError("Saturation must be between 0 and 1")
@property
def brightness(self) -> float:
return self._brightness
@brightness.setter
def brightness(self, value: float):
if 0 <= value <= 1:
self._brightness = value
else:
raise ValueError("Brightness must be between 0 and 1")
def __eq__(self, other):
if isinstance(other, Color):
return (
self.hue == other.hue
and self.saturation == other.saturation
and self.brightness == other.brightness
)
raise ValueError(
"Can only compare Color instance to other Color instance. Instead, instance of another class was provided."
)
def __str__(self):
return self.__repr__()
def __repr__(self):
# Convert HSB to RGB
rgb = self.to_rgb()
# Convert RGB to HEX
return f"#{int(rgb[0] * 255):02X}{int(rgb[1] * 255):02X}{int(rgb[2] * 255):02X}"
def to_rgb(self) -> tuple:
"""Converts HSB to RGB as a tuple of floats in range [0, 1]."""
h, s, b = self.hue, self.saturation, self.brightness
if s == 0:
# Grayscale color (no saturation)
return b, b, b
h = h * 6 # Scale hue to [0, 6]
i = int(h) # Which color sector
f = h - i # Fractional part of hue
p = b * (1 - s)
q = b * (1 - s * f)
t = b * (1 - s * (1 - f))
i %= 6
if i == 0:
return b, t, p
elif i == 1:
return q, b, p
elif i == 2:
return p, b, t
elif i == 3:
return p, q, b
elif i == 4:
return t, p, b
elif i == 5:
return b, p, q

66
src/core/entity.py Normal file
View file

@ -0,0 +1,66 @@
from .color import Color
class EntityOpNotSupportedError(Exception):
def __init__(self, operation: str, *args):
super().__init__(f"Entity does not support '{operation}' operation.", *args)
class Entity:
def __init__(self, *, id: str, name: str, category: str) -> None:
self._id = id
self._name = name
self._category = category.strip().lower()
self._on: bool = False
self._color: Color = Color()
self._message_queue: list[str] = []
self._pattern
def __str__(self) -> str:
return f"{self.name} [{self.id}, {self.category}]"
async def update(self):
"""Implements an entity specific update operation to get the latest state."""
raise EntityOpNotSupportedError("update")
@property
def id(self) -> str:
return self._id
@property
def name(self) -> str:
return self._name
@property
def category(self) -> str:
return self._category
@property
def update_period(self) -> float:
return self._update_period
@property
def color(self) -> Color:
raise EntityOpNotSupportedError("color")
@property
def brightness(self) -> float:
return self.color.brightness
@property
def hue(self) -> float:
return self.color.hue
@property
def saturation(self) -> float:
return self.color.saturation
@property
def on(self) -> bool:
raise EntityOpNotSupportedError("on")
@property
def transition_time(self) -> float:
raise EntityOpNotSupportedError("transition_time")

97
src/core/group.py Normal file
View file

@ -0,0 +1,97 @@
from fnmatch import fnmatch
from .entity import Entity, EntityOpNotSupportedError
class Group(Entity):
def __init__(
self,
*,
entities: list[Entity] = ...,
id: str = "group",
name: str = "Empty Group",
):
super().__init__(id=id, name=name, room=None, device_type="group")
self._entities: list[Entity] = entities
# List of method names to dynamically create
methods_to_create = [
"set_brightness",
"set_hue",
"set_saturation",
"set_color",
"set_transition_duration",
"turn_on",
"turn_off",
]
for method_name in methods_to_create:
setattr(self, method_name, self._create_group_method(method_name))
def __iter__(self):
return iter(self._entities)
def __len__(self):
return len(self._entities)
def __getitem__(self, id: str) -> "Group":
if type(id) is int:
raise "Numerical index not supported."
return self.__get_entities_with_specific_property__("id", id)
async def __call_method__(self, method_name: str, *args, **kwargs):
for entity in self._entities:
try:
func = getattr(entity, method_name)
await func(*args, **kwargs)
except EntityOpNotSupportedError:
pass
def _create_group_method(self, method_name: str):
# Create a method that calls __call_method__ for the given method name
async def group_method(*args, **kwargs):
await self.__call_method__(method_name, *args, **kwargs)
return group_method
def __get_entities_with_specific_property__(
self,
property: str,
target_pattern: str,
) -> list[Entity]:
"""Returns all entities for which the property getter matches the desired pattern.
Args:
property_getter (callable[[Entity], str]): Takes one entity and returns the value of the filtered property.
target_pattern (str): Pattern that is matched against.
Returns:
list[Entity]: A new group of entities or an empty group, if no entity property matches the target pattern.
"""
return Group(
entities=[
e
for e in self._entities
if fnmatch(
getattr(e, property),
target_pattern,
)
],
name=f"{property}={target_pattern}",
)
def with_id(self, id_pattern: str) -> "Group":
return self.__get_entities_with_specific_property__("id", id_pattern)
def with_name(self, name_pattern: str) -> "Group":
return self.__get_entities_with_specific_property__("name", name_pattern)
def in_room(self, room_pattern: str) -> "Group":
return self.__get_entities_with_specific_property__("room", room_pattern)
def of_device_type(self, type_pattern: str) -> "Group":
return self.__get_entities_with_specific_property__("device_type", type_pattern)
def in_groups(self, groups_pattern: str) -> "Group":
return self.__get_entities_with_specific_property__("groups", groups_pattern)

42
src/core/pattern.py Normal file
View file

@ -0,0 +1,42 @@
class Pattern:
def __init__(
self, *, sequence: str = "01", active_ms: int = 1000, inactive_ms: int = 1000
):
self._sequence = None
self._active_ms = None
self._inactive_ms = None
self.sequence = sequence # Use the setter for validation
self.active_ms = active_ms # Use the setter for validation
self.inactive_ms = inactive_ms # Use the setter for validation
@property
def sequence(self) -> str:
return self._sequence
@sequence.setter
def sequence(self, value: str):
if not isinstance(value, str):
raise ValueError("Sequence must be a string.")
if not all(char in "01" for char in value):
raise ValueError("Sequence must only contain '0' and '1'.")
self._sequence = value
@property
def active_ms(self) -> int:
return self._active_ms
@active_ms.setter
def active_ms(self, value: int):
if not isinstance(value, int) or value < 0:
raise ValueError("Active milliseconds must be a non-negative integer.")
self._active_ms = value
@property
def inactive_ms(self) -> int:
return self._inactive_ms
@inactive_ms.setter
def inactive_ms(self, value: int):
if not isinstance(value, int) or value < 0:
raise ValueError("Inactive milliseconds must be a non-negative integer.")
self._inactive_ms = value

56
src/endpoints/bedscale.py Normal file
View file

@ -0,0 +1,56 @@
import asyncio
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi import APIRouter
import os
import csv
from bridges.bedscale import BedscaleEntity
router = APIRouter()
bedscale = BedscaleEntity(
ip_address="http://192.168.178.110:80",
id="bedscale",
name="Bettwaage",
room="Max Zimmer",
)
async def bedscale_service():
while True:
r = await bedscale.update()
await asyncio.sleep(bedscale.update_period)
@router.get("/latest")
async def get_latest():
if len(bedscale.get_history()) == 0:
return HTMLResponse(status_code=200, content="No data given yet")
return bedscale.get_history()[-1]
# @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

54
src/endpoints/fritzbox.py Normal file
View file

@ -0,0 +1,54 @@
import asyncio
import logging
from datetime import datetime
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
from bridges.fritzbox import FritzBoxBridge
router = APIRouter()
fritzbox = FritzBoxBridge(id="fritzbox", ip="192.168.178.1")
refresh_every_seconds = 10
macaddresses_to_track = ["B2:06:77:EE:A9:0F"] # Max' iPhone
devices_last_online: dict[str, datetime] = {}
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:
device = fritzbox.get_device_state(macaddress)
if device.active:
devices_last_online[macaddress] = datetime.now()
except Exception as ex:
logging.exception(ex)
finally:
await asyncio.sleep(refresh_every_seconds)
@router.get("/{mac_address}/state")
async def get_latest(mac_address: str):
if mac_address not in devices_last_online.keys():
return HTMLResponse(status_code=200, content="Mac Address not being tracked.")
last_online_delta = datetime.now() - last_online_delta[mac_address]
return {
"active": last_online_delta < refresh_every_seconds * 2,
"last_active": last_online_delta[mac_address],
}
@router.get("/tracked")
async def get_latest():
return list(devices_last_online.keys())

60
src/endpoints/hue.py Normal file
View file

@ -0,0 +1,60 @@
import asyncio
from fastapi import FastAPI, APIRouter
from bridges.hue import HueBridge, HueLight
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
from core import Group
router = APIRouter(tags=["hue"])
hue_bridge = HueBridge(ip_address="192.168.178.85", id="hue-bridge")
hue_lights: Group = hue_bridge.get_all_lights()
update_period_seconds = 5
async def hue_service():
while True:
try:
await hue_lights.update()
await asyncio.sleep(update_period_seconds)
except:
pass
@router.get("/scenes")
async def get_scenes():
return hue_bridge.list_scenes()
@router.get("/lights")
async def get_scenes():
return {
l.id: {"name": l.name, "room": l.room, "color": str(l.color), "on": l.on}
for l in hue_lights
}
@router.post("/room/{room_name}/scene/{scene_name}")
async def activate_scene(room_name: str, scene_name: str):
try:
hue_bridge.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")
async def deactivate_room(room_name: str):
try:
await hue_lights.in_room(room_name).turn_off()
except Exception as e:
return HTMLResponse(status_code=400, content=str(e))
@router.post("/room/{room_name}/on")
async def activate_room(room_name: str):
try:
await hue_lights.in_room(room_name).turn_on()
except Exception as e:
return HTMLResponse(status_code=400, content=str(e))

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.core.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,13 +0,0 @@
import asyncio
import requests as r
class MatrixClockAdapter:
def __init__(self, ip_address: str) -> None:
self.ip_address = ip_address.strip("/")
if not self.ip_address.startswith("http"):
self.ip_address = f"http://{self.ip_address}"
async def turn_off(self):
await asyncio.run(r.post(f"{self.ip_address}/off"))

View file

@ -1,6 +0,0 @@
from mash.core.feature import Feature
class MatrixClockIntegration(Feature):
def __init__(self) -> None:
super().__init__("matrixclock")

1333
src/hue_api.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,47 @@
import asyncio import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from endpoints.hue import router as hue_router import uvicorn
from endpoints.bettwaage import router as bettwaage_router from endpoints.hue import hue_service, router as hue_router
from endpoints.handlers.fritz import track_network_devices from endpoints.bedscale import bedscale_service, router as bettwaage_router
from endpoints.fritzbox import track_network_devices, router as fritzbox_router
app = FastAPI() # Background task references
asyncio.create_task(track_network_devices()) background_tasks = []
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Start background services."""
# fritz_task = asyncio.create_task(track_network_devices(), name="Fritz!Box Tracker")
bedscale_task = asyncio.create_task(bedscale_service(), name="Polling bed-scale")
# hue_task = asyncio.create_task(hue_service(), name="Polling Hue Bridge")
# TODO: Fix background task execution. ^ these calls are blocking
# Store references to the tasks
# background_tasks.extend([fritz_task, bedscale_task, hue_task])
background_tasks.extend([bedscale_task])
yield
"""Stop background services."""
for task in background_tasks:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass # Expected when cancelling tasks
app = FastAPI(lifespan=lifespan)
# API Routes
app.include_router(hue_router, prefix="/hue", tags=["hue"]) app.include_router(hue_router, prefix="/hue", tags=["hue"])
app.include_router(bettwaage_router, prefix="/bettwaage", tags=["bett"]) app.include_router(bettwaage_router, prefix="/bettwaage", tags=["bed"])
app.include_router(fritzbox_router, prefix="/fritzbox", tags=["fritzbox"])
if __name__ == "__main__": if __name__ == "__main__":
app.run() # Run API server
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

View file

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

View file

@ -1,13 +0,0 @@
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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +0,0 @@
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

@ -1,32 +0,0 @@
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

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

View file

@ -1,50 +0,0 @@
from mash.core.entities.device_type import DeviceType
class Entity:
def __init__(
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:
return self._id
@property
def name(self) -> str:
return self._name
@property
def room(self) -> str:
return self._room
@property
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,66 +0,0 @@
from mash.core.entities.entity import Entity
from fnmatch import fnmatch
class Group:
def __init__(self, *, entities: list[Entity] = []) -> None:
self.entities: list[Entity] = entities
def __len__(self):
return len(self.entities)
def __getitem__(self, id: str) -> "Group":
if type(id) is int:
raise "Numerical index not supported."
return self.id(id)
def __get_entities_with_specific_property__(
entities: list[Entity],
property_getter: callable[[Entity], str],
target_pattern: str,
) -> list[Entity]:
"""Returns all entities for which the property getter matches the desired pattern.
Args:
entities (list[Entity]): List of entities.
property_getter (callable[[Entity], str]): Takes one entity and returns the value of the filtered property.
target_pattern (str): Pattern that is matched against.
Returns:
list[Entity]: A new group of entities or an empty group, if no entity property matches the target pattern.
"""
return Group(
entities=[
e for e in entities if fnmatch(property_getter(e), target_pattern)
]
)
def device_type(self, device_type: str) -> "Group":
return Group.__get_entities_with_specific_property__(
self.entities, lambda e: e.device_type, device_type
)
def id(self, id: str) -> "Group":
return Group.__get_entities_with_specific_property__(
self.entities, lambda e: e.id, id
)
def room(self, room: str) -> "Group":
return Group.__get_entities_with_specific_property__(
self.entities, lambda e: e.room, room
)
def name(self, name: str) -> "Group":
return Group.__get_entities_with_specific_property__(
self.entities, lambda e: e.name, name
)
def lights(self) -> "Group":
return self.device_type("light")
def beds(self) -> "Group":
return self.device_type("bed")
def max(self) -> "Group":
return self.room("max")

View file

@ -1,7 +0,0 @@
from mash.core.entities.entity import Entity
from mash.core.entities.group import Group
class Home(Group):
def __init__(self, *, entities: list[Entity] = []) -> None:
super().__init__(entities=entities)

View file

@ -1,66 +0,0 @@
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

@ -1,9 +0,0 @@
from fastapi import FastAPI
class Feature:
def __init__(self, feature_id: str) -> None:
self.integration_id = feature_id
def add_routes(self, server: FastAPI) -> None:
pass

View file

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

View file

@ -1,39 +0,0 @@
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

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

View file

@ -1,26 +0,0 @@
import yaml
from fastapi import FastAPI
from mash.core.feature import Feature
class MaSH:
def __init__(self, config_path: str) -> None:
self.server: FastAPI = FastAPI()
self.config: dict = None
self._load_config_(config_path)
def _load_config_(self, config_path: str) -> None:
try:
with open(config_path, "r", encoding="UTF-8") as fp:
self.config = yaml.safe_load(fp)
except FileNotFoundError:
raise f"Config file for MaSH server could not be opened at [{config_path}]."
def add_integration(self, feature: Feature) -> None:
feature.add_routes(self.server)
self.server.include_router(feature.get_router())
def run(self):
self.server.run()

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +0,0 @@
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")

View file

@ -35,35 +35,22 @@ class Automation:
def decorator(func): def decorator(func):
return func return func
return decorator return decorator
class PeopleCountEngineV1(Automation): class PeopleCountEngineV1(Automation):
@Automation.trigger( @Automation.trigger(
devices=["matrixclock"], devices=["matrixclock"], rule=lambda h: h.device("matrixclock").contrast == 6
rule=lambda h: h.device("matrixclock").contrast == 6
) )
def turn_light_on_sometimes(self, home: Home): def turn_light_on_sometimes(self, home: Home):
home.room("max").lights().on = True home.room("max").lights().on = True
@Automation.trigger(people=["max"], rule=lambda h: h.person("max").athome())
@Automation.trigger(
people=["max"],
rule=lambda h: h.person("max").athome()
)
def turn_light_on_sometimes(self, home: Home): def turn_light_on_sometimes(self, home: Home):
home.room("max").lights().on = h.person("max").athome() home.room("max").lights().on = h.person("max").athome()
@Automation.state(h.room("Max").lights()) @Automation.state(h.room("Max").lights())
def max_room_light(): def max_room_light():
if max.ishome(): if max.ishome():
@ -80,7 +67,6 @@ def max_room_light():
return scene return scene
from mash.mash import MaSH from mash.mash import MaSH
mash = MaSH() mash = MaSH()