Compare commits

6 Commits

Author SHA1 Message Date
John Lancaster
6bd0e0709e renamed 2024-06-11 22:55:16 -05:00
John Lancaster
58b5023653 simplified base a bit 2024-05-29 21:25:01 -05:00
John Lancaster
e5f8871378 made RoomControllerBase 2024-05-29 20:25:55 -05:00
John Lancaster
aee8002d77 implemented async queue 2024-05-29 18:45:54 -05:00
John Lancaster
a58909ca26 cleaned up console 2024-05-28 18:56:19 -05:00
John Lancaster
2c287ab5b9 initial implementation 2024-05-28 18:47:46 -05:00
8 changed files with 195 additions and 122 deletions

View File

@@ -1,4 +1,4 @@
from .room_control import RoomController
from .controller import RoomController
from .motion import Motion
from .button import Button
from .door import Door

150
src/room_control/base.py Normal file
View File

@@ -0,0 +1,150 @@
import asyncio
import logging.config
from logging import Handler, LogRecord
from typing import TYPE_CHECKING
import aiohttp
from appdaemon.adapi import ADAPI
from .console import RCHighlighter, console
if TYPE_CHECKING:
from room_control import RoomController
class Singleton(type):
"""
https://en.wikipedia.org/wiki/Singleton_pattern
https://stackoverflow.com/q/6760685
https://realpython.com/python-metaclasses/
https://docs.python.org/3/reference/datamodel.html#metaclasses
"""
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
return cls._instances[cls]
class AsyncLokiHandler(Handler, metaclass=Singleton):
loop: asyncio.BaseEventLoop
loki_url: str
queue: asyncio.Queue
consumer_task: asyncio.Task
def __init__(self, loop: asyncio.BaseEventLoop, loki_url: str):
console.print(f' [bold yellow]{hex(id(self))}[/] '.center(50, '-'))
super().__init__()
self.loop = loop
self.loki_url = loki_url
self.queue = asyncio.Queue()
self.consumer_task = self.loop.create_task(self.log_consumer())
def emit(self, record: LogRecord):
self.loop.create_task(self.send_to_loki(record))
async def send_to_loki(self, record: LogRecord):
labels = {'room': record.room}
if component := getattr(record, 'component', False):
labels['component'] = component
message = self.format(record)
ns = round(record.created * 1_000_000_000)
# https://grafana.com/docs/loki/latest/reference/loki-http-api/#ingest-logs
payload = {'streams': [{'stream': labels, 'values': [[str(ns), message]]}]}
await self.queue.put(payload)
async def log_consumer(self):
async with aiohttp.ClientSession() as session:
try:
while True:
payload = await self.queue.get()
await session.post(self.loki_url, json=payload, timeout=1)
# console.print('[bold yellow]Sent to Loki[/]')
except asyncio.CancelledError:
console.print('[bold red]Cancelled[/]')
class RoomControlBase(ADAPI):
app: 'RoomController'
logger: logging.LoggerAdapter
def initialize(self, room: str, component: str = None):
"""Sets up the logging"""
logger_name = f'Appdaemon.{room}'
extra_attributes = {'room': room}
# all the stuff that has to happen for a component
if component is not None:
self.app: 'RoomController' = self.get_app(self.args['app'])
logger_name += f'.{component}'
extra_attributes['component'] = component
rich_handler_args = {
'console': console,
'highlighter': RCHighlighter(),
'markup': True,
'show_path': False,
'omit_repeated_times': False,
}
logging.config.dictConfig(
{
'version': 1,
'disable_existing_loggers': False,
'filters': {'unmarkup': {'()': 'room_control.console.UnMarkupFilter'}},
'formatters': {
'basic': {'style': '{', 'format': '{message}'},
'rich': {
'style': '{',
'format': '[room]{room}[/] {message}',
'datefmt': '%H:%M:%S.%f',
},
'rich_component': {
'style': '{',
'format': '[room]{room}[/] [component]{component}[/] {message}',
'datefmt': '%H:%M:%S.%f',
},
},
'handlers': {
'rich': {
'formatter': 'rich',
'()': 'rich.logging.RichHandler',
**rich_handler_args,
},
'rich_component': {
'formatter': 'rich_component',
'()': 'rich.logging.RichHandler',
**rich_handler_args,
},
'async_queue': {
'formatter': 'basic',
'filters': ['unmarkup'],
'()': 'room_control.base.AsyncLokiHandler',
'loop': self.AD.loop,
'loki_url': self.args['loki_url'],
},
},
'loggers': {
logger_name: {
'level': 'INFO',
'propagate': False,
'handlers': [
'rich' if component is None else 'rich_component',
'async_queue',
],
}
},
}
)
self.logger = logging.LoggerAdapter(logging.getLogger(logger_name), extra_attributes)
self.handler: AsyncLokiHandler = self.logger.logger.handlers[-1]
def terminate(self):
status: bool = self.handler.consumer_task.cancel()
if status:
self.log('Cancelled consumer task')

