Compare commits
23 commits
master
...
database-i
Author | SHA1 | Date | |
---|---|---|---|
7d67b6c91b | |||
b3106e8bae | |||
b9d8c5312c | |||
4848c06448 | |||
866b717b27 | |||
69465c8edf | |||
1a3bcb88f0 | |||
2b17367313 | |||
0330e49ec0 | |||
3709dc10bd | |||
0749f72c60 | |||
c1a5b61e30 | |||
91552bafb6 | |||
ff7b23b716 | |||
fd414c2cd1 | |||
c2aa2f5adc | |||
f2c8d6885d | |||
a1f992856b | |||
72e680a515 | |||
836e286178 | |||
56bae56bdf | |||
1b49d4e023 | |||
7bc79139f0 |
47 changed files with 783 additions and 306 deletions
48
README.md
48
README.md
|
@ -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]
|
||||
|
|
1
bettwaage-plotter/latest_history.json
Normal file
1
bettwaage-plotter/latest_history.json
Normal file
File diff suppressed because one or more lines are too long
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -5,12 +5,15 @@ phue
|
|||
fritzconnection
|
||||
|
||||
# API
|
||||
requests
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
|
||||
# Clients
|
||||
# Bridges
|
||||
requests
|
||||
paho-mqtt
|
||||
|
||||
# Config file
|
||||
pyyaml
|
||||
|
||||
# Database
|
||||
peewee
|
||||
|
|
|
@ -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"
|
|
@ -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)
|
|
@ -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})
|
|
@ -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))
|
|
@ -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
|
||||
|
|
@ -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
1
src/mash/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .mash import MaSH
|
1
src/mash/bridges/fritzbox/__init__.py
Normal file
1
src/mash/bridges/fritzbox/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .fritzbox_bridge import FritzBoxBridge
|
101
src/mash/bridges/fritzbox/fritzbox_bridge.py
Normal file
101
src/mash/bridges/fritzbox/fritzbox_bridge.py
Normal 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
|
||||
),
|
||||
)
|
1
src/mash/bridges/hue/__init__.py
Normal file
1
src/mash/bridges/hue/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .hue_bridge import HueBridge
|
92
src/mash/bridges/hue/hue_bridge.py
Normal file
92
src/mash/bridges/hue/hue_bridge.py
Normal 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})
|
13
src/mash/bridges/hue/hue_light.py
Normal file
13
src/mash/bridges/hue/hue_light.py
Normal 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
|
1
src/mash/bridges/matrixclock/__init__.py
Normal file
1
src/mash/bridges/matrixclock/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .matrixclock_entity import MatrixClockEntity
|
48
src/mash/bridges/matrixclock/matrixclock_entity.py
Normal file
48
src/mash/bridges/matrixclock/matrixclock_entity.py
Normal 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})
|
1
src/mash/bridges/restapi/__init__.py
Normal file
1
src/mash/bridges/restapi/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .restapi_bridge import RestApiBridge
|
6
src/mash/bridges/restapi/restapi_bridge.py
Normal file
6
src/mash/bridges/restapi/restapi_bridge.py
Normal 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")
|
1
src/mash/bridges/zigbee2mqtt/__init__.py
Normal file
1
src/mash/bridges/zigbee2mqtt/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .zigbee2mqtt_bridge import Z2mBridge
|
|
@ -0,0 +1,5 @@
|
|||
from mash.core.entities.contact_sensor import ContactSensor
|
||||
|
||||
|
||||
class ContactSensorZ2M(ContactSensor):
|
||||
|
90
src/mash/bridges/zigbee2mqtt/zigbee2mqtt_bridge.py
Normal file
90
src/mash/bridges/zigbee2mqtt/zigbee2mqtt_bridge.py
Normal 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)
|
3
src/mash/core/__init__.py
Normal file
3
src/mash/core/__init__.py
Normal 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
46
src/mash/core/bridge.py
Normal 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
|
6
src/mash/core/entities/__init__.py
Normal file
6
src/mash/core/entities/__init__.py
Normal 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
|
32
src/mash/core/entities/contact_sensor.py
Normal file
32
src/mash/core/entities/contact_sensor.py
Normal 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
|
6
src/mash/core/entities/device_type.py
Normal file
6
src/mash/core/entities/device_type.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from enum import Enum
|
||||
|
||||
|
||||
class DeviceType(Enum):
|
||||
LIGHT = "light"
|
||||
CONTACT_SENSOR = "contact_sensor"
|
|
@ -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]"
|
|
@ -1,4 +1,4 @@
|
|||
from mash.entities.entity import Entity
|
||||
from mash.core.entities.entity import Entity
|
||||
from fnmatch import fnmatch
|
||||
|
||||
|
|
@ -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):
|
66
src/mash/core/entities/light.py
Normal file
66
src/mash/core/entities/light.py
Normal 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))
|
2
src/mash/core/utilities/__init__.py
Normal file
2
src/mash/core/utilities/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
from .glow import Glow
|
||||
from .validation import clip_int
|
39
src/mash/core/utilities/glow.py
Normal file
39
src/mash/core/utilities/glow.py
Normal 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
|
2
src/mash/core/utilities/validation.py
Normal file
2
src/mash/core/utilities/validation.py
Normal 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])
|
|
@ -1,7 +1,7 @@
|
|||
import yaml
|
||||
from fastapi import FastAPI
|
||||
|
||||
from mash.feature import Feature
|
||||
from mash.core.feature import Feature
|
||||
|
||||
|
||||
class MaSH:
|
||||
|
|
0
src/mash/storage/__init__.py
Normal file
0
src/mash/storage/__init__.py
Normal file
3
src/mash/storage/database.py
Normal file
3
src/mash/storage/database.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from peewee import SqliteDatabase
|
||||
|
||||
database = SqliteDatabase("mash_database.sqlite")
|
7
src/mash/storage/helper.py
Normal file
7
src/mash/storage/helper.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
from .database import database
|
||||
from .models import *
|
||||
|
||||
|
||||
def create_tables():
|
||||
with database:
|
||||
database.create_tables([Device, Feature, StateLog])
|
3
src/mash/storage/models/__init__.py
Normal file
3
src/mash/storage/models/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from base_model import BaseModel
|
||||
from device import Device, Feature
|
||||
from logs import StateLog
|
11
src/mash/storage/models/base_model.py
Normal file
11
src/mash/storage/models/base_model.py
Normal 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
|
12
src/mash/storage/models/logs.py
Normal file
12
src/mash/storage/models/logs.py
Normal 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")
|
Loading…
Reference in a new issue