Merge branch 'master' of ch1p.io:homekit

This commit is contained in:
Evgeny Zinoviev 2023-09-27 00:54:57 +03:00
commit d3a295872c
292 changed files with 4180 additions and 2852 deletions

12
.gitignore vendored
View File

@ -6,17 +6,19 @@
config.def.h
__pycache__
.DS_Store
/src/test/test_inverter_monitor.log
/include/test/test_inverter_monitor.log
/youtrack-certificate
/cpp
/src/test.py
/esp32-cam/CameraWebServer/wifi_password.h
/test/test.py
/bin/test.py
/arduino/ESP32CameraWebServer/wifi_password.h
cmake-build-*
.pio
platformio.ini
CMakeListsPrivate.txt
/platformio/*/CMakeLists.txt
/platformio/*/CMakeListsPrivate.txt
/pio/*/CMakeLists.txt
/pio/*/CMakeListsPrivate.txt
/pio/*/.gitignore
*.swp
/localwebsite/vendor

View File

@ -5,12 +5,6 @@ a country house, solving real life tasks.
Mostly undocumented.
## TODO
esp8266/esp32 code:
- move common stuff to the `commom` directory and use it as a framework
## License
BSD-3c

9
bin/__py_include.py Normal file
View File

@ -0,0 +1,9 @@
import sys
import os.path
for _name in ('include/py',):
sys.path.extend([
os.path.realpath(
os.path.join(os.path.dirname(os.path.join(__file__)), '..', _name)
)
])

View File

@ -1,12 +1,13 @@
#!/usr/bin/env python3
import asyncio
import time
import __py_include
from home.config import config
from home.media import MediaNodeServer, ESP32CameraRecordStorage, CameraRecorder
from home.camera import CameraType, esp32
from home.util import Addr
from home import http
from homekit.config import config
from homekit.media import MediaNodeServer, ESP32CameraRecordStorage, CameraRecorder
from homekit.camera import CameraType, esp32
from homekit.util import Addr
from homekit import http
# Implements HTTP API for a camera.
@ -65,7 +66,7 @@ class ESP32CameraNodeServer(MediaNodeServer):
if __name__ == '__main__':
config.load('camera_node')
config.load_app('camera_node')
recorder_kwargs = {}
camera_type = CameraType(config['camera']['type'])

View File

@ -3,12 +3,12 @@ import logging
import os
import sys
import inspect
import zoneinfo
import __py_include
from home.config import config # do not remove this import!
from homekit.config import config # do not remove this import!
from datetime import datetime, timedelta
from logging import Logger
from home.database import InverterDatabase
from homekit.database import InverterDatabase
from argparse import ArgumentParser, ArgumentError
from typing import Optional

View File

@ -2,10 +2,11 @@
import asyncio
import logging
import os.path
import __py_include
from argparse import ArgumentParser
from home.camera.esp32 import WebClient
from home.util import parse_addr, Addr
from homekit.camera.esp32 import WebClient
from homekit.util import Addr
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from datetime import datetime
from typing import Optional
@ -50,7 +51,7 @@ if __name__ == '__main__':
loop = asyncio.get_event_loop()
ESP32Capture(parse_addr(arg.addr), arg.interval, arg.output_directory)
ESP32Capture(Addr.fromstring(arg.addr), arg.interval, arg.output_directory)
try:
loop.run_forever()
except KeyboardInterrupt:

View File

@ -3,11 +3,12 @@ import asyncio
import logging
import os.path
import tempfile
import home.telegram.aio as telegram
import __py_include
import homekit.telegram.aio as telegram
from home.config import config
from home.camera.esp32 import WebClient
from home.util import parse_addr, send_datagram, stringify
from homekit.config import config
from homekit.camera.esp32 import WebClient
from homekit.util import Addr, send_datagram, stringify
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from typing import Optional
@ -34,11 +35,11 @@ async def pyssim(fn1: str, fn2: str) -> float:
class ESP32CamCaptureDiffNode:
def __init__(self):
self.client = WebClient(parse_addr(config['esp32cam_web_addr']))
self.client = WebClient(Addr.fromstring(config['esp32cam_web_addr']))
self.directory = tempfile.gettempdir()
self.nextpic = 1
self.first = True
self.server_addr = parse_addr(config['node']['server_addr'])
self.server_addr = Addr.fromstring(config['node']['server_addr'])
self.scheduler = AsyncIOScheduler()
self.scheduler.add_job(self.capture, 'interval', seconds=config['node']['interval'])
@ -76,7 +77,7 @@ class ESP32CamCaptureDiffNode:
if __name__ == '__main__':
config.load('esp32cam_capture_diff_node')
config.load_app('esp32cam_capture_diff_node')
loop = asyncio.get_event_loop()
ESP32CamCaptureDiffNode()

31
bin/gpiorelayd.py Executable file
View File

@ -0,0 +1,31 @@
#!/usr/bin/env python3
import logging
import os
import sys
import __py_include
from argparse import ArgumentParser
from homekit.util import Addr
from homekit.config import config
from homekit.relay.sunxi_h3_server import RelayServer
logger = logging.getLogger(__name__)
if __name__ == '__main__':
if os.getegid() != 0:
sys.exit('Must be run as root.')
parser = ArgumentParser()
parser.add_argument('--pin', type=str, required=True,
help='name of GPIO pin of Allwinner H3 sunxi board')
parser.add_argument('--listen', type=str, required=True,
help='address to listen to, in ip:port format')
arg = config.load_app(no_config=True, parser=parser)
listen = Addr.fromstring(arg.listen)
try:
RelayServer(pinname=arg.pin, addr=listen).run()
except KeyboardInterrupt:
logger.info('Exiting...')

View File

@ -4,32 +4,40 @@ import re
import datetime
import json
import itertools
import sys
import asyncio
import __py_include
from inverterd import Format, InverterError
from html import escape
from typing import Optional, Tuple, Union
from home.util import chunks
from home.config import config
from home.telegram import bot
from home.inverter import (
from homekit.util import chunks
from homekit.config import config, AppConfigUnit
from homekit.telegram import bot
from homekit.telegram.config import TelegramBotConfig, TelegramUserListType
from homekit.inverter import (
wrapper_instance as inverter,
beautify_table,
InverterMonitor,
)
from home.inverter.types import (
from homekit.inverter.types import (
ChargingEvent,
ACPresentEvent,
BatteryState,
ACMode,
OutputSourcePriority
)
from home.database.inverter_time_formats import *
from home.api.types import BotType
from home.api import WebAPIClient
from homekit.database.inverter_time_formats import FormatDate
from homekit.api import WebApiClient
from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton
monitor: Optional[InverterMonitor] = None
if __name__ != '__main__':
print(f'this script can not be imported as module', file=sys.stderr)
sys.exit(1)
db = None
LT = escape('<=')
flags_map = {
@ -42,9 +50,56 @@ flags_map = {
'alarm_on_on_primary_source_interrupt': 'ALRM',
'fault_code_record': 'FTCR',
}
logger = logging.getLogger(__name__)
config.load('inverter_bot')
class InverterBotConfig(AppConfigUnit, TelegramBotConfig):
NAME = 'inverter_bot'
@classmethod
def schema(cls) -> Optional[dict]:
acmode_item_schema = {
'thresholds': {
'type': 'list',
'required': True,
'schema': {
'type': 'list',
'min': 40,
'max': 60
},
},
'initial_current': {'type': 'integer'}
}
return {
**super(TelegramBotConfig).schema(),
'ac_mode': {
'type': 'dict',
'required': True,
'schema': {
'generator': acmode_item_schema,
'utilities': acmode_item_schema
}
},
'monitor': {
'type': 'dict',
'required': True,
'schema': {
'vlow': {'type': 'integer', 'required': True},
'vcrit': {'type': 'integer', 'required': True},
'gen_currents': {'type': 'list', 'schema': {'type': 'integer'}, 'required': True},
'gen_raise_intervals': {'type': 'list', 'schema': {'type': 'integer'}, 'required': True},
'gen_cur30_v_limit': {'type': 'float', 'required': True},
'gen_cur20_v_limit': {'type': 'float', 'required': True},
'gen_cur10_v_limit': {'type': 'float', 'required': True},
'gen_floating_v': {'type': 'integer', 'required': True},
'gen_floating_time_max': {'type': 'integer', 'required': True}
}
}
}
config.load_app(InverterBotConfig)
bot.initialize()
bot.lang.ru(
@ -293,8 +348,11 @@ def monitor_charging(event: ChargingEvent, **kwargs) -> None:
key = f'chrg_evt_{key}'
if is_util:
key = f'util_{key}'
bot.notify_all(
lambda lang: bot.lang.get(key, lang, *args)
asyncio.ensure_future(
bot.notify_all(
lambda lang: bot.lang.get(key, lang, *args)
)
)
@ -309,9 +367,11 @@ def monitor_battery(state: BatteryState, v: float, load_watts: int) -> None:
logger.error('unknown battery state:', state)
return
bot.notify_all(
lambda lang: bot.lang.get('battery_level_changed', lang,
emoji, bot.lang.get(f'bat_state_{state.name.lower()}', lang), v, load_watts)
asyncio.ensure_future(
bot.notify_all(
lambda lang: bot.lang.get('battery_level_changed', lang,
emoji, bot.lang.get(f'bat_state_{state.name.lower()}', lang), v, load_watts)
)
)
@ -321,14 +381,18 @@ def monitor_util(event: ACPresentEvent):
else:
key = 'disconnected'
key = f'util_{key}'
bot.notify_all(
lambda lang: bot.lang.get(key, lang)
asyncio.ensure_future(
bot.notify_all(
lambda lang: bot.lang.get(key, lang)
)
)
def monitor_error(error: str) -> None:
bot.notify_all(
lambda lang: bot.lang.get('error_message', lang, error)
asyncio.ensure_future(
bot.notify_all(
lambda lang: bot.lang.get('error_message', lang, error)
)
)
@ -338,35 +402,37 @@ def osp_change_cb(new_osp: OutputSourcePriority,
setosp(new_osp)
bot.notify_all(
lambda lang: bot.lang.get('osp_auto_changed_notification', lang,
bot.lang.get(f'settings_osp_{new_osp.value.lower()}', lang), v, solar_input),
asyncio.ensure_future(
bot.notify_all(
lambda lang: bot.lang.get('osp_auto_changed_notification', lang,
bot.lang.get(f'settings_osp_{new_osp.value.lower()}', lang), v, solar_input),
)
)
@bot.handler(command='status')
def full_status(ctx: bot.Context) -> None:
async def full_status(ctx: bot.Context) -> None:
status = inverter.exec('get-status', format=Format.TABLE)
ctx.reply(beautify_table(status))
await ctx.reply(beautify_table(status))
@bot.handler(command='config')
def full_rated(ctx: bot.Context) -> None:
async def full_rated(ctx: bot.Context) -> None:
rated = inverter.exec('get-rated', format=Format.TABLE)
ctx.reply(beautify_table(rated))
await ctx.reply(beautify_table(rated))
@bot.handler(command='errors')
def full_errors(ctx: bot.Context) -> None:
async def full_errors(ctx: bot.Context) -> None:
errors = inverter.exec('get-errors', format=Format.TABLE)
ctx.reply(beautify_table(errors))
await ctx.reply(beautify_table(errors))
@bot.handler(command='flags')
def flags_handler(ctx: bot.Context) -> None:
async def flags_handler(ctx: bot.Context) -> None:
flags = inverter.exec('get-flags')['data']
text, markup = build_flags_keyboard(flags, ctx)
ctx.reply(text, markup=markup)
await ctx.reply(text, markup=markup)
def build_flags_keyboard(flags: dict, ctx: bot.Context) -> Tuple[str, InlineKeyboardMarkup]:
@ -423,11 +489,11 @@ class SettingsConversation(bot.conversation):
REDISCHARGE_VOLTAGES = [48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58]
@bot.conventer(START, message='settings')
def start_enter(self, ctx: bot.Context):
async def start_enter(self, ctx: bot.Context):
buttons = list(chunks(list(self.START_BUTTONS), 2))
buttons.reverse()
return self.reply(ctx, self.START, ctx.lang('settings_msg'), buttons,
with_cancel=True)
return await self.reply(ctx, self.START, ctx.lang('settings_msg'), buttons,
with_cancel=True)
@bot.convinput(START, messages={
'settings_osp': OSP,
@ -436,16 +502,16 @@ class SettingsConversation(bot.conversation):
'settings_bat_cut_off_voltage': BAT_CUT_OFF_VOLTAGE,
'settings_ac_max_charging_current': AC_MAX_CHARGING_CURRENT
})
def start_input(self, ctx: bot.Context):
async def start_input(self, ctx: bot.Context):
pass
@bot.conventer(OSP)
def osp_enter(self, ctx: bot.Context):
return self.reply(ctx, self.OSP, ctx.lang('settings_osp_msg'), self.OSP_BUTTONS,
with_back=True)
async def osp_enter(self, ctx: bot.Context):
return await self.reply(ctx, self.OSP, ctx.lang('settings_osp_msg'), self.OSP_BUTTONS,
with_back=True)
@bot.convinput(OSP, messages=OSP_BUTTONS)
def osp_input(self, ctx: bot.Context):
async def osp_input(self, ctx: bot.Context):
selected_sp = None
for sp in OutputSourcePriority:
if ctx.text == ctx.lang(f'settings_osp_{sp.value.lower()}'):
@ -458,25 +524,28 @@ class SettingsConversation(bot.conversation):
# apply the mode
setosp(selected_sp)
# reply to user
ctx.reply(ctx.lang('saved'), markup=bot.IgnoreMarkup())
await asyncio.gather(
# reply to user
ctx.reply(ctx.lang('saved'), markup=bot.IgnoreMarkup()),
# notify other users
bot.notify_all(
lambda lang: bot.lang.get('osp_changed_notification', lang,
ctx.user.id, ctx.user.name,
bot.lang.get(f'settings_osp_{selected_sp.value.lower()}', lang)),
exclude=(ctx.user_id,)
# notify other users
bot.notify_all(
lambda lang: bot.lang.get('osp_changed_notification', lang,
ctx.user.id, ctx.user.name,
bot.lang.get(f'settings_osp_{selected_sp.value.lower()}', lang)),
exclude=(ctx.user_id,)
)
)
return self.END
@bot.conventer(AC_PRESET)
def acpreset_enter(self, ctx: bot.Context):
return self.reply(ctx, self.AC_PRESET, ctx.lang('settings_ac_preset_msg'), self.AC_PRESET_BUTTONS,
with_back=True)
async def acpreset_enter(self, ctx: bot.Context):
return await self.reply(ctx, self.AC_PRESET, ctx.lang('settings_ac_preset_msg'), self.AC_PRESET_BUTTONS,
with_back=True)
@bot.convinput(AC_PRESET, messages=AC_PRESET_BUTTONS)
def acpreset_input(self, ctx: bot.Context):
async def acpreset_input(self, ctx: bot.Context):
if monitor.active_current is not None:
raise RuntimeError('generator charging program is active')
@ -493,85 +562,88 @@ class SettingsConversation(bot.conversation):
# save
bot.db.set_param('ac_mode', str(newmode.value))
# reply to user
ctx.reply(ctx.lang('saved'), markup=bot.IgnoreMarkup())
await asyncio.gather(
# reply to user
ctx.reply(ctx.lang('saved'), markup=bot.IgnoreMarkup()),
# notify other users
bot.notify_all(
lambda lang: bot.lang.get('ac_mode_changed_notification', lang,
ctx.user.id, ctx.user.name,
bot.lang.get(str(newmode.value), lang)),
exclude=(ctx.user_id,)
# notify other users
bot.notify_all(
lambda lang: bot.lang.get('ac_mode_changed_notification', lang,
ctx.user.id, ctx.user.name,
bot.lang.get(str(newmode.value), lang)),
exclude=(ctx.user_id,)
)
)
return self.END
@bot.conventer(BAT_THRESHOLDS_1)
def thresholds1_enter(self, ctx: bot.Context):
async def thresholds1_enter(self, ctx: bot.Context):
buttons = list(map(lambda v: f'{v} V', self.RECHARGE_VOLTAGES))
buttons = chunks(buttons, 4)
return self.reply(ctx, self.BAT_THRESHOLDS_1, ctx.lang('settings_select_bottom_threshold'), buttons,
with_back=True, buttons_lang_completed=True)
return await self.reply(ctx, self.BAT_THRESHOLDS_1, ctx.lang('settings_select_bottom_threshold'), buttons,
with_back=True, buttons_lang_completed=True)
@bot.convinput(BAT_THRESHOLDS_1,
messages=list(map(lambda n: f'{n} V', RECHARGE_VOLTAGES)),
messages_lang_completed=True)
def thresholds1_input(self, ctx: bot.Context):
async def thresholds1_input(self, ctx: bot.Context):
v = self._parse_voltage(ctx.text)
ctx.user_data['bat_thrsh_v1'] = v
return self.invoke(self.BAT_THRESHOLDS_2, ctx)
return await self.invoke(self.BAT_THRESHOLDS_2, ctx)
@bot.conventer(BAT_THRESHOLDS_2)
def thresholds2_enter(self, ctx: bot.Context):
async def thresholds2_enter(self, ctx: bot.Context):
buttons = list(map(lambda v: f'{v} V', self.REDISCHARGE_VOLTAGES))
buttons = chunks(buttons, 4)
return self.reply(ctx, self.BAT_THRESHOLDS_2, ctx.lang('settings_select_upper_threshold'), buttons,
with_back=True, buttons_lang_completed=True)
return await self.reply(ctx, self.BAT_THRESHOLDS_2, ctx.lang('settings_select_upper_threshold'), buttons,
with_back=True, buttons_lang_completed=True)
@bot.convinput(BAT_THRESHOLDS_2,
messages=list(map(lambda n: f'{n} V', REDISCHARGE_VOLTAGES)),
messages_lang_completed=True)
def thresholds2_input(self, ctx: bot.Context):
async def thresholds2_input(self, ctx: bot.Context):
v2 = v = self._parse_voltage(ctx.text)
v1 = ctx.user_data['bat_thrsh_v1']
del ctx.user_data['bat_thrsh_v1']
response = inverter.exec('set-charge-thresholds', (v1, v2))
ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR',
markup=bot.IgnoreMarkup())
await ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR',
markup=bot.IgnoreMarkup())
return self.END
@bot.conventer(AC_MAX_CHARGING_CURRENT)
def ac_max_enter(self, ctx: bot.Context):
async def ac_max_enter(self, ctx: bot.Context):
buttons = self._get_allowed_ac_charge_amps()
buttons = map(lambda n: f'{n} A', buttons)
buttons = [list(buttons)]
return self.reply(ctx, self.AC_MAX_CHARGING_CURRENT, ctx.lang('settings_select_max_current'), buttons,
with_back=True, buttons_lang_completed=True)
return await self.reply(ctx, self.AC_MAX_CHARGING_CURRENT, ctx.lang('settings_select_max_current'), buttons,
with_back=True, buttons_lang_completed=True)
@bot.convinput(AC_MAX_CHARGING_CURRENT, regex=r'^\d+ A$')
def ac_max_input(self, ctx: bot.Context):
async def ac_max_input(self, ctx: bot.Context):
a = self._parse_amps(ctx.text)
allowed = self._get_allowed_ac_charge_amps()
if a not in allowed:
raise ValueError('input is not allowed')
response = inverter.exec('set-max-ac-charge-current', (0, a))
ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR',
markup=bot.IgnoreMarkup())
await ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR',
markup=bot.IgnoreMarkup())
return self.END
@bot.conventer(BAT_CUT_OFF_VOLTAGE)
def cutoff_enter(self, ctx: bot.Context):
return self.reply(ctx, self.BAT_CUT_OFF_VOLTAGE, ctx.lang('settings_enter_cutoff_voltage'), None,
with_back=True)
async def cutoff_enter(self, ctx: bot.Context):
return await self.reply(ctx, self.BAT_CUT_OFF_VOLTAGE, ctx.lang('settings_enter_cutoff_voltage'), None,
with_back=True)
@bot.convinput(BAT_CUT_OFF_VOLTAGE, regex=r'^(\d{2}(\.\d{1})?)$')
def cutoff_input(self, ctx: bot.Context):
async def cutoff_input(self, ctx: bot.Context):
v = float(ctx.text)
if 40.0 <= v <= 48.0:
response = inverter.exec('set-battery-cutoff-voltage', (v,))
ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR',
markup=bot.IgnoreMarkup())
await ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR',
markup=bot.IgnoreMarkup())
else:
raise ValueError('invalid voltage')
@ -606,38 +678,38 @@ class ConsumptionConversation(bot.conversation):
INTERVAL_BUTTONS_FLAT = list(itertools.chain.from_iterable(INTERVAL_BUTTONS))
@bot.conventer(START, message='consumption')
def start_enter(self, ctx: bot.Context):
return self.reply(ctx, self.START, ctx.lang('consumption_msg'), [self.START_BUTTONS],
with_cancel=True)
async def start_enter(self, ctx: bot.Context):
return await self.reply(ctx, self.START, ctx.lang('consumption_msg'), [self.START_BUTTONS],
with_cancel=True)
@bot.convinput(START, messages={
'consumption_total': TOTAL,
'consumption_grid': GRID
})
def start_input(self, ctx: bot.Context):
async def start_input(self, ctx: bot.Context):
pass
@bot.conventer(TOTAL)
def total_enter(self, ctx: bot.Context):
return self._render_interval_btns(ctx, self.TOTAL)
async def total_enter(self, ctx: bot.Context):
return await self._render_interval_btns(ctx, self.TOTAL)
@bot.conventer(GRID)
def grid_enter(self, ctx: bot.Context):
return self._render_interval_btns(ctx, self.GRID)
async def grid_enter(self, ctx: bot.Context):
return await self._render_interval_btns(ctx, self.GRID)
def _render_interval_btns(self, ctx: bot.Context, state):
return self.reply(ctx, state, ctx.lang('consumption_select_interval'), self.INTERVAL_BUTTONS,
with_back=True)
async def _render_interval_btns(self, ctx: bot.Context, state):
return await self.reply(ctx, state, ctx.lang('consumption_select_interval'), self.INTERVAL_BUTTONS,
with_back=True)
@bot.convinput(TOTAL, messages=INTERVAL_BUTTONS_FLAT)
def total_input(self, ctx: bot.Context):
return self._render_interval_results(ctx, self.TOTAL)
async def total_input(self, ctx: bot.Context):
return await self._render_interval_results(ctx, self.TOTAL)
@bot.convinput(GRID, messages=INTERVAL_BUTTONS_FLAT)
def grid_input(self, ctx: bot.Context):
return self._render_interval_results(ctx, self.GRID)
async def grid_input(self, ctx: bot.Context):
return await self._render_interval_results(ctx, self.GRID)
def _render_interval_results(self, ctx: bot.Context, state):
async def _render_interval_results(self, ctx: bot.Context, state):
# if ctx.text == ctx.lang('to_select_interval'):
# TODO
# pass
@ -661,41 +733,43 @@ class ConsumptionConversation(bot.conversation):
# [InlineKeyboardButton(ctx.lang('please_wait'), callback_data='wait')]
# ])
message = ctx.reply(ctx.lang('consumption_request_sent'),
markup=bot.IgnoreMarkup())
message = await ctx.reply(ctx.lang('consumption_request_sent'),
markup=bot.IgnoreMarkup())
api = WebAPIClient(timeout=60)
api = WebApiClient(timeout=60)
method = 'inverter_get_consumed_energy' if state == self.TOTAL else 'inverter_get_grid_consumed_energy'
try:
wh = getattr(api, method)(s_from, s_to)
bot.delete_message(message.chat_id, message.message_id)
ctx.reply('%.2f Wh' % (wh,),
markup=bot.IgnoreMarkup())
await bot.delete_message(message.chat_id, message.message_id)
await ctx.reply('%.2f Wh' % (wh,),
markup=bot.IgnoreMarkup())
return self.END
except Exception as e:
bot.delete_message(message.chat_id, message.message_id)
ctx.reply_exc(e)
await asyncio.gather(
bot.delete_message(message.chat_id, message.message_id),
ctx.reply_exc(e)
)
# other
# -----
@bot.handler(command='monstatus')
def monstatus_handler(ctx: bot.Context) -> None:
async def monstatus_handler(ctx: bot.Context) -> None:
msg = ''
st = monitor.dump_status()
for k, v in st.items():
msg += k + ': ' + str(v) + '\n'
ctx.reply(msg)
await ctx.reply(msg)
@bot.handler(command='monsetcur')
def monsetcur_handler(ctx: bot.Context) -> None:
ctx.reply('not implemented yet')
async def monsetcur_handler(ctx: bot.Context) -> None:
await ctx.reply('not implemented yet')
@bot.callbackhandler
def button_callback(ctx: bot.Context) -> None:
async def button_callback(ctx: bot.Context) -> None:
query = ctx.callback_query
if query.data.startswith('flag_'):
@ -708,7 +782,7 @@ def button_callback(ctx: bot.Context) -> None:
json_key = k
break
if not found:
query.answer(ctx.lang('flags_invalid'))
await query.answer(ctx.lang('flags_invalid'))
return
flags = inverter.exec('get-flags')['data']
@ -719,32 +793,31 @@ def button_callback(ctx: bot.Context) -> None:
response = inverter.exec('set-flag', (flag, target_flag_value))
# notify user
query.answer(ctx.lang('done') if response['result'] == 'ok' else ctx.lang('flags_fail'))
await query.answer(ctx.lang('done') if response['result'] == 'ok' else ctx.lang('flags_fail'))
# edit message
flags[json_key] = not cur_flag_value
text, markup = build_flags_keyboard(flags, ctx)
query.edit_message_text(text, reply_markup=markup)
await query.edit_message_text(text, reply_markup=markup)
else:
query.answer(ctx.lang('unexpected_callback_data'))
await query.answer(ctx.lang('unexpected_callback_data'))
@bot.exceptionhandler
def exception_handler(e: Exception, ctx: bot.Context) -> Optional[bool]:
async def exception_handler(e: Exception, ctx: bot.Context) -> Optional[bool]:
if isinstance(e, InverterError):
try:
err = json.loads(str(e))['message']
except json.decoder.JSONDecodeError:
err = str(e)
err = re.sub(r'((?:.*)?error:) (.*)', r'<b>\1</b> \2', err)
ctx.reply(err,
markup=bot.IgnoreMarkup())
await ctx.reply(err, markup=bot.IgnoreMarkup())
return True
@bot.handler(message='status')
def status_handler(ctx: bot.Context) -> None:
async def status_handler(ctx: bot.Context) -> None:
gs = inverter.exec('get-status')['data']
rated = inverter.exec('get-rated')['data']
@ -788,11 +861,11 @@ def status_handler(ctx: bot.Context) -> None:
html += f'\n<b>{ctx.lang("priority")}</b>: {rated["output_source_priority"]}'
# send response
ctx.reply(html)
await ctx.reply(html)
@bot.handler(message='generation')
def generation_handler(ctx: bot.Context) -> None:
async def generation_handler(ctx: bot.Context) -> None:
today = datetime.date.today()
yday = today - datetime.timedelta(days=1)
yday2 = today - datetime.timedelta(days=2)
@ -822,7 +895,7 @@ def generation_handler(ctx: bot.Context) -> None:
html += f'\n<b>{ctx.lang("yday2")}:</b> %s Wh' % (gen_yday2['wh'])
# send response
ctx.reply(html)
await ctx.reply(html)
@bot.defaultreplymarkup
@ -863,28 +936,26 @@ class InverterStore(bot.BotDatabase):
self.commit()
if __name__ == '__main__':
inverter.init(host=config['inverter']['ip'], port=config['inverter']['port'])
inverter.init(host=config['inverter']['ip'], port=config['inverter']['port'])
bot.set_database(InverterStore())
bot.enable_logging(BotType.INVERTER)
bot.set_database(InverterStore())
bot.add_conversation(SettingsConversation(enable_back=True))
bot.add_conversation(ConsumptionConversation(enable_back=True))
bot.add_conversation(SettingsConversation(enable_back=True))
bot.add_conversation(ConsumptionConversation(enable_back=True))
monitor = InverterMonitor()
monitor.set_charging_event_handler(monitor_charging)
monitor.set_battery_event_handler(monitor_battery)
monitor.set_util_event_handler(monitor_util)
monitor.set_error_handler(monitor_error)
monitor.set_osp_need_change_callback(osp_change_cb)
monitor = InverterMonitor()
monitor.set_charging_event_handler(monitor_charging)
monitor.set_battery_event_handler(monitor_battery)
monitor.set_util_event_handler(monitor_util)
monitor.set_error_handler(monitor_error)
monitor.set_osp_need_change_callback(osp_change_cb)
setacmode(getacmode())
setacmode(getacmode())
if not config.get('monitor.disabled'):
logging.info('starting monitor')
monitor.start()
if not config.get('monitor.disabled'):
logging.info('starting monitor')
monitor.start()
bot.run()
bot.run()
monitor.stop()
monitor.stop()

27
bin/inverter_mqtt_util.py Executable file
View File

@ -0,0 +1,27 @@
#!/usr/bin/env python3
import __py_include
from argparse import ArgumentParser
from homekit.config import config
from homekit.mqtt import MqttWrapper, MqttNode
if __name__ == '__main__':
parser = ArgumentParser()
parser.add_argument('mode', type=str, choices=('sender', 'receiver'), nargs=1)
config.load_app('inverter_mqtt_util', parser=parser)
arg = parser.parse_args()
mode = arg.mode[0]
mqtt = MqttWrapper(client_id=f'inverter_mqtt_{mode}',
clean_session=mode != 'receiver')
node = MqttNode(node_id='inverter')
module_kwargs = {}
if mode == 'sender':
module_kwargs['status_poll_freq'] = int(config.app_config['poll_freq'])
module_kwargs['generation_poll_freq'] = int(config.app_config['generation_poll_freq'])
node.load_module('inverter', **module_kwargs)
mqtt.add_node(node)
mqtt.connect_and_loop()

View File

@ -1,7 +1,8 @@
#!/usr/bin/env python3
import logging
import __py_include
from home.inverter.emulator import InverterEmulator
from homekit.inverter.emulator import InverterEmulator
if __name__ == '__main__':

141
bin/ipcam_capture.py Executable file
View File

@ -0,0 +1,141 @@
#!/usr/bin/env python3
import __py_include
import sys
import os
import subprocess
import asyncio
import signal
from typing import TextIO
from argparse import ArgumentParser
from socket import gethostname
from asyncio.streams import StreamReader
from homekit.config import LinuxBoardsConfig, config as homekit_config
from homekit.camera import IpcamConfig, CaptureType
from homekit.camera.util import get_hls_directory, get_hls_channel_name, get_recordings_path
ipcam_config = IpcamConfig()
lbc_config = LinuxBoardsConfig()
channels = (1, 2)
tasks = []
restart_delay = 3
lock = asyncio.Lock()
worker_type: CaptureType
async def read_output(stream: StreamReader,
thread_name: str,
output: TextIO):
try:
while True:
line = await stream.readline()
if not line:
break
print(f"[{thread_name}] {line.decode().strip()}", file=output)
except asyncio.LimitOverrunError:
print(f"[{thread_name}] Output limit exceeded.", file=output)
except Exception as e:
print(f"[{thread_name}] Error occurred while reading output: {e}", file=sys.stderr)
async def run_ffmpeg(cam: int, channel: int):
prefix = get_hls_channel_name(cam, channel)
if homekit_config.app_config.logging_is_verbose():
debug_args = ['-v', '-info']
else:
debug_args = ['-nostats', '-loglevel', 'error']
protocol = 'tcp' if ipcam_config.should_use_tcp_for_rtsp(cam) else 'udp'
user, pw = ipcam_config.get_rtsp_creds()
ip = ipcam_config.get_camera_ip(cam)
path = ipcam_config.get_camera_type(cam).get_channel_url(channel)
ext = ipcam_config.get_camera_container(cam)
ffmpeg_command = ['ffmpeg', *debug_args,
'-rtsp_transport', protocol,
'-i', f'rtsp://{user}:{pw}@{ip}:554{path}',
'-c', 'copy',]
if worker_type == CaptureType.HLS:
ffmpeg_command.extend(['-bufsize', '1835k',
'-pix_fmt', 'yuv420p',
'-flags', '-global_header',
'-hls_time', '2',
'-hls_list_size', '3',
'-hls_flags', 'delete_segments',
os.path.join(get_hls_directory(cam, channel), 'live.m3u8')])
elif worker_type == CaptureType.RECORD:
ffmpeg_command.extend(['-f', 'segment',
'-strftime', '1',
'-segment_time', '00:10:00',
'-segment_atclocktime', '1',
os.path.join(get_recordings_path(cam), f'record_%Y-%m-%d-%H.%M.%S.{ext.value}')])
else:
raise ValueError(f'invalid worker type: {worker_type}')
while True:
try:
process = await asyncio.create_subprocess_exec(
*ffmpeg_command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
stdout_task = asyncio.create_task(read_output(process.stdout, prefix, sys.stdout))
stderr_task = asyncio.create_task(read_output(process.stderr, prefix, sys.stderr))
await asyncio.gather(stdout_task, stderr_task)
# check the return code of the process
if process.returncode != 0:
raise subprocess.CalledProcessError(process.returncode, ffmpeg_command)
except (FileNotFoundError, PermissionError, subprocess.CalledProcessError) as e:
# an error occurred, print the error message
error_message = f"Error occurred in {prefix}: {e}"
print(error_message, file=sys.stderr)
# sleep for 5 seconds before restarting the process
await asyncio.sleep(restart_delay)
async def run():
kwargs = {}
if worker_type == CaptureType.RECORD:
kwargs['filter_by_server'] = gethostname()
for cam in ipcam_config.get_all_cam_names(**kwargs):
for channel in channels:
task = asyncio.create_task(run_ffmpeg(cam, channel))
tasks.append(task)
try:
await asyncio.gather(*tasks)
except KeyboardInterrupt:
print('KeyboardInterrupt: stopping processes...', file=sys.stderr)
for task in tasks:
task.cancel()
# wait for subprocesses to terminate
await asyncio.gather(*tasks, return_exceptions=True)
# send termination signal to all subprocesses
for task in tasks:
process = task.get_stack()
if process:
process.send_signal(signal.SIGTERM)
if __name__ == '__main__':
capture_types = [t.value for t in CaptureType]
parser = ArgumentParser()
parser.add_argument('type', type=str, metavar='CAPTURE_TYPE', choices=tuple(capture_types),
help='capture type (variants: '+', '.join(capture_types)+')')
arg = homekit_config.load_app(no_config=True, parser=parser)
worker_type = CaptureType(arg['type'])
asyncio.run(run())

View File

@ -5,7 +5,7 @@ set -e
DIR="$( cd "$( dirname "$(realpath "${BASH_SOURCE[0]}")" )" &>/dev/null && pwd )"
PROGNAME="$0"
. "$DIR/lib.bash"
. "$DIR/../include/bash/include.bash"
curl_opts="-s --connect-timeout 10 --retry 5 --max-time 180 --retry-delay 0 --retry-max-time 180"
allow_multiple=

View File

@ -1,58 +1,52 @@
#!/usr/bin/env python3
import logging
import os
import re
import asyncio
import time
import shutil
import home.telegram.aio as telegram
import __py_include
import homekit.telegram.aio as telegram
from socket import gethostname
from argparse import ArgumentParser
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from asyncio import Lock
from home.config import config
from home import http
from home.database.sqlite import SQLiteBase
from home.camera import util as camutil
from homekit.config import config as homekit_config, LinuxBoardsConfig
from homekit.util import Addr
from homekit import http
from homekit.database.sqlite import SQLiteBase
from homekit.camera import util as camutil, IpcamConfig
from homekit.camera.types import (
TimeFilterType,
TelegramLinkType,
VideoContainerType
)
from homekit.camera.util import (
get_recordings_path,
get_motion_path,
is_valid_recording_name,
datetime_from_filename
)
from enum import Enum
from typing import Optional, Union, List, Tuple
from datetime import datetime, timedelta
from functools import cmp_to_key
class TimeFilterType(Enum):
FIX = 'fix'
MOTION = 'motion'
MOTION_START = 'motion_start'
class TelegramLinkType(Enum):
FRAGMENT = 'fragment'
ORIGINAL_FILE = 'original_file'
def valid_recording_name(filename: str) -> bool:
return filename.startswith('record_') and filename.endswith('.mp4')
def filename_to_datetime(filename: str) -> datetime:
filename = os.path.basename(filename).replace('record_', '').replace('.mp4', '')
return datetime.strptime(filename, datetime_format)
def get_all_cams() -> list:
return [cam for cam in config['camera'].keys()]
ipcam_config = IpcamConfig()
lbc_config = LinuxBoardsConfig()
# ipcam database
# --------------
class IPCamServerDatabase(SQLiteBase):
class IpcamServerDatabase(SQLiteBase):
SCHEMA = 4
def __init__(self):
super().__init__()
def __init__(self, path=None):
super().__init__(path=path)
def schema_init(self, version: int) -> None:
cursor = self.cursor()
@ -64,7 +58,7 @@ class IPCamServerDatabase(SQLiteBase):
fix_time INTEGER NOT NULL,
motion_time INTEGER NOT NULL
)""")
for cam in config['camera'].keys():
for cam in ipcam_config.get_all_cam_names_for_this_server():
self.add_camera(cam)
if version < 2:
@ -132,7 +126,7 @@ class IPCamServerDatabase(SQLiteBase):
# ipcam web api
# -------------
class IPCamWebServer(http.HTTPServer):
class IpcamWebServer(http.HTTPServer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -143,16 +137,16 @@ class IPCamWebServer(http.HTTPServer):
self.get('/api/timestamp/{name}/{type}', self.get_timestamp)
self.get('/api/timestamp/all', self.get_all_timestamps)
self.post('/api/debug/migrate-mtimes', self.debug_migrate_mtimes)
self.post('/api/debug/fix', self.debug_fix)
self.post('/api/debug/cleanup', self.debug_cleanup)
self.post('/api/timestamp/{name}/{type}', self.set_timestamp)
self.post('/api/motion/done/{name}', self.submit_motion)
self.post('/api/motion/fail/{name}', self.submit_motion_failure)
self.get('/api/motion/params/{name}', self.get_motion_params)
self.get('/api/motion/params/{name}/roi', self.get_motion_roi_params)
# self.get('/api/motion/params/{name}', self.get_motion_params)
# self.get('/api/motion/params/{name}/roi', self.get_motion_roi_params)
self.queue_lock = Lock()
@ -170,7 +164,7 @@ class IPCamWebServer(http.HTTPServer):
files = get_recordings_files(camera, filter, limit)
if files:
time = filename_to_datetime(files[len(files)-1]['name'])
time = datetime_from_filename(files[len(files)-1]['name'])
db.set_timestamp(camera, TimeFilterType.MOTION_START, time)
return self.ok({'files': files})
@ -185,7 +179,7 @@ class IPCamWebServer(http.HTTPServer):
if files:
times_by_cam = {}
for file in files:
time = filename_to_datetime(file['name'])
time = datetime_from_filename(file['name'])
if file['cam'] not in times_by_cam or times_by_cam[file['cam']] < time:
times_by_cam[file['cam']] = time
for cam, time in times_by_cam.items():
@ -197,14 +191,14 @@ class IPCamWebServer(http.HTTPServer):
cam = int(req.match_info['name'])
file = req.match_info['file']
fullpath = os.path.join(config['camera'][cam]['recordings_path'], file)
fullpath = os.path.join(get_recordings_path(cam), file)
if not os.path.isfile(fullpath):
raise ValueError(f'file "{fullpath}" does not exists')
return http.FileResponse(fullpath)
async def camlist(self, req: http.Request):
return self.ok(config['camera'])
return self.ok(ipcam_config.get_all_cam_names_for_this_server())
async def submit_motion(self, req: http.Request):
data = await req.post()
@ -213,7 +207,7 @@ class IPCamWebServer(http.HTTPServer):
timecodes = data['timecodes']
filename = data['filename']
time = filename_to_datetime(filename)
time = datetime_from_filename(filename)
try:
if timecodes != '':
@ -236,27 +230,10 @@ class IPCamWebServer(http.HTTPServer):
message = data['message']
db.add_motion_failure(camera, filename, message)
db.set_timestamp(camera, TimeFilterType.MOTION, filename_to_datetime(filename))
db.set_timestamp(camera, TimeFilterType.MOTION, datetime_from_filename(filename))
return self.ok()
async def debug_migrate_mtimes(self, req: http.Request):
written = {}
for cam in config['camera'].keys():
confdir = os.path.join(os.getenv('HOME'), '.config', f'video-util-{cam}')
for time_type in TimeFilterType:
txt_file = os.path.join(confdir, f'{time_type.value}_mtime')
if os.path.isfile(txt_file):
with open(txt_file, 'r') as fd:
data = fd.read()
db.set_timestamp(cam, time_type, int(data.strip()))
if cam not in written:
written[cam] = []
written[cam].append(time_type)
return self.ok({'written': written})
async def debug_fix(self, req: http.Request):
asyncio.ensure_future(fix_job())
return self.ok()
@ -277,26 +254,26 @@ class IPCamWebServer(http.HTTPServer):
async def get_all_timestamps(self, req: http.Request):
return self.ok(db.get_all_timestamps())
async def get_motion_params(self, req: http.Request):
data = config['motion_params'][int(req.match_info['name'])]
lines = [
f'threshold={data["threshold"]}',
f'min_event_length=3s',
f'frame_skip=2',
f'downscale_factor=3',
]
return self.plain('\n'.join(lines)+'\n')
async def get_motion_roi_params(self, req: http.Request):
data = config['motion_params'][int(req.match_info['name'])]
return self.plain('\n'.join(data['roi'])+'\n')
# async def get_motion_params(self, req: http.Request):
# data = config['motion_params'][int(req.match_info['name'])]
# lines = [
# f'threshold={data["threshold"]}',
# f'min_event_length=3s',
# f'frame_skip=2',
# f'downscale_factor=3',
# ]
# return self.plain('\n'.join(lines)+'\n')
#
# async def get_motion_roi_params(self, req: http.Request):
# data = config['motion_params'][int(req.match_info['name'])]
# return self.plain('\n'.join(data['roi'])+'\n')
@staticmethod
def _getset_timestamp_params(req: http.Request, need_time=False):
values = []
cam = int(req.match_info['name'])
assert cam in config['camera'], 'invalid camera'
assert cam in ipcam_config.get_all_cam_names_for_this_server(), 'invalid camera'
values.append(cam)
values.append(TimeFilterType(req.match_info['type']))
@ -304,7 +281,7 @@ class IPCamWebServer(http.HTTPServer):
if need_time:
time = req.query['time']
if time.startswith('record_'):
time = filename_to_datetime(time)
time = datetime_from_filename(time)
elif time.isnumeric():
time = int(time)
else:
@ -317,32 +294,24 @@ class IPCamWebServer(http.HTTPServer):
# other global stuff
# ------------------
def open_database():
def open_database(database_path: str):
global db
db = IPCamServerDatabase()
db = IpcamServerDatabase(database_path)
# update cams list in database, if needed
cams = db.get_all_timestamps().keys()
for cam in config['camera']:
if cam not in cams:
stored_cams = db.get_all_timestamps().keys()
for cam in ipcam_config.get_all_cam_names_for_this_server():
if cam not in stored_cams:
db.add_camera(cam)
def get_recordings_path(cam: int) -> str:
return config['camera'][cam]['recordings_path']
def get_motion_path(cam: int) -> str:
return config['camera'][cam]['motion_path']
def get_recordings_files(cam: Optional[int] = None,
time_filter_type: Optional[TimeFilterType] = None,
limit=0) -> List[dict]:
from_time = 0
to_time = int(time.time())
cams = [cam] if cam is not None else get_all_cams()
cams = [cam] if cam is not None else ipcam_config.get_all_cam_names_for_this_server()
files = []
for cam in cams:
if time_filter_type:
@ -359,7 +328,7 @@ def get_recordings_files(cam: Optional[int] = None,
'name': file,
'size': os.path.getsize(os.path.join(recdir, file))}
for file in os.listdir(recdir)
if valid_recording_name(file) and from_time < filename_to_datetime(file) <= to_time]
if is_valid_recording_name(file) and from_time < datetime_from_filename(file) <= to_time]
cam_files.sort(key=lambda file: file['name'])
if cam_files:
@ -379,7 +348,7 @@ def get_recordings_files(cam: Optional[int] = None,
async def process_fragments(camera: int,
filename: str,
fragments: List[Tuple[int, int]]) -> None:
time = filename_to_datetime(filename)
time = datetime_from_filename(filename)
rec_dir = get_recordings_path(camera)
motion_dir = get_motion_path(camera)
@ -389,8 +358,8 @@ async def process_fragments(camera: int,
for fragment in fragments:
start, end = fragment
start -= config['motion']['padding']
end += config['motion']['padding']
start -= ipcam_config['motion_padding']
end += ipcam_config['motion_padding']
if start < 0:
start = 0
@ -405,14 +374,14 @@ async def process_fragments(camera: int,
start_pos=start,
duration=duration)
if fragments and 'telegram' in config['motion'] and config['motion']['telegram']:
if fragments and ipcam_config['motion_telegram']:
asyncio.ensure_future(motion_notify_tg(camera, filename, fragments))
async def motion_notify_tg(camera: int,
filename: str,
fragments: List[Tuple[int, int]]):
dt_file = filename_to_datetime(filename)
dt_file = datetime_from_filename(filename)
fmt = '%H:%M:%S'
text = f'Camera: <b>{camera}</b>\n'
@ -420,8 +389,8 @@ async def motion_notify_tg(camera: int,
text += _tg_links(TelegramLinkType.ORIGINAL_FILE, camera, filename)
for start, end in fragments:
start -= config['motion']['padding']
end += config['motion']['padding']
start -= ipcam_config['motion_padding']
end += ipcam_config['motion_padding']
if start < 0:
start = 0
@ -443,7 +412,7 @@ def _tg_links(link_type: TelegramLinkType,
camera: int,
file: str) -> str:
links = []
for link_name, link_template in config['telegram'][f'{link_type.value}_url_templates']:
for link_name, link_template in ipcam_config[f'{link_type.value}_url_templates']:
link = link_template.replace('{camera}', str(camera)).replace('{file}', file)
links.append(f'<a href="{link}">{link_name}</a>')
return ' '.join(links)
@ -459,7 +428,7 @@ async def fix_job() -> None:
try:
fix_job_running = True
for cam in config['camera'].keys():
for cam in ipcam_config.get_all_cam_names_for_this_server():
files = get_recordings_files(cam, TimeFilterType.FIX)
if not files:
logger.debug(f'fix_job: no files for camera {cam}')
@ -470,7 +439,7 @@ async def fix_job() -> None:
for file in files:
fullpath = os.path.join(get_recordings_path(cam), file['name'])
await camutil.ffmpeg_recreate(fullpath)
timestamp = filename_to_datetime(file['name'])
timestamp = datetime_from_filename(file['name'])
if timestamp:
db.set_timestamp(cam, TimeFilterType.FIX, timestamp)
@ -479,21 +448,9 @@ async def fix_job() -> None:
async def cleanup_job() -> None:
def fn2dt(name: str) -> datetime:
name = os.path.basename(name)
if name.startswith('record_'):
return datetime.strptime(re.match(r'record_(.*?)\.mp4', name).group(1), datetime_format)
m = re.match(rf'({datetime_format_re})__{datetime_format_re}\.mp4', name)
if m:
return datetime.strptime(m.group(1), datetime_format)
raise ValueError(f'unrecognized filename format: {name}')
def compare(i1: str, i2: str) -> int:
dt1 = fn2dt(i1)
dt2 = fn2dt(i2)
dt1 = datetime_from_filename(i1)
dt2 = datetime_from_filename(i2)
if dt1 < dt2:
return -1
@ -513,18 +470,19 @@ async def cleanup_job() -> None:
cleanup_job_running = True
gb = float(1 << 30)
for storage in config['storages']:
disk_number = 0
for storage in lbc_config.get_board_disks(gethostname()):
disk_number += 1
if os.path.exists(storage['mountpoint']):
total, used, free = shutil.disk_usage(storage['mountpoint'])
free_gb = free // gb
if free_gb < config['cleanup_min_gb']:
# print(f"{storage['mountpoint']}: free={free}, free_gb={free_gb}")
if free_gb < ipcam_config['cleanup_min_gb']:
cleaned = 0
files = []
for cam in storage['cams']:
for _dir in (config['camera'][cam]['recordings_path'], config['camera'][cam]['motion_path']):
for cam in ipcam_config.get_all_cam_names_for_this_server(filter_by_disk=disk_number):
for _dir in (get_recordings_path(cam), get_motion_path(cam)):
files += list(map(lambda file: os.path.join(_dir, file), os.listdir(_dir)))
files = list(filter(lambda path: os.path.isfile(path) and path.endswith('.mp4'), files))
files = list(filter(lambda path: os.path.isfile(path) and path.endswith(tuple([f'.{t.value}' for t in VideoContainerType])), files))
files.sort(key=cmp_to_key(compare))
for file in files:
@ -534,7 +492,7 @@ async def cleanup_job() -> None:
cleaned += size
except OSError as e:
logger.exception(e)
if (free + cleaned) // gb >= config['cleanup_min_gb']:
if (free + cleaned) // gb >= ipcam_config['cleanup_min_gb']:
break
else:
logger.error(f"cleanup_job: {storage['mountpoint']} not found")
@ -547,8 +505,8 @@ cleanup_job_running = False
datetime_format = '%Y-%m-%d-%H.%M.%S'
datetime_format_re = r'\d{4}-\d{2}-\d{2}-\d{2}\.\d{2}.\d{2}'
db: Optional[IPCamServerDatabase] = None
server: Optional[IPCamWebServer] = None
db: Optional[IpcamServerDatabase] = None
server: Optional[IpcamWebServer] = None
logger = logging.getLogger(__name__)
@ -556,18 +514,25 @@ logger = logging.getLogger(__name__)
# --------------------
if __name__ == '__main__':
config.load('ipcam_server')
parser = ArgumentParser()
parser.add_argument('--listen', type=str, required=True)
parser.add_argument('--database-path', type=str, required=True)
arg = homekit_config.load_app(no_config=True, parser=parser)
open_database()
open_database(arg.database_path)
loop = asyncio.get_event_loop()
try:
scheduler = AsyncIOScheduler(event_loop=loop)
if config['fix_enabled']:
scheduler.add_job(fix_job, 'interval', seconds=config['fix_interval'], misfire_grace_time=None)
if ipcam_config['fix_enabled']:
scheduler.add_job(fix_job, 'interval',
seconds=ipcam_config['fix_interval'],
misfire_grace_time=None)
scheduler.add_job(cleanup_job, 'interval', seconds=config['cleanup_interval'], misfire_grace_time=None)
scheduler.add_job(cleanup_job, 'interval',
seconds=ipcam_config['cleanup_interval'],
misfire_grace_time=None)
scheduler.start()
except KeyError:
pass
@ -575,5 +540,5 @@ if __name__ == '__main__':
asyncio.ensure_future(fix_job())
asyncio.ensure_future(cleanup_job())
server = IPCamWebServer(config.get_addr('server.listen'))
server = IpcamWebServer(Addr.fromstring(arg.listen))
server.run()

207
bin/lugovaya_pump_mqtt_bot.py Executable file
View File

@ -0,0 +1,207 @@
#!/usr/bin/env python3
import datetime
import __py_include
from enum import Enum
from typing import Optional
from telegram import ReplyKeyboardMarkup, User
from homekit.config import config, AppConfigUnit
from homekit.telegram import bot
from homekit.telegram.config import TelegramBotConfig
from homekit.telegram._botutil import user_any_name
from homekit.mqtt import MqttNode, MqttPayload, MqttNodesConfig, MqttWrapper
from homekit.mqtt.module.relay import MqttRelayState, MqttRelayModule
from homekit.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload
class LugovayaPumpMqttBotConfig(TelegramBotConfig, AppConfigUnit):
NAME = 'lugovaya_pump_mqtt_bot'
@classmethod
def schema(cls) -> Optional[dict]:
return {
**TelegramBotConfig.schema(),
'relay_node_id': {
'type': 'string',
'required': True
},
}
@staticmethod
def custom_validator(data):
relay_node_names = MqttNodesConfig().get_nodes(filters=('relay',), only_names=True)
if data['relay_node_id'] not in relay_node_names:
raise ValueError('unknown relay node "%s"' % (data['relay_node_id'],))
config.load_app(LugovayaPumpMqttBotConfig)
bot.initialize()
bot.lang.ru(
start_message="Выберите команду на клавиатуре",
start_message_no_access="Доступ запрещён. Вы можете отправить заявку на получение доступа.",
unknown_command="Неизвестная команда",
send_access_request="Отправить заявку",
management="Админка",
enable="Включить",
enabled="Включен ✅",
disable="Выключить",
disabled="Выключен ❌",
status="Статус",
status_updated=' (обновлено %s)',
done="Готово 👌",
user_action_notification='Пользователь <a href="tg://user?id=%d">%s</a> <b>%s</b> насос.',
user_action_on="включил",
user_action_off="выключил",
date_yday="вчера",
date_yyday="позавчера",
date_at="в"
)
bot.lang.en(
start_message="Select command on the keyboard",
start_message_no_access="You have no access.",
unknown_command="Unknown command",
send_access_request="Send request",
management="Admin options",
enable="Turn ON",
enable_silently="Turn ON silently",
enabled="Turned ON ✅",
disable="Turn OFF",
disable_silently="Turn OFF silently",
disabled="Turned OFF ❌",
status="Status",
status_updated=' (updated %s)',
done="Done 👌",
user_action_notification='User <a href="tg://user?id=%d">%s</a> turned the pump <b>%s</b>.',
user_action_on="ON",
user_action_off="OFF",
date_yday="yesterday",
date_yyday="the day before yesterday",
date_at="at"
)
mqtt: MqttWrapper
relay_state = MqttRelayState()
relay_module: MqttRelayModule
class UserAction(Enum):
ON = 'on'
OFF = 'off'
# def on_mqtt_message(home_id, message: MqttPayload):
# if isinstance(message, InitialDiagnosticsPayload) or isinstance(message, DiagnosticsPayload):
# kwargs = dict(rssi=message.rssi, enabled=message.flags.state)
# if isinstance(message, InitialDiagnosticsPayload):
# kwargs['fw_version'] = message.fw_version
# relay_state.update(**kwargs)
async def notify(user: User, action: UserAction) -> None:
def text_getter(lang: str):
action_name = bot.lang.get(f'user_action_{action.value}', lang)
user_name = user_any_name(user)
return ' ' + bot.lang.get('user_action_notification', lang,
user.id, user_name, action_name)
await bot.notify_all(text_getter, exclude=(user.id,))
@bot.handler(message='enable')
async def enable_handler(ctx: bot.Context) -> None:
relay_module.switchpower(True)
await ctx.reply(ctx.lang('done'))
await notify(ctx.user, UserAction.ON)
@bot.handler(message='disable')
async def disable_handler(ctx: bot.Context) -> None:
relay_module.switchpower(False)
await ctx.reply(ctx.lang('done'))
await notify(ctx.user, UserAction.OFF)
@bot.handler(message='status')
async def status(ctx: bot.Context) -> None:
label = ctx.lang('enabled') if relay_state.enabled else ctx.lang('disabled')
if relay_state.ever_updated:
date_label = ''
today = datetime.date.today()
if today != relay_state.update_time.date():
yday = today - datetime.timedelta(days=1)
yyday = today - datetime.timedelta(days=2)
if yday == relay_state.update_time.date():
date_label = ctx.lang('date_yday')
elif yyday == relay_state.update_time.date():
date_label = ctx.lang('date_yyday')
else:
date_label = relay_state.update_time.strftime('%d.%m.%Y')
date_label += ' '
date_label += ctx.lang('date_at') + ' '
date_label += relay_state.update_time.strftime('%H:%M')
label += ctx.lang('status_updated', date_label)
await ctx.reply(label)
async def start(ctx: bot.Context) -> None:
if ctx.user_id in config['bot']['users']:
await ctx.reply(ctx.lang('start_message'))
else:
buttons = [
[ctx.lang('send_access_request')]
]
await ctx.reply(ctx.lang('start_message_no_access'),
markup=ReplyKeyboardMarkup(buttons, one_time_keyboard=False))
@bot.exceptionhandler
def exception_handler(e: Exception, ctx: bot.Context) -> bool:
return False
@bot.defaultreplymarkup
def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]:
buttons = [
[
ctx.lang('enable'),
ctx.lang('disable')
],
# [ctx.lang('status')]
]
# if ctx.user_id in config['bot']['admin_users']:
# buttons.append([ctx.lang('management')])
return ReplyKeyboardMarkup(buttons, one_time_keyboard=False)
node_data = MqttNodesConfig().get_node(config.app_config['relay_node_id'])
mqtt = MqttWrapper(client_id='lugovaya_pump_mqtt_bot')
mqtt_node = MqttNode(node_id=config.app_config['relay_node_id'],
node_secret=node_data['password'])
module_kwargs = {}
try:
if node_data['relay']['legacy_topics']:
module_kwargs['legacy_topics'] = True
except KeyError:
pass
relay_module = mqtt_node.load_module('relay', **module_kwargs)
# mqtt_node.add_payload_callback(on_mqtt_message)
mqtt.add_node(mqtt_node)
mqtt.connect_and_loop(loop_forever=False)
bot.run(start_handler=start)
mqtt.disconnect()

68
bin/mqtt_node_util.py Executable file
View File

@ -0,0 +1,68 @@
#!/usr/bin/env python3
import os.path
import __py_include
from time import sleep
from typing import Optional
from argparse import ArgumentParser, ArgumentError
from homekit.config import config
from homekit.mqtt import MqttNode, MqttWrapper, get_mqtt_modules
from homekit.mqtt import MqttNodesConfig
mqtt_node: Optional[MqttNode] = None
mqtt: Optional[MqttWrapper] = None
if __name__ == '__main__':
nodes_config = MqttNodesConfig()
parser = ArgumentParser()
parser.add_argument('--node-id', type=str, required=True, choices=nodes_config.get_nodes(only_names=True))
parser.add_argument('--modules', type=str, choices=get_mqtt_modules(), nargs='*',
help='mqtt modules to include')
parser.add_argument('--switch-relay', choices=[0, 1], type=int,
help='send relay state')
parser.add_argument('--legacy-relay', action='store_true')
parser.add_argument('--push-ota', type=str, metavar='OTA_FILENAME',
help='push OTA, receives path to firmware.bin')
config.load_app(parser=parser, no_config=True)
arg = parser.parse_args()
if arg.switch_relay is not None and 'relay' not in arg.modules:
raise ArgumentError(None, '--relay is only allowed when \'relay\' module included in --modules')
mqtt = MqttWrapper(randomize_client_id=True,
client_id='mqtt_node_util')
mqtt_node = MqttNode(node_id=arg.node_id,
node_secret=nodes_config.get_node(arg.node_id)['password'])
mqtt.add_node(mqtt_node)
# must-have modules
ota_module = mqtt_node.load_module('ota')
mqtt_node.load_module('diagnostics')
if arg.modules:
for m in arg.modules:
kwargs = {}
if m == 'relay' and arg.legacy_relay:
kwargs['legacy_topics'] = True
module_instance = mqtt_node.load_module(m, **kwargs)
if m == 'relay' and arg.switch_relay is not None:
module_instance.switchpower(arg.switch_relay == 1)
try:
mqtt.connect_and_loop(loop_forever=False)
if arg.push_ota:
if not os.path.exists(arg.push_ota):
raise OSError(f'--push-ota: file \"{arg.push_ota}\" does not exists')
ota_module.push_ota(arg.push_ota, 1)
while True:
sleep(0.1)
except KeyboardInterrupt:
mqtt.disconnect()

79
bin/openwrt_log_analyzer.py Executable file
View File

@ -0,0 +1,79 @@
#!/usr/bin/env python3
import __py_include
import homekit.telegram as telegram
from homekit.telegram.config import TelegramChatsConfig
from homekit.util import validate_mac_address
from typing import Optional
from homekit.config import config, AppConfigUnit
from homekit.database import BotsDatabase, SimpleState
class OpenwrtLogAnalyzerConfig(AppConfigUnit):
@classmethod
def schema(cls) -> Optional[dict]:
return {
'database_name': {'type': 'string', 'required': True},
'devices': {
'type': 'dict',
'keysrules': {'type': 'string'},
'valuesrules': {
'type': 'string',
'check_with': validate_mac_address
}
},
'limit': {'type': 'integer'},
'telegram_chat': {'type': 'string'},
'aps': {
'type': 'list',
'schema': {'type': 'integer'}
}
}
@staticmethod
def custom_validator(data):
chats = TelegramChatsConfig()
if data['telegram_chat'] not in chats:
return ValueError(f'unknown telegram chat {data["telegram_chat"]}')
def main(mac: str,
title: str,
ap: int) -> int:
db = BotsDatabase()
data = db.get_openwrt_logs(filter_text=mac,
min_id=state['last_id'],
access_point=ap,
limit=config['openwrt_log_analyzer']['limit'])
if not data:
return 0
max_id = 0
for log in data:
if log.id > max_id:
max_id = log.id
text = '\n'.join(map(lambda s: str(s), data))
telegram.send_message(f'<b>{title} (AP #{ap})</b>\n\n' + text, config.app_config['telegram_chat'])
return max_id
if __name__ == '__main__':
config.load_app(OpenwrtLogAnalyzerConfig)
for ap in config.app_config['aps']:
dbname = config.app_config['database_name']
dbname = dbname.replace('.txt', f'-{ap}.txt')
state = SimpleState(name=dbname,
default={'last_id': 0})
max_last_id = 0
for name, mac in config['devices'].items():
last_id = main(mac, title=name, ap=ap)
if last_id > max_last_id:
max_last_id = last_id
if max_last_id:
state['last_id'] = max_last_id

View File

@ -1,30 +1,21 @@
#!/usr/bin/env python3
import os
import __py_include
from datetime import datetime
from typing import Tuple, List
from typing import Tuple, List, Optional
from argparse import ArgumentParser
from home.config import config
from home.database import SimpleState
from home.api import WebAPIClient
from homekit.config import config, AppConfigUnit
from homekit.database import SimpleState
from homekit.api import WebApiClient
f"""
This script is supposed to be run by cron every 5 minutes or so.
It looks for new lines in log file and sends them to remote server.
OpenWRT must have remote logging enabled (UDP; IP of host this script is launched on; port 514)
/etc/rsyslog.conf contains following (assuming 192.168.1.1 is the router IP):
$ModLoad imudp
$UDPServerRun 514
:fromhost-ip, isequal, "192.168.1.1" /var/log/openwrt.log
& ~
Also comment out the following line:
$ActionFileDefaultTemplate RSYSLOG_TraditionalFileFormat
"""
class OpenwrtLoggerConfig(AppConfigUnit):
@classmethod
def schema(cls) -> Optional[dict]:
return dict(
database_name_template=dict(type='string', required=True)
)
def parse_line(line: str) -> Tuple[int, str]:
@ -46,11 +37,10 @@ if __name__ == '__main__':
parser.add_argument('--access-point', type=int, required=True,
help='access point number')
arg = config.load('openwrt_logger', parser=parser)
state = SimpleState(file=config['simple_state']['file'].replace('{ap}', str(arg.access_point)),
default={'seek': 0, 'size': 0})
arg = config.load_app(OpenwrtLoggerConfig, parser=parser)
state = SimpleState(name=config.app_config['database_name_template'].replace('{ap}', str(arg.access_point)),
default=dict(seek=0, size=0))
fsize = os.path.getsize(arg.file)
if fsize < state['size']:
state['seek'] = 0
@ -79,5 +69,5 @@ if __name__ == '__main__':
except ValueError:
lines.append((0, line))
api = WebAPIClient()
api = WebApiClient()
api.log_openwrt(lines, arg.access_point)

View File

@ -1,4 +1,5 @@
#!/usr/bin/env python3
import __py_include
if __name__ == '__main__':
print('TODO')

View File

@ -2,22 +2,24 @@
import os
import yaml
import re
import __py_include
from pprint import pprint
from argparse import ArgumentParser, ArgumentError
from home.pio import get_products, platformio_ini
from home.pio.exceptions import ProductConfigNotFoundError
from homekit.pio import get_products, platformio_ini
from homekit.pio.exceptions import ProductConfigNotFoundError
from homekit.config import CONFIG_DIRECTORIES
def get_config(product: str) -> dict:
config_path = os.path.join(
os.getenv('HOME'), '.config',
'homekit_pio', f'{product}.yaml'
)
if not os.path.exists(config_path):
raise ProductConfigNotFoundError(f'{config_path}: product config not found')
with open(config_path, 'r') as f:
path = None
for directory in CONFIG_DIRECTORIES:
config_path = os.path.join(directory, 'pio', f'{product}.yaml')
if os.path.exists(config_path) and os.path.isfile(config_path):
path = config_path
break
if not path:
raise ProductConfigNotFoundError(f'pio/{product}.yaml not found')
with open(path, 'r') as f:
return yaml.safe_load(f)
@ -54,12 +56,17 @@ def bsd_parser(product_config: dict,
arg_kwargs['type'] = int
elif kwargs['type'] == 'int':
arg_kwargs['type'] = int
elif kwargs['type'] == 'bool':
arg_kwargs['action'] = 'store_true'
arg_kwargs['required'] = False
else:
raise TypeError(f'unsupported type {kwargs["type"]} for define {define_name}')
else:
arg_kwargs['action'] = 'store_true'
parser.add_argument(f'--{define_name}', required=True, **arg_kwargs)
if 'required' not in arg_kwargs:
arg_kwargs['required'] = True
parser.add_argument(f'--{define_name}', **arg_kwargs)
bsd_walk(product_config, f)
@ -76,6 +83,10 @@ def bsd_get(product_config: dict,
enums.append(f'CONFIG_{define_name}')
defines[f'CONFIG_{define_name}'] = f'HOMEKIT_{attr_value.upper()}'
return
if kwargs['type'] == 'bool':
if attr_value is True:
defines[f'CONFIG_{define_name}'] = True
return
defines[f'CONFIG_{define_name}'] = str(attr_value)
bsd_walk(product_config, f)
return defines, enums
@ -98,7 +109,7 @@ if __name__ == '__main__':
product_config = get_config(product)
# then everythingm else
# then everything else
parser = ArgumentParser(parents=[product_parser])
parser.add_argument('--target', type=str, required=True, choices=product_config['targets'],
help='PIO build target')
@ -115,6 +126,7 @@ if __name__ == '__main__':
raise ArgumentError(None, f'target {arg.target} not found for product {product}')
bsd, bsd_enums = bsd_get(product_config, arg)
ini = platformio_ini(product_config=product_config,
target=arg.target,
build_specific_defines=bsd,

View File

@ -1,6 +1,7 @@
#!/usr/bin/env python3
from __future__ import annotations
import __py_include
import logging
import locale
import queue
@ -8,11 +9,10 @@ import time
import threading
import paho.mqtt.client as mqtt
from home.telegram import bot
from home.api.types import BotType
from home.mqtt import MqttBase
from home.config import config
from home.util import chunks
from homekit.telegram import bot
from homekit.mqtt import Mqtt
from homekit.config import config
from homekit.util import chunks
from syncleo import (
Kettle,
PowerType,
@ -41,7 +41,7 @@ from telegram.ext import (
)
logger = logging.getLogger(__name__)
config.load('polaris_kettle_bot')
config.load_app('polaris_kettle_bot')
primary_choices = (70, 80, 90, 100)
all_choices = range(
@ -204,7 +204,7 @@ class KettleInfo:
class KettleController(threading.Thread,
MqttBase,
Mqtt,
DeviceListener,
IncomingMessageListener,
KettleInfoListener,
@ -224,7 +224,7 @@ class KettleController(threading.Thread,
def __init__(self):
# basic setup
MqttBase.__init__(self, clean_session=False)
Mqtt.__init__(self, clean_session=False)
threading.Thread.__init__(self)
self._logger = logging.getLogger(self.__class__.__name__)
@ -737,9 +737,6 @@ if __name__ == '__main__':
kc = KettleController()
if 'api' in config:
bot.enable_logging(BotType.POLARIS_KETTLE)
bot.run()
# bot library handles signals, so when sigterm or something like that happens, we should stop all other threads here

View File

@ -4,12 +4,13 @@
import logging
import sys
import paho.mqtt.client as mqtt
import __py_include
from typing import Optional
from argparse import ArgumentParser
from queue import SimpleQueue
from home.mqtt import MqttBase
from home.config import config
from homekit.mqtt import Mqtt
from homekit.config import config
from syncleo import (
Kettle,
PowerType,
@ -21,7 +22,7 @@ logger = logging.getLogger(__name__)
control_tasks = SimpleQueue()
class MqttServer(MqttBase):
class MqttServer(Mqtt):
def __init__(self):
super().__init__(clean_session=False)
@ -75,7 +76,7 @@ def main():
parser.add_argument('-t', '--temperature', dest='temp', type=int, default=tempmax,
choices=range(tempmin, tempmax+tempstep, tempstep))
arg = config.load('polaris_kettle_util', use_cli=True, parser=parser)
arg = config.load_app('polaris_kettle_util', use_cli=True, parser=parser)
if arg.mode == 'mqtt':
server = MqttServer()

297
bin/pump_bot.py Executable file
View File

@ -0,0 +1,297 @@
#!/usr/bin/env python3
import __py_include
import sys
import asyncio
from enum import Enum
from typing import Optional, Union
from telegram import ReplyKeyboardMarkup, User
from time import time
from datetime import datetime
from homekit.config import config, is_development_mode, AppConfigUnit
from homekit.telegram import bot
from homekit.telegram.config import TelegramBotConfig, TelegramUserListType
from homekit.telegram._botutil import user_any_name
from homekit.relay.sunxi_h3_client import RelayClient
from homekit.mqtt import MqttNode, MqttWrapper, MqttPayload, MqttNodesConfig, MqttModule
from homekit.mqtt.module.relay import MqttPowerStatusPayload, MqttRelayModule
from homekit.mqtt.module.temphum import MqttTemphumDataPayload
from homekit.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload
if __name__ != '__main__':
print(f'this script can not be imported as module', file=sys.stderr)
sys.exit(1)
mqtt_nodes_config = MqttNodesConfig()
class PumpBotUserListType(TelegramUserListType):
SILENT = 'silent_users'
class PumpBotConfig(AppConfigUnit, TelegramBotConfig):
NAME = 'pump_bot'
@classmethod
def schema(cls) -> Optional[dict]:
return {
**super(TelegramBotConfig).schema(),
PumpBotUserListType.SILENT: TelegramBotConfig._userlist_schema(),
'watering_relay_node': {'type': 'string'},
'pump_relay_addr': cls._addr_schema()
}
@staticmethod
def custom_validator(data):
relay_node_names = mqtt_nodes_config.get_nodes(filters=('relay',), only_names=True)
if data['watering_relay_node'] not in relay_node_names:
raise ValueError(f'unknown relay node "{data["watering_relay_node"]}"')
config.load_app(PumpBotConfig)
mqtt: MqttWrapper
mqtt_node: MqttNode
mqtt_relay_module: Union[MqttRelayModule, MqttModule]
time_format = '%d.%m.%Y, %H:%M:%S'
watering_mcu_status = {
'last_time': 0,
'last_boot_time': 0,
'relay_opened': False,
'ambient_temp': 0.0,
'ambient_rh': 0.0,
}
bot.initialize()
bot.lang.ru(
start_message="Выберите команду на клавиатуре",
unknown_command="Неизвестная команда",
enable="Включить",
enable_silently="Включить тихо",
enabled="Насос включен ✅",
disable="Выключить",
disable_silently="Выключить тихо",
disabled="Насос выключен ❌",
start_watering="Включить полив",
stop_watering="Отключить полив",
status="Статус насоса",
watering_status="Статус полива",
done="Готово 👌",
sent="Команда отправлена",
user_action_notification='Пользователь <a href="tg://user?id=%d">%s</a> <b>%s</b> насос.',
user_watering_notification='Пользователь <a href="tg://user?id=%d">%s</a> <b>%s</b> полив.',
user_action_on="включил",
user_action_off="выключил",
user_action_watering_on="включил",
user_action_watering_off="выключил",
)
bot.lang.en(
start_message="Select command on the keyboard",
unknown_command="Unknown command",
enable="Turn ON",
enable_silently="Turn ON silently",
enabled="The pump is turned ON ✅",
disable="Turn OFF",
disable_silently="Turn OFF silently",
disabled="The pump is turned OFF ❌",
start_watering="Start watering",
stop_watering="Stop watering",
status="Pump status",
watering_status="Watering status",
done="Done 👌",
sent="Request sent",
user_action_notification='User <a href="tg://user?id=%d">%s</a> turned the pump <b>%s</b>.',
user_watering_notification='User <a href="tg://user?id=%d">%s</a> <b>%s</b> the watering.',
user_action_on="ON",
user_action_off="OFF",
user_action_watering_on="started",
user_action_watering_off="stopped",
)
class UserAction(Enum):
ON = 'on'
OFF = 'off'
WATERING_ON = 'watering_on'
WATERING_OFF = 'watering_off'
def get_relay() -> RelayClient:
relay = RelayClient(host=config.app_config['pump_relay_addr'].host,
port=config.app_config['pump_relay_addr'].port)
relay.connect()
return relay
async def on(ctx: bot.Context, silent=False) -> None:
get_relay().on()
futures = [ctx.reply(ctx.lang('done'))]
if not silent:
futures.append(notify(ctx.user, UserAction.ON))
await asyncio.gather(*futures)
async def off(ctx: bot.Context, silent=False) -> None:
get_relay().off()
futures = [ctx.reply(ctx.lang('done'))]
if not silent:
futures.append(notify(ctx.user, UserAction.OFF))
await asyncio.gather(*futures)
async def watering_on(ctx: bot.Context) -> None:
mqtt_relay_module.switchpower(True)
await asyncio.gather(
ctx.reply(ctx.lang('sent')),
notify(ctx.user, UserAction.WATERING_ON)
)
async def watering_off(ctx: bot.Context) -> None:
mqtt_relay_module.switchpower(False)
await asyncio.gather(
ctx.reply(ctx.lang('sent')),
notify(ctx.user, UserAction.WATERING_OFF)
)
async def notify(user: User, action: UserAction) -> None:
notification_key = 'user_watering_notification' if action in (UserAction.WATERING_ON, UserAction.WATERING_OFF) else 'user_action_notification'
def text_getter(lang: str):
action_name = bot.lang.get(f'user_action_{action.value}', lang)
user_name = user_any_name(user)
return ' ' + bot.lang.get(notification_key, lang,
user.id, user_name, action_name)
await bot.notify_all(text_getter, exclude=(user.id,))
@bot.handler(message='enable')
async def enable_handler(ctx: bot.Context) -> None:
await on(ctx)
@bot.handler(message='enable_silently')
async def enable_s_handler(ctx: bot.Context) -> None:
await on(ctx, True)
@bot.handler(message='disable')
async def disable_handler(ctx: bot.Context) -> None:
await off(ctx)
@bot.handler(message='start_watering')
async def start_watering(ctx: bot.Context) -> None:
await watering_on(ctx)
@bot.handler(message='stop_watering')
async def stop_watering(ctx: bot.Context) -> None:
await watering_off(ctx)
@bot.handler(message='disable_silently')
async def disable_s_handler(ctx: bot.Context) -> None:
await off(ctx, True)
@bot.handler(message='status')
async def status(ctx: bot.Context) -> None:
await ctx.reply(
ctx.lang('enabled') if get_relay().status() == 'on' else ctx.lang('disabled')
)
def _get_timestamp_as_string(timestamp: int) -> str:
if timestamp != 0:
return datetime.fromtimestamp(timestamp).strftime(time_format)
else:
return 'unknown'
@bot.handler(message='watering_status')
async def watering_status(ctx: bot.Context) -> None:
buf = ''
if 0 < watering_mcu_status["last_time"] < time()-1800:
buf += '<b>WARNING! long time no reports from mcu! maybe something\'s wrong</b>\n'
buf += f'last report time: <b>{_get_timestamp_as_string(watering_mcu_status["last_time"])}</b>\n'
if watering_mcu_status["last_boot_time"] != 0:
buf += f'boot time: <b>{_get_timestamp_as_string(watering_mcu_status["last_boot_time"])}</b>\n'
buf += 'relay opened: <b>' + ('yes' if watering_mcu_status['relay_opened'] else 'no') + '</b>\n'
buf += f'ambient temp & humidity: <b>{watering_mcu_status["ambient_temp"]} °C, {watering_mcu_status["ambient_rh"]}%</b>'
await ctx.reply(buf)
@bot.defaultreplymarkup
def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]:
buttons = []
if ctx.user_id in config.app_config.get_user_ids(PumpBotUserListType.SILENT):
buttons.append([ctx.lang('enable_silently'), ctx.lang('disable_silently')])
buttons.append([ctx.lang('enable'), ctx.lang('disable'), ctx.lang('status')],)
buttons.append([ctx.lang('start_watering'), ctx.lang('stop_watering'), ctx.lang('watering_status')])
return ReplyKeyboardMarkup(buttons, one_time_keyboard=False)
def mqtt_payload_callback(mqtt_node: MqttNode, payload: MqttPayload):
global watering_mcu_status
types_the_node_can_send = (
InitialDiagnosticsPayload,
DiagnosticsPayload,
MqttTemphumDataPayload,
MqttPowerStatusPayload
)
for cl in types_the_node_can_send:
if isinstance(payload, cl):
watering_mcu_status['last_time'] = int(time())
break
if isinstance(payload, InitialDiagnosticsPayload):
watering_mcu_status['last_boot_time'] = int(time())
elif isinstance(payload, MqttTemphumDataPayload):
watering_mcu_status['ambient_temp'] = payload.temp
watering_mcu_status['ambient_rh'] = payload.rh
elif isinstance(payload, MqttPowerStatusPayload):
watering_mcu_status['relay_opened'] = payload.opened
mqtt = MqttWrapper(client_id='pump_bot')
mqtt_node = MqttNode(node_id=config.app_config['watering_relay_node'])
if is_development_mode():
mqtt_node.load_module('diagnostics')
mqtt_node.load_module('temphum')
mqtt_relay_module = mqtt_node.load_module('relay')
mqtt_node.add_payload_callback(mqtt_payload_callback)
mqtt.connect_and_loop(loop_forever=False)
bot.run()
try:
mqtt.disconnect()
except:
pass

View File

@ -1,20 +1,20 @@
#!/usr/bin/env python3
import datetime
import __py_include
from enum import Enum
from typing import Optional
from telegram import ReplyKeyboardMarkup, User
from home.config import config
from home.telegram import bot
from home.telegram._botutil import user_any_name
from home.mqtt.esp import MqttEspDevice
from home.mqtt import MqttRelay, MqttRelayState
from home.mqtt.payload import MqttPayload
from home.mqtt.payload.relay import InitialDiagnosticsPayload, DiagnosticsPayload
from homekit.config import config
from homekit.telegram import bot
from homekit.telegram._botutil import user_any_name
from homekit.mqtt import MqttNode, MqttPayload
from homekit.mqtt.module.relay import MqttRelayState
from homekit.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload
config.load('pump_mqtt_bot')
config.load_app('pump_mqtt_bot')
bot.initialize()
bot.lang.ru(
@ -70,7 +70,7 @@ bot.lang.en(
)
mqtt_relay: Optional[MqttRelay] = None
mqtt: Optional[MqttNode] = None
relay_state = MqttRelayState()
@ -99,14 +99,14 @@ def notify(user: User, action: UserAction) -> None:
@bot.handler(message='enable')
def enable_handler(ctx: bot.Context) -> None:
mqtt_relay.set_power(config['mqtt']['home_id'], True)
mqtt.set_power(config['mqtt']['home_id'], True)
ctx.reply(ctx.lang('done'))
notify(ctx.user, UserAction.ON)
@bot.handler(message='disable')
def disable_handler(ctx: bot.Context) -> None:
mqtt_relay.set_power(config['mqtt']['home_id'], False)
mqtt.set_power(config['mqtt']['home_id'], False)
ctx.reply(ctx.lang('done'))
notify(ctx.user, UserAction.OFF)
@ -157,13 +157,12 @@ def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]:
if __name__ == '__main__':
mqtt_relay = MqttRelay(devices=MqttEspDevice(id=config['mqtt']['home_id'],
secret=config['mqtt']['home_secret']))
mqtt_relay.set_message_callback(on_mqtt_message)
mqtt_relay.configure_tls()
mqtt_relay.connect_and_loop(loop_forever=False)
mqtt = MqttRelay(devices=MqttEspDevice(id=config['mqtt']['home_id'],
secret=config['mqtt']['home_secret']))
mqtt.set_message_callback(on_mqtt_message)
mqtt.connect_and_loop(loop_forever=False)
# bot.enable_logging(BotType.PUMP_MQTT)
bot.run(start_handler=start)
mqtt_relay.disconnect()
mqtt.disconnect()

164
bin/relay_mqtt_bot.py Executable file
View File

@ -0,0 +1,164 @@
#!/usr/bin/env python3
import sys
import __py_include
from enum import Enum
from typing import Optional, Union
from telegram import ReplyKeyboardMarkup
from functools import partial
from homekit.config import config, AppConfigUnit, Translation
from homekit.telegram import bot
from homekit.telegram.config import TelegramBotConfig
from homekit.mqtt import MqttPayload, MqttNode, MqttWrapper, MqttModule, MqttNodesConfig
from homekit.mqtt.module.relay import MqttRelayModule, MqttRelayState
from homekit.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload
if __name__ != '__main__':
print(f'this script can not be imported as module', file=sys.stderr)
sys.exit(1)
mqtt_nodes_config = MqttNodesConfig()
class RelayMqttBotConfig(AppConfigUnit, TelegramBotConfig):
NAME = 'relay_mqtt_bot'
_strings: Translation
def __init__(self):
super().__init__()
self._strings = Translation('mqtt_nodes')
@classmethod
def schema(cls) -> Optional[dict]:
return {
**super(TelegramBotConfig).schema(),
'relay_nodes': {
'type': 'list',
'required': True,
'schema': {
'type': 'string'
}
},
}
@staticmethod
def custom_validator(data):
relay_node_names = mqtt_nodes_config.get_nodes(filters=('relay',), only_names=True)
for node in data['relay_nodes']:
if node not in relay_node_names:
raise ValueError(f'unknown relay node "{node}"')
def get_relay_name_translated(self, lang: str, relay_name: str) -> str:
return self._strings.get(lang)[relay_name]['relay']
config.load_app(RelayMqttBotConfig)
bot.initialize()
bot.lang.ru(
start_message="Выберите команду на клавиатуре",
unknown_command="Неизвестная команда",
done="Готово 👌",
)
bot.lang.en(
start_message="Select command on the keyboard",
unknown_command="Unknown command",
done="Done 👌",
)
type_emojis = {
'lamp': '💡'
}
status_emoji = {
'on': '',
'off': ''
}
mqtt: MqttWrapper
relay_nodes: dict[str, Union[MqttRelayModule, MqttModule]] = {}
relay_states: dict[str, MqttRelayState] = {}
class UserAction(Enum):
ON = 'on'
OFF = 'off'
def on_mqtt_message(node: MqttNode,
message: MqttPayload):
if isinstance(message, InitialDiagnosticsPayload) or isinstance(message, DiagnosticsPayload):
kwargs = dict(rssi=message.rssi, enabled=message.flags.state)
if isinstance(message, InitialDiagnosticsPayload):
kwargs['fw_version'] = message.fw_version
if node.id not in relay_states:
relay_states[node.id] = MqttRelayState()
relay_states[node.id].update(**kwargs)
async def enable_handler(node_id: str, ctx: bot.Context) -> None:
relay_nodes[node_id].switchpower(True)
await ctx.reply(ctx.lang('done'))
async def disable_handler(node_id: str, ctx: bot.Context) -> None:
relay_nodes[node_id].switchpower(False)
await ctx.reply(ctx.lang('done'))
async def start(ctx: bot.Context) -> None:
await ctx.reply(ctx.lang('start_message'))
@bot.exceptionhandler
async def exception_handler(e: Exception, ctx: bot.Context) -> bool:
return False
@bot.defaultreplymarkup
def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]:
buttons = []
for node_id in config.app_config['relay_nodes']:
node_data = mqtt_nodes_config.get_node(node_id)
type_emoji = type_emojis[node_data['relay']['device_type']]
row = [f'{type_emoji}{status_emoji[i.value]} {config.app_config.get_relay_name_translated(ctx.user_lang, node_id)}'
for i in UserAction]
buttons.append(row)
return ReplyKeyboardMarkup(buttons, one_time_keyboard=False)
devices = []
mqtt = MqttWrapper(client_id='relay_mqtt_bot')
for node_id in config.app_config['relay_nodes']:
node_data = mqtt_nodes_config.get_node(node_id)
mqtt_node = MqttNode(node_id=node_id,
node_secret=node_data['password'])
module_kwargs = {}
try:
if node_data['relay']['legacy_topics']:
module_kwargs['legacy_topics'] = True
except KeyError:
pass
relay_nodes[node_id] = mqtt_node.load_module('relay', **module_kwargs)
mqtt_node.add_payload_callback(on_mqtt_message)
mqtt.add_node(mqtt_node)
type_emoji = type_emojis[node_data['relay']['device_type']]
for action in UserAction:
messages = []
for _lang in Translation.LANGUAGES:
_label = config.app_config.get_relay_name_translated(_lang, node_id)
messages.append(f'{type_emoji}{status_emoji[action.value]} {_label}')
bot.handler(texts=messages)(partial(enable_handler if action == UserAction.ON else disable_handler, node_id))
mqtt.connect_and_loop(loop_forever=False)
bot.run(start_handler=start)
mqtt.disconnect()

134
bin/relay_mqtt_http_proxy.py Executable file
View File

@ -0,0 +1,134 @@
#!/usr/bin/env python3
import logging
import __py_include
from homekit import http
from homekit.config import config, AppConfigUnit
from homekit.mqtt import MqttPayload, MqttWrapper, MqttNode, MqttModule, MqttNodesConfig
from homekit.mqtt.module.relay import MqttRelayState, MqttRelayModule, MqttPowerStatusPayload
from homekit.mqtt.module.diagnostics import InitialDiagnosticsPayload, DiagnosticsPayload
from typing import Optional, Union
logger = logging.getLogger(__name__)
mqtt: Optional[MqttWrapper] = None
mqtt_nodes: dict[str, MqttNode] = {}
relay_modules: dict[str, Union[MqttRelayModule, MqttModule]] = {}
relay_states: dict[str, MqttRelayState] = {}
mqtt_nodes_config = MqttNodesConfig()
class RelayMqttHttpProxyConfig(AppConfigUnit):
NAME = 'relay_mqtt_http_proxy'
@classmethod
def schema(cls) -> Optional[dict]:
return {
'relay_nodes': {
'type': 'list',
'required': True,
'schema': {
'type': 'string'
}
},
'listen_addr': cls._addr_schema(required=True)
}
@staticmethod
def custom_validator(data):
relay_node_names = mqtt_nodes_config.get_nodes(filters=('relay',), only_names=True)
for node in data['relay_nodes']:
if node not in relay_node_names:
raise ValueError(f'unknown relay node "{node}"')
def on_mqtt_message(node: MqttNode,
message: MqttPayload):
try:
is_legacy = mqtt_nodes_config[node.id]['relay']['legacy_topics']
logger.debug(f'on_mqtt_message: relay {node.id} uses legacy topic names')
except KeyError:
is_legacy = False
kwargs = {}
if isinstance(message, InitialDiagnosticsPayload) or isinstance(message, DiagnosticsPayload):
kwargs['rssi'] = message.rssi
if is_legacy:
kwargs['enabled'] = message.flags.state
if not is_legacy and isinstance(message, MqttPowerStatusPayload):
kwargs['enabled'] = message.opened
if len(kwargs):
logger.debug(f'on_mqtt_message: {node.id}: going to update relay state: {str(kwargs)}')
if node.id not in relay_states:
relay_states[node.id] = MqttRelayState()
relay_states[node.id].update(**kwargs)
class RelayMqttHttpProxy(http.HTTPServer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.get('/relay/{id}/on', self.relay_on)
self.get('/relay/{id}/off', self.relay_off)
self.get('/relay/{id}/toggle', self.relay_toggle)
async def _relay_on_off(self,
enable: Optional[bool],
req: http.Request):
node_id = req.match_info['id']
node_secret = req.query['secret']
node = mqtt_nodes[node_id]
relay_module = relay_modules[node_id]
if enable is None:
if node_id in relay_states and relay_states[node_id].ever_updated:
cur_state = relay_states[node_id].enabled
else:
cur_state = False
enable = not cur_state
node.secret = node_secret
relay_module.switchpower(enable)
return self.ok()
async def relay_on(self, req: http.Request):
return await self._relay_on_off(True, req)
async def relay_off(self, req: http.Request):
return await self._relay_on_off(False, req)
async def relay_toggle(self, req: http.Request):
return await self._relay_on_off(None, req)
if __name__ == '__main__':
config.load_app(RelayMqttHttpProxyConfig)
mqtt = MqttWrapper(client_id='relay_mqtt_http_proxy',
randomize_client_id=True)
for node_id in config.app_config['relay_nodes']:
node_data = mqtt_nodes_config.get_node(node_id)
mqtt_node = MqttNode(node_id=node_id)
module_kwargs = {}
try:
if node_data['relay']['legacy_topics']:
module_kwargs['legacy_topics'] = True
except KeyError:
pass
relay_modules[node_id] = mqtt_node.load_module('relay', **module_kwargs)
if 'legacy_topics' in module_kwargs:
mqtt_node.load_module('diagnostics')
mqtt_node.add_payload_callback(on_mqtt_message)
mqtt.add_node(mqtt_node)
mqtt_nodes[node_id] = mqtt_node
mqtt.connect_and_loop(loop_forever=False)
proxy = RelayMqttHttpProxy(config.app_config['listen_addr'])
try:
proxy.run()
except KeyboardInterrupt:
mqtt.disconnect()

View File

@ -4,6 +4,7 @@ import socket
import logging
import re
import gc
import __py_include
from io import BytesIO
from typing import Optional
@ -14,16 +15,15 @@ import matplotlib.ticker as mticker
from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton
from home.config import config
from home.telegram import bot
from home.util import chunks, MySimpleSocketClient
from home.api import WebAPIClient
from home.api.types import (
BotType,
from homekit.config import config
from homekit.telegram import bot
from homekit.util import chunks, MySimpleSocketClient
from homekit.api import WebApiClient
from homekit.api.types import (
TemperatureSensorLocation
)
config.load('sensors_bot')
config.load_app('sensors_bot')
bot.initialize()
bot.lang.ru(
@ -111,7 +111,7 @@ def callback_handler(ctx: bot.Context) -> None:
sensor = TemperatureSensorLocation[match.group(1).upper()]
hours = int(match.group(2))
api = WebAPIClient(timeout=20)
api = WebApiClient(timeout=20)
data = api.get_sensors_data(sensor, hours)
title = ctx.lang(sensor.name.lower()) + ' (' + ctx.lang('n_hrs', hours) + ')'
@ -175,7 +175,4 @@ def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]:
if __name__ == '__main__':
if 'api' in config:
bot.enable_logging(BotType.SENSORS)
bot.run()

View File

@ -2,32 +2,33 @@
import logging
import os
import tempfile
import __py_include
from enum import Enum
from datetime import datetime, timedelta
from html import escape
from typing import Optional, List, Dict, Tuple
from home.config import config
from home.api import WebAPIClient
from home.api.types import SoundSensorLocation, BotType
from home.api.errors import ApiResponseError
from home.media import SoundNodeClient, SoundRecordClient, SoundRecordFile, CameraNodeClient
from home.soundsensor import SoundSensorServerGuardClient
from home.util import parse_addr, chunks, filesize_fmt
from homekit.config import config
from homekit.api import WebApiClient
from homekit.api.types import SoundSensorLocation
from homekit.api.errors import ApiResponseError
from homekit.media import SoundNodeClient, SoundRecordClient, SoundRecordFile, CameraNodeClient
from homekit.soundsensor import SoundSensorServerGuardClient
from homekit.util import Addr, chunks, filesize_fmt
from home.telegram import bot
from homekit.telegram import bot
from telegram.error import TelegramError
from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton, User
from PIL import Image
config.load('sound_bot')
config.load_app('sound_bot')
nodes = {}
for nodename, nodecfg in config['nodes'].items():
nodes[nodename] = parse_addr(nodecfg['addr'])
nodes[nodename] = Addr.fromstring(nodecfg['addr'])
bot.initialize()
bot.lang.ru(
@ -142,13 +143,13 @@ cam_client_links: Dict[str, CameraNodeClient] = {}
def node_client(node: str) -> SoundNodeClient:
if node not in node_client_links:
node_client_links[node] = SoundNodeClient(parse_addr(config['nodes'][node]['addr']))
node_client_links[node] = SoundNodeClient(Addr.fromstring(config['nodes'][node]['addr']))
return node_client_links[node]
def camera_client(cam: str) -> CameraNodeClient:
if cam not in node_client_links:
cam_client_links[cam] = CameraNodeClient(parse_addr(config['cameras'][cam]['addr']))
cam_client_links[cam] = CameraNodeClient(Addr.fromstring(config['cameras'][cam]['addr']))
return cam_client_links[cam]
@ -188,7 +189,7 @@ def manual_recording_allowed(user_id: int) -> bool:
def guard_client() -> SoundSensorServerGuardClient:
return SoundSensorServerGuardClient(parse_addr(config['bot']['guard_server']))
return SoundSensorServerGuardClient(Addr.fromstring(config['bot']['guard_server']))
# message renderers
@ -734,7 +735,7 @@ def sound_sensors_last_24h(ctx: bot.Context):
ctx.answer()
cl = WebAPIClient()
cl = WebApiClient()
data = cl.get_sound_sensor_hits(location=SoundSensorLocation[node.upper()],
after=datetime.now() - timedelta(hours=24))
@ -757,7 +758,7 @@ def sound_sensors_last_anything(ctx: bot.Context):
ctx.answer()
cl = WebAPIClient()
cl = WebApiClient()
data = cl.get_last_sound_sensor_hits(location=SoundSensorLocation[node.upper()],
last=20)
@ -883,7 +884,5 @@ if __name__ == '__main__':
finished_handler=record_onfinished,
download_on_finish=True)
if 'api' in config:
bot.enable_logging(BotType.SOUND)
bot.run()
record_client.stop()

View File

@ -1,12 +1,13 @@
#!/usr/bin/env python3
import os
import __py_include
from typing import Optional
from home.config import config
from home.audio import amixer
from home.media import MediaNodeServer, SoundRecordStorage, SoundRecorder
from home import http
from homekit.config import config
from homekit.audio import amixer
from homekit.media import MediaNodeServer, SoundRecordStorage, SoundRecorder
from homekit import http
# This script must be run as root as it runs arecord.
@ -77,7 +78,7 @@ if __name__ == '__main__':
if not os.getegid() == 0:
raise RuntimeError("Must be run as root.")
config.load('sound_node')
config.load_app('sound_node')
storage = SoundRecordStorage(config['node']['storage'])

View File

@ -2,10 +2,11 @@
import logging
import os
import sys
import __py_include
from home.config import config
from home.util import parse_addr
from home.soundsensor import SoundSensorNode
from homekit.config import config
from homekit.util import Addr
from homekit.soundsensor import SoundSensorNode
logger = logging.getLogger(__name__)
@ -14,14 +15,14 @@ if __name__ == '__main__':
if not os.getegid() == 0:
sys.exit('Must be run as root.')
config.load('sound_sensor_node')
config.load_app('sound_sensor_node')
kwargs = {}
if 'delay' in config['node']:
kwargs['delay'] = config['node']['delay']
if 'server_addr' in config['node']:
server_addr = parse_addr(config['node']['server_addr'])
server_addr = Addr.fromstring(config['node']['server_addr'])
else:
server_addr = None

View File

@ -1,16 +1,17 @@
#!/usr/bin/env python3
import logging
import threading
import __py_include
from time import sleep
from typing import Optional, List, Dict, Tuple
from functools import partial
from home.config import config
from home.util import parse_addr
from home.api import WebAPIClient, RequestParams
from home.api.types import SoundSensorLocation
from home.soundsensor import SoundSensorServer, SoundSensorHitHandler
from home.media import MediaNodeType, SoundRecordClient, CameraRecordClient, RecordClient
from homekit.config import config
from homekit.util import Addr
from homekit.api import WebApiClient, RequestParams
from homekit.api.types import SoundSensorLocation
from homekit.soundsensor import SoundSensorServer, SoundSensorHitHandler
from homekit.media import MediaNodeType, SoundRecordClient, CameraRecordClient, RecordClient
interrupted = False
logger = logging.getLogger(__name__)
@ -120,7 +121,7 @@ def hits_sender():
sleep(5)
api: Optional[WebAPIClient] = None
api: Optional[WebApiClient] = None
hc: Optional[HitCounter] = None
record_clients: Dict[MediaNodeType, RecordClient] = {}
@ -159,10 +160,10 @@ def api_error_handler(exc, name, req: RequestParams):
if __name__ == '__main__':
config.load('sound_sensor_server')
config.load_app('sound_sensor_server')
hc = HitCounter()
api = WebAPIClient(timeout=(10, 60))
api = WebApiClient(timeout=(10, 60))
api.enable_async(error_handler=api_error_handler)
t = threading.Thread(target=hits_sender)
@ -172,12 +173,12 @@ if __name__ == '__main__':
sound_nodes = {}
if 'sound_nodes' in config:
for nodename, nodecfg in config['sound_nodes'].items():
sound_nodes[nodename] = parse_addr(nodecfg['addr'])
sound_nodes[nodename] = Addr.fromstring(nodecfg['addr'])
camera_nodes = {}
if 'camera_nodes' in config:
for nodename, nodecfg in config['camera_nodes'].items():
camera_nodes[nodename] = parse_addr(nodecfg['addr'])
camera_nodes[nodename] = Addr.fromstring(nodecfg['addr'])
if sound_nodes:
record_clients[MediaNodeType.SOUND] = SoundRecordClient(sound_nodes,

View File

@ -1,14 +1,14 @@
#!/usr/bin/env python3
from home.config import config
import __py_include
from homekit.config import config
if __name__ == '__main__':
config.load('ssh_tunnels_config_util')
config.load_app('ssh_tunnels_config_util')
network_prefix = config['network']
hostnames = []
for k, v in config.items():
for k, v in config.app_config.get().items():
if type(v) is not dict:
continue
hostnames.append(k)

79
bin/temphum_mqtt_node.py Executable file
View File

@ -0,0 +1,79 @@
#!/usr/bin/env python3
import asyncio
import json
import logging
import __py_include
from typing import Optional
from homekit.config import config
from homekit.temphum import SensorType, BaseSensor
from homekit.temphum.i2c import create_sensor
logger = logging.getLogger(__name__)
sensor: Optional[BaseSensor] = None
lock = asyncio.Lock()
delay = 0.01
async def get_measurements():
async with lock:
await asyncio.sleep(delay)
temp = sensor.temperature()
rh = sensor.humidity()
return rh, temp
async def handle_client(reader, writer):
request = None
while request != 'quit':
try:
request = await reader.read(255)
if request == b'\x04':
break
request = request.decode('utf-8').strip()
except Exception:
break
if request == 'read':
try:
rh, temp = await asyncio.wait_for(get_measurements(), timeout=3)
data = dict(humidity=rh, temp=temp)
except asyncio.TimeoutError as e:
logger.exception(e)
data = dict(error='i2c call timed out')
else:
data = dict(error='invalid request')
writer.write((json.dumps(data) + '\r\n').encode('utf-8'))
try:
await writer.drain()
except ConnectionResetError:
pass
writer.close()
async def run_server(host, port):
server = await asyncio.start_server(handle_client, host, port)
async with server:
logger.info('Server started.')
await server.serve_forever()
if __name__ == '__main__':
config.load_app()
if 'measure_delay' in config['sensor']:
delay = float(config['sensor']['measure_delay'])
sensor = create_sensor(SensorType(config['sensor']['type']),
int(config['sensor']['bus']))
try:
host, port = config.get_addr('server.listen')
asyncio.run(run_server(host, port))
except KeyboardInterrupt:
logging.info('Exiting...')

View File

@ -1,22 +1,13 @@
#!/usr/bin/env python3
import paho.mqtt.client as mqtt
import re
import __py_include
from home.mqtt import MqttBase
from home.config import config
from home.mqtt.payload.sensors import Temperature
from home.api.types import TemperatureSensorLocation
from home.database import SensorsDatabase
from homekit.config import config
from homekit.mqtt import MqttWrapper, MqttNode
def get_sensor_type(sensor: str) -> TemperatureSensorLocation:
for item in TemperatureSensorLocation:
if sensor == item.name.lower():
return item
raise ValueError(f'unexpected sensor value: {sensor}')
class MqttServer(MqttBase):
class MqttServer(Mqtt):
def __init__(self):
super().__init__(clean_session=False)
self.database = SensorsDatabase()
@ -47,7 +38,11 @@ class MqttServer(MqttBase):
if __name__ == '__main__':
config.load('sensors_mqtt_receiver')
config.load_app('temphum_mqtt_receiver')
server = MqttServer()
server.connect_and_loop()
mqtt = MqttWrapper(clean_session=False)
node = MqttNode(node_id='+')
node.load_module('temphum', write_to_database=True)
mqtt.add_node(node)
mqtt.connect_and_loop()

View File

@ -1,5 +1,7 @@
#!/usr/bin/env python3
from home.mqtt.temphum import MqttTempHumNodes
import __py_include
from homekit.mqtt.temphum import MqttTempHumNodes
if __name__ == '__main__':
max_name_len = 0

View File

@ -1,6 +1,9 @@
#!/usr/bin/env python3
import __py_include
from argparse import ArgumentParser
from home.temphum import SensorType, create_sensor
from homekit.temphum import SensorType
from homekit.temphum.i2c import create_sensor
if __name__ == '__main__':

View File

@ -2,14 +2,16 @@
import asyncio
import json
import logging
import __py_include
from typing import Optional
from home.config import config
from home.temphum import SensorType, create_sensor, TempHumSensor
from homekit.config import config
from homekit.temphum import SensorType, BaseSensor
from homekit.temphum.i2c import create_sensor
logger = logging.getLogger(__name__)
sensor: Optional[TempHumSensor] = None
sensor: Optional[BaseSensor] = None
lock = asyncio.Lock()
delay = 0.01
@ -62,7 +64,7 @@ async def run_server(host, port):
if __name__ == '__main__':
config.load()
config.load_app()
if 'measure_delay' in config['sensor']:
delay = float(config['sensor']['measure_delay'])

View File

@ -2,16 +2,17 @@
import asyncio
import json
import os
import __py_include
from datetime import datetime, timedelta
from aiohttp import web
from home import http
from home.config import config, is_development_mode
from home.database import BotsDatabase, SensorsDatabase, InverterDatabase
from home.database.inverter_time_formats import *
from home.api.types import BotType, TemperatureSensorLocation, SoundSensorLocation
from home.media import SoundRecordStorage
from homekit import http
from homekit.config import config, is_development_mode
from homekit.database import BotsDatabase, SensorsDatabase, InverterDatabase
from homekit.database.inverter_time_formats import *
from homekit.api.types import TemperatureSensorLocation, SoundSensorLocation
from homekit.media import SoundRecordStorage
def strptime_auto(s: str) -> datetime:
@ -41,7 +42,6 @@ class WebAPIServer(http.HTTPServer):
self.get('/sound_sensors/hits/', self.GET_sound_sensors_hits)
self.post('/sound_sensors/hits/', self.POST_sound_sensors_hits)
self.post('/log/bot_request/', self.POST_bot_request_log)
self.post('/log/openwrt/', self.POST_openwrt_log)
self.get('/inverter/consumed_energy/', self.GET_consumed_energy)
@ -125,30 +125,6 @@ class WebAPIServer(http.HTTPServer):
BotsDatabase().add_sound_hits(hits, datetime.now())
return self.ok()
async def POST_bot_request_log(self, req: http.Request):
data = await req.post()
try:
user_id = int(data['user_id'])
except KeyError:
user_id = 0
try:
message = data['message']
except KeyError:
message = ''
bot = BotType(int(data['bot']))
# validate message
if message.strip() == '':
raise ValueError('message can\'t be empty')
# add record to the database
BotsDatabase().add_request(bot, user_id, message)
return self.ok()
async def POST_openwrt_log(self, req: http.Request):
data = await req.post()
@ -231,7 +207,7 @@ if __name__ == '__main__':
_app_name = 'web_api'
if is_development_mode():
_app_name += '_dev'
config.load(_app_name)
config.load_app(_app_name)
loop = asyncio.get_event_loop()

28
doc/openwrt_logger.md Normal file
View File

@ -0,0 +1,28 @@
# openwrt_logger.py
This script is supposed to be run by cron every 5 minutes or so.
It looks for new lines in log file and sends them to remote server.
OpenWRT must have remote logging enabled (UDP; IP of host this script is launched on; port 514)
`/etc/rsyslog.conf` contains following (assuming `192.168.1.1` is the router IP):
```
$ModLoad imudp
$UDPServerRun 514
:fromhost-ip, isequal, "192.168.1.1" /var/log/openwrt.log
& ~
```
Also comment out the following line:
```
$ActionFileDefaultTemplate RSYSLOG_TraditionalFileFormat
```
Cron line example:
```
* * * * * /home/user/homekit/src/openwrt_logger.py --access-point 1 --file /var/wrtlogfs/openwrt-5.log >/dev/null
```
`/var/wrtlogfs` is recommended to be tmpfs, to avoid writes on mmc card, in case
you use arm sbcs as I do.

View File

@ -6,7 +6,12 @@
namespace homekit::main {
#ifndef CONFIG_TARGET_ESP01
#ifndef CONFIG_NO_RECOVERY
enum WorkingMode working_mode = WorkingMode::NORMAL;
#endif
#endif
static const uint16_t recovery_boot_detection_ms = 2000;
static const uint8_t recovery_boot_delay_ms = 100;
@ -22,8 +27,10 @@ static StopWatch blinkStopWatch;
#endif
#ifndef CONFIG_TARGET_ESP01
#ifndef CONFIG_NO_RECOVERY
static DNSServer* dnsServer = nullptr;
#endif
#endif
static void onWifiConnected(const WiFiEventStationModeGotIP& event);
static void onWifiDisconnected(const WiFiEventStationModeDisconnected& event);
@ -45,6 +52,7 @@ static void wifiConnect() {
}
#ifndef CONFIG_TARGET_ESP01
#ifndef CONFIG_NO_RECOVERY
static void wifiHotspot() {
led::mcu_led->on();
@ -71,13 +79,16 @@ static void waitForRecoveryPress() {
}
}
#endif
#endif
void setup() {
WiFi.disconnect();
#ifndef CONFIG_NO_RECOVERY
#ifndef CONFIG_TARGET_ESP01
homekit::main::waitForRecoveryPress();
#endif
#endif
#ifdef DEBUG
Serial.begin(115200);
@ -95,25 +106,31 @@ void setup() {
}
#ifndef CONFIG_TARGET_ESP01
#ifndef CONFIG_NO_RECOVERY
switch (working_mode) {
case WorkingMode::RECOVERY:
wifiHotspot();
break;
case WorkingMode::NORMAL:
#endif
#endif
wifiConnectHandler = WiFi.onStationModeGotIP(onWifiConnected);
wifiDisconnectHandler = WiFi.onStationModeDisconnected(onWifiDisconnected);
wifiConnect();
#ifndef CONFIG_NO_RECOVERY
#ifndef CONFIG_TARGET_ESP01
break;
}
#endif
#endif
}
void loop(LoopConfig* config) {
#ifndef CONFIG_NO_RECOVERY
#ifndef CONFIG_TARGET_ESP01
if (working_mode == WorkingMode::NORMAL) {
#endif
#endif
if (wifi_state == WiFiConnectionState::WAITING) {
PRINT(".");
@ -166,6 +183,7 @@ void loop(LoopConfig* config) {
}
#endif
}
#ifndef CONFIG_NO_RECOVERY
#ifndef CONFIG_TARGET_ESP01
} else {
if (dnsServer != nullptr)
@ -176,6 +194,7 @@ void loop(LoopConfig* config) {
httpServer->loop();
}
#endif
#endif
}
static void onWifiConnected(const WiFiEventStationModeGotIP& event) {
@ -191,4 +210,4 @@ static void onWifiDisconnected(const WiFiEventStationModeDisconnected& event) {
wifiTimer.once(2, wifiConnect);
}
}
}

View File

@ -10,8 +10,10 @@
#include <homekit/config.h>
#include <homekit/logging.h>
#ifndef CONFIG_TARGET_ESP01
#ifndef CONFIG_NO_RECOVERY
#include <homekit/http_server.h>
#endif
#endif
#include <homekit/wifi.h>
#include <homekit/mqtt/mqtt.h>
@ -20,6 +22,7 @@
namespace homekit::main {
#ifndef CONFIG_TARGET_ESP01
#ifndef CONFIG_NO_RECOVERY
enum class WorkingMode {
RECOVERY, // AP mode, http server with configuration
NORMAL, // MQTT client
@ -27,6 +30,7 @@ enum class WorkingMode {
extern enum WorkingMode working_mode;
#endif
#endif
enum class WiFiConnectionState {
WAITING = 0,

View File

@ -0,0 +1,12 @@
{
"name": "homekit_main",
"version": "1.0.11",
"build": {
"flags": "-I../../include"
},
"dependencies": {
"homekit_mqtt_module_ota": "file://../../include/pio/libs/mqtt_module_ota",
"homekit_mqtt_module_diagnostics": "file://../../include/pio/libs/mqtt_module_diagnostics"
}
}

View File

@ -21,6 +21,6 @@ void MqttModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, con
void MqttModule::handleOnPublish(uint16_t packetId) {}
void MqttModule::handleOnDisconnect(espMqttClientTypes::DisconnectReason reason) {}
void MqttModule::onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) {}
}

View File

@ -28,20 +28,25 @@ public:
, receiveOnPublish(_receiveOnPublish)
, receiveOnDisconnect(_receiveOnDisconnect) {}
virtual void init(Mqtt& mqtt) = 0;
virtual void tick(Mqtt& mqtt) = 0;
virtual void onConnect(Mqtt& mqtt) = 0;
virtual void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason);
virtual void handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total);
virtual void handleOnPublish(uint16_t packetId);
virtual void handleOnDisconnect(espMqttClientTypes::DisconnectReason reason);
inline void setInitialized() {
initialized = true;
}
inline short getTickInterval() {
return tickInterval;
}
inline void unsetInitialized() {
initialized = false;
}
inline short getTickInterval() const {
return tickInterval;
}
friend class Mqtt;
};

View File

@ -34,7 +34,7 @@ Mqtt::Mqtt() {
for (auto* module: modules) {
if (!module->initialized) {
module->init(*this);
module->onConnect(*this);
module->setInitialized();
}
}
@ -50,18 +50,13 @@ Mqtt::Mqtt() {
#endif
for (auto* module: modules) {
if (module->receiveOnDisconnect) {
module->handleOnDisconnect(reason);
}
module->onDisconnect(*this, reason);
module->unsetInitialized();
}
// if (ota.readyToRestart) {
// restartTimer.once(1, restart);
// } else {
reconnectTimer.once(2, [&]() {
reconnect();
});
// }
reconnectTimer.once(2, [&]() {
reconnect();
});
});
client.onSubscribe([&](uint16_t packetId, const SubscribeReturncode* returncodes, size_t len) {
@ -79,7 +74,7 @@ Mqtt::Mqtt() {
PRINTF("mqtt: message received, topic=%s, qos=%d, dup=%d, retain=%d, len=%ul, index=%ul, total=%ul\n",
topic, properties.qos, (int)properties.dup, (int)properties.retain, len, index, total);
const char *ptr = topic + nodeId.length() + 10;
const char *ptr = topic + nodeId.length() + 4;
String relevantTopic(ptr);
auto it = moduleSubscriptions.find(relevantTopic);
@ -87,7 +82,7 @@ Mqtt::Mqtt() {
auto module = it->second;
module->handlePayload(*this, relevantTopic, properties.packetId, payload, len, index, total);
} else {
PRINTF("error: module subscription for topic %s not found\n", topic);
PRINTF("error: module subscription for topic %s not found\n", relevantTopic.c_str());
}
});
@ -130,8 +125,8 @@ void Mqtt::disconnect() {
void Mqtt::loop() {
client.loop();
for (auto& module: modules) {
if (module->getTickInterval() != 0)
module->tick(*this);
if (module->getTickInterval() != 0)
module->tick(*this);
}
}
@ -154,14 +149,14 @@ uint16_t Mqtt::subscribe(const String& topic, uint8_t qos) {
void Mqtt::addModule(MqttModule* module) {
modules.emplace_back(module);
if (connected) {
module->init(*this);
module->onConnect(*this);
module->setInitialized();
}
}
void Mqtt::subscribeModule(String& topic, MqttModule* module, uint8_t qos) {
moduleSubscriptions[topic] = module;
subscribe(topic, qos);
subscribe(topic, qos);
}
}

View File

@ -1,6 +1,6 @@
{
"name": "homekit_mqtt",
"version": "1.0.9",
"version": "1.0.11",
"build": {
"flags": "-I../../include"
}

View File

@ -7,12 +7,21 @@ namespace homekit::mqtt {
static const char TOPIC_DIAGNOSTICS[] = "diag";
static const char TOPIC_INITIAL_DIAGNOSTICS[] = "d1ag";
void MqttDiagnosticsModule::init(Mqtt& mqtt) {}
void MqttDiagnosticsModule::onConnect(Mqtt &mqtt) {
sendDiagnostics(mqtt);
}
void MqttDiagnosticsModule::onDisconnect(Mqtt &mqtt, espMqttClientTypes::DisconnectReason reason) {
initialSent = false;
}
void MqttDiagnosticsModule::tick(Mqtt& mqtt) {
if (!tickElapsed())
return;
sendDiagnostics(mqtt);
}
void MqttDiagnosticsModule::sendDiagnostics(Mqtt& mqtt) {
auto cfg = config::read();
if (!initialSent) {

View File

@ -32,12 +32,15 @@ class MqttDiagnosticsModule: public MqttModule {
private:
bool initialSent;
void sendDiagnostics(Mqtt& mqtt);
public:
MqttDiagnosticsModule()
: MqttModule(30)
, initialSent(false) {}
void init(Mqtt& mqtt) override;
void onConnect(Mqtt& mqtt) override;
void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) override;
void tick(Mqtt& mqtt) override;
};

View File

@ -1,10 +1,10 @@
{
"name": "homekit_mqtt_module_diagnostics",
"version": "1.0.1",
"version": "1.0.3",
"build": {
"flags": "-I../../include"
},
"dependencies": {
"homekit_mqtt": "file://../common/libs/mqtt"
"homekit_mqtt": "file://../../include/pio/libs/mqtt"
}
}

View File

@ -12,7 +12,7 @@ using homekit::led::mcu_led;
static const char TOPIC_OTA[] = "ota";
static const char TOPIC_OTA_RESPONSE[] = "otares";
void MqttOtaModule::init(Mqtt& mqtt) {
void MqttOtaModule::onConnect(Mqtt& mqtt) {
String topic(TOPIC_OTA);
mqtt.subscribeModule(topic, this);
}
@ -140,17 +140,15 @@ uint16_t MqttOtaModule::sendResponse(Mqtt& mqtt, OtaResult status, uint8_t error
return mqtt.publish(TOPIC_OTA_RESPONSE, reinterpret_cast<uint8_t*>(&resp), sizeof(resp));
}
void MqttOtaModule::handleOnDisconnect(espMqttClientTypes::DisconnectReason reason) {
if (ota.started()) {
void MqttOtaModule::onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) {
if (ota.readyToRestart) {
restartTimer.once(1, restart);
} else if (ota.started()) {
PRINTLN("mqtt: update was in progress, canceling..");
ota.clean();
Update.end();
Update.clearError();
}
if (ota.readyToRestart) {
restartTimer.once(1, restart);
}
}
void MqttOtaModule::handleOnPublish(uint16_t packetId) {

View File

@ -57,11 +57,14 @@ private:
public:
MqttOtaModule() : MqttModule(0, true, true) {}
void init(Mqtt& mqtt) override;
void onConnect(Mqtt& mqtt) override;
void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) override;
void tick(Mqtt& mqtt) override;
void handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) override;
void handleOnPublish(uint16_t packetId) override;
void handleOnDisconnect(espMqttClientTypes::DisconnectReason reason) override;
inline bool isReadyToRestart() const {
return ota.readyToRestart;
}

View File

@ -0,0 +1,11 @@
{
"name": "homekit_mqtt_module_ota",
"version": "1.0.6",
"build": {
"flags": "-I../../include"
},
"dependencies": {
"homekit_led": "file://../../include/pio/libs/led",
"homekit_mqtt": "file://../../include/pio/libs/mqtt"
}
}

View File

@ -5,19 +5,28 @@
namespace homekit::mqtt {
static const char TOPIC_RELAY_SWITCH[] = "relay/switch";
static const char TOPIC_RELAY_STATUS[] = "relay/status";
void MqttRelayModule::init(Mqtt &mqtt) {
String topic(TOPIC_RELAY_SWITCH);
mqtt.subscribeModule(topic, this, 1);
void MqttRelayModule::onConnect(Mqtt &mqtt) {
String topic(TOPIC_RELAY_SWITCH);
mqtt.subscribeModule(topic, this, 1);
}
void MqttRelayModule::onDisconnect(Mqtt &mqtt, espMqttClientTypes::DisconnectReason reason) {
#ifdef CONFIG_RELAY_OFF_ON_DISCONNECT
if (relay::state()) {
relay::off();
}
#endif
}
void MqttRelayModule::tick(homekit::mqtt::Mqtt& mqtt) {}
void MqttRelayModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) {
if (topic != TOPIC_RELAY_SWITCH)
return;
if (topic != TOPIC_RELAY_SWITCH)
return;
if (length != sizeof(MqttRelaySwitchPayload)) {
if (length != sizeof(MqttRelaySwitchPayload)) {
PRINTF("error: size of payload (%ul) does not match expected (%ul)\n",
length, sizeof(MqttRelaySwitchPayload));
return;
@ -29,6 +38,8 @@ void MqttRelayModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId
return;
}
MqttRelayStatusPayload resp{};
if (pd->state == 1) {
PRINTLN("mqtt: turning relay on");
relay::on();
@ -38,6 +49,10 @@ void MqttRelayModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId
} else {
PRINTLN("error: unexpected state value");
}
resp.opened = relay::state();
mqtt.publish(TOPIC_RELAY_STATUS, reinterpret_cast<uint8_t*>(&resp), sizeof(resp));
}
}

View File

@ -10,14 +10,20 @@ struct MqttRelaySwitchPayload {
uint8_t state;
} __attribute__((packed));
struct MqttRelayStatusPayload {
uint8_t opened;
} __attribute__((packed));
class MqttRelayModule : public MqttModule {
public:
MqttRelayModule() : MqttModule(0) {}
void init(Mqtt& mqtt) override;
void onConnect(Mqtt& mqtt) override;
void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) override;
void tick(Mqtt& mqtt) override;
void handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) override;
void handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) override;
};
}
#endif //HOMEKIT_LIB_MQTT_MODULE_RELAY_H

View File

@ -0,0 +1,11 @@
{
"name": "homekit_mqtt_module_relay",
"version": "1.0.6",
"build": {
"flags": "-I../../include"
},
"dependencies": {
"homekit_mqtt": "file://../../include/pio/libs/mqtt",
"homekit_relay": "file://../../include/pio/libs/relay"
}
}

View File

@ -4,7 +4,7 @@ namespace homekit::mqtt {
static const char TOPIC_TEMPHUM_DATA[] = "temphum/data";
void MqttTemphumModule::init(Mqtt &mqtt) {}
void MqttTemphumModule::onConnect(Mqtt &mqtt) {}
void MqttTemphumModule::tick(homekit::mqtt::Mqtt& mqtt) {
if (!tickElapsed())

View File

@ -19,7 +19,7 @@ private:
public:
MqttTemphumModule(temphum::Sensor* _sensor) : MqttModule(10), sensor(_sensor) {}
void init(Mqtt& mqtt) override;
void onConnect(Mqtt& mqtt) override;
void tick(Mqtt& mqtt) override;
};

View File

@ -0,0 +1,11 @@
{
"name": "homekit_mqtt_module_temphum",
"version": "1.0.10",
"build": {
"flags": "-I../../include"
},
"dependencies": {
"homekit_mqtt": "file://../../include/pio/libs/mqtt",
"homekit_temphum": "file://../../include/pio/libs/temphum"
}
}

View File

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@ -0,0 +1,19 @@
import importlib
__all__ = [
# web_api_client.py
'WebApiClient',
'RequestParams',
# config.py
'WebApiConfig'
]
def __getattr__(name):
if name in __all__:
file = 'config' if name == 'WebApiConfig' else 'web_api_client'
module = importlib.import_module(f'.{file}', __name__)
return getattr(module, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

Some files were not shown because too many files have changed in this diff Show More