View File

@@ -1,27 +1,21 @@
import json
from dataclasses import dataclass
from logging import Logger
from typing import TYPE_CHECKING, List
from typing import List
from appdaemon.plugins.mqtt.mqttapi import Mqtt
from . import console
from .base import RoomControlBase
from .model import ButtonConfig
if TYPE_CHECKING:
from room_control import RoomController
@dataclass(init=False)
class Button(Mqtt):
class Button(RoomControlBase, Mqtt):
button: str | List[str]
rich: bool = False
config: ButtonConfig
logger: Logger
async def initialize(self):
self.app: 'RoomController' = await self.get_app(self.args['app'])
self.logger = console.load_rich_config(self.app.name, type(self).__name__)
def initialize(self):
super().initialize(room=self.args['app'], component='Button')
self.config = ButtonConfig(**self.args)
self.log(f'Connected to AD app [room]{self.app.name}[/]', level='DEBUG')

View File

@@ -48,6 +48,8 @@ class RCHighlighter(RegexHighlighter):
def load_rich_config(
room: str = None, component: str = None, level: str = 'INFO'
) -> logging.LoggerAdapter:
"""Loads the config from the .rich_logging.yaml file, adds some bits, and returns a LoggerAdapter
"""
logger_name = f'Appdaemon.{room}'
if component is not None:
@@ -60,6 +62,12 @@ def load_rich_config(
RICH_CFG['handlers']['rich']['highlighter'] = RCHighlighter()
RICH_CFG['handlers']['rich_component']['console'] = console
RICH_CFG['handlers']['rich_component']['highlighter'] = RCHighlighter()
# RICH_CFG['handlers']['async_queue'] = {
# 'formatter': 'basic',
# '()': 'room_control.loki.AsyncQueueHandler',
# 'loop': self.AD.loop,
# 'queue': self.loki_queue,
# }
RICH_CFG['loggers'] = {
logger_name: {
'handlers': ['rich' if component is None else 'rich_component'],
@@ -79,18 +87,11 @@ def load_rich_config(
return adapter
RICH_HANDLER_CFG = {
'()': 'rich.logging.RichHandler',
'markup': True,
'show_path': False,
# 'show_time': False,
'omit_repeated_times': False,
'console': console,
'highlighter': RCHighlighter(),
}
class ContextSettingFilter(logging.Filter, ABC):
"""Adds all the dataclass fields as attributes to LogRecord objects.
Intended to be inherited from by something that uses the dataclasses.dataclass decorator.
"""
def filter(self, record: logging.LogRecord) -> logging.LogRecord:
for name, val in asdict(self).items():
if val is not None:
@@ -98,12 +99,6 @@ class ContextSettingFilter(logging.Filter, ABC):
return record
# @dataclass
# class RoomControllerFilter(ContextSettingFilter):
# room: str
# component: Optional[str] = None
class RoomFilter(logging.Filter):
"""Used to filter out messages that have a component field because they will have already been printed by their respective logger."""
@@ -111,11 +106,6 @@ class RoomFilter(logging.Filter):
return not hasattr(record, 'component')
# class RoomControllerFormatter(logging.Formatter):
# def format(self, record: logging.LogRecord):
# return super().format(record)
class UnMarkupFilter(logging.Filter):
md_regex = re.compile(r'(?P<open>\[.*?\])(?P<text>.*?)(?P<close>\[\/\])')
@@ -129,6 +119,17 @@ class JSONFormatter(logging.Formatter):
return json.dumps(record.__dict__)
RICH_HANDLER_CFG = {
'()': 'rich.logging.RichHandler',
'markup': True,
'show_path': False,
# 'show_time': False,
'omit_repeated_times': False,
'console': console,
'highlighter': RCHighlighter(),
}
## currently unused
def room_logging_config(name: str):
return {
'version': 1,
@@ -186,52 +187,3 @@ def room_logging_config(name: str):
},
},
}
# def component_logging_config(parent_room: str, component: str):
# logger_name = f'AppDaemon.{parent_room}.{component}'
# cfg = load_rich_config()
# LOG_CFG = {
# 'version': 1,
# 'disable_existing_loggers': False,
# 'formatters': {
# 'rich_component': {
# 'style': '{',
# 'format': '[room]{room}[/] [component]{component}[/] {message}',
# 'datefmt': '%H:%M:%S.%f',
# },
# },
# 'handlers': {
# 'rich_component': {
# 'formatter': 'rich_component',
# **RICH_HANDLER_CFG,
# },
# },
# 'loggers': {
# logger_name: {
# # 'level': 'INFO',
# 'propagate': True,
# 'handlers': ['rich_component'],
# }
# },
# }
# return LOG_CFG
# def setup_component_logging(self) -> logging.Logger:
# """Creates a logger for a subcomponent with a RichHandler"""
# component = type(self).__name__
# parent = self.args['app']
# cfg_dict = component_logging_config(parent_room=parent, component=component)
# logger_name = next(iter(cfg_dict['loggers']))
# try:
# logging.config.dictConfig(cfg_dict)
# except Exception:
# console.print_exception()
# else:
# logger = logging.getLogger(logger_name)
# logger = logging.LoggerAdapter(logger, {'room': parent, 'component': component})
# return logger

View File

@@ -7,15 +7,14 @@ from typing import Dict, List
from appdaemon.entity import Entity
from appdaemon.plugins.hass.hassapi import Hass
from appdaemon.plugins.mqtt.mqttapi import Mqtt
from . import console
from .base import RoomControlBase
from .model import ControllerStateConfig, RoomControllerConfig
logger = logging.getLogger(__name__)
class RoomController(Hass, Mqtt):
class RoomController(RoomControlBase, Hass):
"""Class for linking room's lights with a motion sensor.
- Separate the turning on and turning off functions.
@@ -35,9 +34,8 @@ class RoomController(Hass, Mqtt):
self._room_config.states = new
def initialize(self):
self.logger = console.load_rich_config(self.name)
super().initialize(room=self.name)
self.app_entities = self.gather_app_entities()
# self.log(f'entities: {self.app_entities}')
self.refresh_state_times()
self.run_daily(callback=self.refresh_state_times, start='00:00:00')
self.log(f'Initialized [bold green]{type(self).__name__}[/]')

View File

@@ -1,26 +1,17 @@
from logging import Logger
from typing import TYPE_CHECKING
from appdaemon.plugins.hass.hassapi import Hass
from . import console
if TYPE_CHECKING:
from room_control import RoomController
from .base import RoomControlBase
class Door(Hass):
app: 'RoomController'
logger: Logger
class Door(RoomControlBase, Hass):
def initialize(self):
super().initialize(room=self.args['app'], component='Door')
async def initialize(self):
self.app: 'RoomController' = await self.get_app(self.args['app'])
self.logger = console.load_rich_config(room=self.app.name, component=type(self).__name__)
self.log(f'Connected to AD app [room]{self.app.name}[/]', level='DEBUG')
await self.listen_state(
self.listen_state(
self.app.activate_all_off,
entity_id=self.args['door'],
new='on',
cause='door open',
)
self.log(f'Waiting for door to open: [bold green]{self.args["door"]}[/]')

View File

@@ -4,7 +4,7 @@ from typing import Annotated, Dict, List, Optional, Self
import yaml
from astral import SunDirection
from pydantic import BaseModel, BeforeValidator, Field, root_validator
from pydantic import BaseModel, BeforeValidator, Field, model_validator
from pydantic_core import PydanticCustomError
from rich.console import Console, ConsoleOptions, RenderResult
from rich.table import Column, Table
@@ -51,7 +51,7 @@ class ControllerStateConfig(BaseModel):
off_duration: Optional[OffDuration] = None
scene: dict[str, State] | str
@root_validator(pre=True)
@model_validator(mode='before')
def check_args(cls, values):
time, elevation = values.get('time'), values.get('elevation')
if time is not None and elevation is not None:

View File

@@ -1,16 +1,12 @@
import re
from datetime import timedelta
from logging import Logger
from typing import TYPE_CHECKING, Literal, Optional
from typing import Literal, Optional
from appdaemon.entity import Entity
from appdaemon.plugins.hass.hassapi import Hass
from pydantic import BaseModel, ValidationError
from . import console
if TYPE_CHECKING:
from room_control import RoomController
from .base import RoomControlBase
class CallbackEntry(BaseModel):
@@ -27,10 +23,7 @@ class CallbackEntry(BaseModel):
Callbacks = dict[str, dict[str, CallbackEntry]]
class Motion(Hass):
logger: Logger
app: 'RoomController'
class Motion(RoomControlBase, Hass):
@property
def sensor(self) -> Entity:
return self.get_entity(self.args['sensor'])
@@ -52,8 +45,7 @@ class Motion(Hass):
return self.sensor_state != self.ref_entity_state
def initialize(self):
self.app: 'RoomController' = self.get_app(self.args['app'])
self.logger = console.load_rich_config(self.app.name, type(self).__name__)
super().initialize(room=self.args['app'], component='Motion')
assert self.entity_exists(self.args['sensor'])
assert self.entity_exists(self.args['ref_entity'])
@@ -71,7 +63,6 @@ class Motion(Hass):
if self.sensor_state:
self.app.activate(kwargs={'cause': f'Syncing state with {self.sensor.entity_id}'})
# don't need to await these because they'll already get turned into a task by the utils.sync_wrapper decorator
self.listen_state(
**base_kwargs,
attribute='brightness',
@@ -79,9 +70,6 @@ class Motion(Hass):
)
self.listen_state(**base_kwargs, new='off', callback=self.callback_light_off)
for handle, cb in self.callbacks():
self.log(f'Handle [yellow]{handle[:4]}[/]: {cb.function}', level='DEBUG')
self.log(f'Initialized [bold green]{type(self).__name__}[/]')
def callbacks(self):