6 Commits
events ... loki

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 .motion import Motion
from .button import Button from .button import Button
from .door import Door 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 import json
from dataclasses import dataclass from dataclasses import dataclass
from logging import Logger from typing import List
from typing import TYPE_CHECKING, List
from appdaemon.plugins.mqtt.mqttapi import Mqtt from appdaemon.plugins.mqtt.mqttapi import Mqtt
from . import console from .base import RoomControlBase
from .model import ButtonConfig from .model import ButtonConfig
if TYPE_CHECKING:
from room_control import RoomController
@dataclass(init=False) @dataclass(init=False)
class Button(Mqtt): class Button(RoomControlBase, Mqtt):
button: str | List[str] button: str | List[str]
rich: bool = False rich: bool = False
config: ButtonConfig config: ButtonConfig
logger: Logger
async def initialize(self): def initialize(self):
self.app: 'RoomController' = await self.get_app(self.args['app']) super().initialize(room=self.args['app'], component='Button')
self.logger = console.load_rich_config(self.app.name, type(self).__name__)
self.config = ButtonConfig(**self.args) self.config = ButtonConfig(**self.args)
self.log(f'Connected to AD app [room]{self.app.name}[/]', level='DEBUG') 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( def load_rich_config(
room: str = None, component: str = None, level: str = 'INFO' room: str = None, component: str = None, level: str = 'INFO'
) -> logging.LoggerAdapter: ) -> logging.LoggerAdapter:
"""Loads the config from the .rich_logging.yaml file, adds some bits, and returns a LoggerAdapter
"""
logger_name = f'Appdaemon.{room}' logger_name = f'Appdaemon.{room}'
if component is not None: if component is not None:
@@ -60,6 +62,12 @@ def load_rich_config(
RICH_CFG['handlers']['rich']['highlighter'] = RCHighlighter() RICH_CFG['handlers']['rich']['highlighter'] = RCHighlighter()
RICH_CFG['handlers']['rich_component']['console'] = console RICH_CFG['handlers']['rich_component']['console'] = console
RICH_CFG['handlers']['rich_component']['highlighter'] = RCHighlighter() 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'] = { RICH_CFG['loggers'] = {
logger_name: { logger_name: {
'handlers': ['rich' if component is None else 'rich_component'], 'handlers': ['rich' if component is None else 'rich_component'],
@@ -79,18 +87,11 @@ def load_rich_config(
return adapter 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): 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: def filter(self, record: logging.LogRecord) -> logging.LogRecord:
for name, val in asdict(self).items(): for name, val in asdict(self).items():
if val is not None: if val is not None:
@@ -98,12 +99,6 @@ class ContextSettingFilter(logging.Filter, ABC):
return record return record
# @dataclass
# class RoomControllerFilter(ContextSettingFilter):
# room: str
# component: Optional[str] = None
class RoomFilter(logging.Filter): 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.""" """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') return not hasattr(record, 'component')
# class RoomControllerFormatter(logging.Formatter):
# def format(self, record: logging.LogRecord):
# return super().format(record)
class UnMarkupFilter(logging.Filter): class UnMarkupFilter(logging.Filter):
md_regex = re.compile(r'(?P<open>\[.*?\])(?P<text>.*?)(?P<close>\[\/\])') md_regex = re.compile(r'(?P<open>\[.*?\])(?P<text>.*?)(?P<close>\[\/\])')
@@ -129,6 +119,17 @@ class JSONFormatter(logging.Formatter):
return json.dumps(record.__dict__) 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): def room_logging_config(name: str):
return { return {
'version': 1, '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.entity import Entity
from appdaemon.plugins.hass.hassapi import Hass 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 from .model import ControllerStateConfig, RoomControllerConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RoomController(Hass, Mqtt): class RoomController(RoomControlBase, Hass):
"""Class for linking room's lights with a motion sensor. """Class for linking room's lights with a motion sensor.
- Separate the turning on and turning off functions. - Separate the turning on and turning off functions.
@@ -35,9 +34,8 @@ class RoomController(Hass, Mqtt):
self._room_config.states = new self._room_config.states = new
def initialize(self): def initialize(self):
self.logger = console.load_rich_config(self.name) super().initialize(room=self.name)
self.app_entities = self.gather_app_entities() self.app_entities = self.gather_app_entities()
# self.log(f'entities: {self.app_entities}')
self.refresh_state_times() self.refresh_state_times()
self.run_daily(callback=self.refresh_state_times, start='00:00:00') self.run_daily(callback=self.refresh_state_times, start='00:00:00')
self.log(f'Initialized [bold green]{type(self).__name__}[/]') 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 appdaemon.plugins.hass.hassapi import Hass
from . import console from .base import RoomControlBase
if TYPE_CHECKING:
from room_control import RoomController
class Door(Hass): class Door(RoomControlBase, Hass):
app: 'RoomController' def initialize(self):
logger: Logger super().initialize(room=self.args['app'], component='Door')
async def initialize(self): self.listen_state(
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.app.activate_all_off, self.app.activate_all_off,
entity_id=self.args['door'], entity_id=self.args['door'],
new='on', new='on',
cause='door open', 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 import yaml
from astral import SunDirection 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 pydantic_core import PydanticCustomError
from rich.console import Console, ConsoleOptions, RenderResult from rich.console import Console, ConsoleOptions, RenderResult
from rich.table import Column, Table from rich.table import Column, Table
@@ -51,7 +51,7 @@ class ControllerStateConfig(BaseModel):
off_duration: Optional[OffDuration] = None off_duration: Optional[OffDuration] = None
scene: dict[str, State] | str scene: dict[str, State] | str
@root_validator(pre=True) @model_validator(mode='before')
def check_args(cls, values): def check_args(cls, values):
time, elevation = values.get('time'), values.get('elevation') time, elevation = values.get('time'), values.get('elevation')
if time is not None and elevation is not None: if time is not None and elevation is not None:

View File

@@ -1,16 +1,12 @@
import re import re
from datetime import timedelta from datetime import timedelta
from logging import Logger from typing import Literal, Optional
from typing import TYPE_CHECKING, Literal, Optional
from appdaemon.entity import Entity from appdaemon.entity import Entity
from appdaemon.plugins.hass.hassapi import Hass from appdaemon.plugins.hass.hassapi import Hass
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
from . import console from .base import RoomControlBase
if TYPE_CHECKING:
from room_control import RoomController
class CallbackEntry(BaseModel): class CallbackEntry(BaseModel):
@@ -27,10 +23,7 @@ class CallbackEntry(BaseModel):
Callbacks = dict[str, dict[str, CallbackEntry]] Callbacks = dict[str, dict[str, CallbackEntry]]
class Motion(Hass): class Motion(RoomControlBase, Hass):
logger: Logger
app: 'RoomController'
@property @property
def sensor(self) -> Entity: def sensor(self) -> Entity:
return self.get_entity(self.args['sensor']) return self.get_entity(self.args['sensor'])
@@ -52,8 +45,7 @@ class Motion(Hass):
return self.sensor_state != self.ref_entity_state return self.sensor_state != self.ref_entity_state
def initialize(self): def initialize(self):
self.app: 'RoomController' = self.get_app(self.args['app']) super().initialize(room=self.args['app'], component='Motion')
self.logger = console.load_rich_config(self.app.name, type(self).__name__)
assert self.entity_exists(self.args['sensor']) assert self.entity_exists(self.args['sensor'])
assert self.entity_exists(self.args['ref_entity']) assert self.entity_exists(self.args['ref_entity'])
@@ -71,7 +63,6 @@ class Motion(Hass):
if self.sensor_state: if self.sensor_state:
self.app.activate(kwargs={'cause': f'Syncing state with {self.sensor.entity_id}'}) 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( self.listen_state(
**base_kwargs, **base_kwargs,
attribute='brightness', attribute='brightness',
@@ -79,9 +70,6 @@ class Motion(Hass):
) )
self.listen_state(**base_kwargs, new='off', callback=self.callback_light_off) 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__}[/]') self.log(f'Initialized [bold green]{type(self).__name__}[/]')
def callbacks(self): def callbacks(self):