Merge branch 'master' of ch1p.io:homekit
This commit is contained in:
commit
d3a295872c
12
.gitignore
vendored
12
.gitignore
vendored
@ -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
|
||||
|
@ -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
9
bin/__py_include.py
Normal 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)
|
||||
)
|
||||
])
|
@ -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'])
|
@ -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
|
||||
|
@ -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:
|
@ -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
31
bin/gpiorelayd.py
Executable 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...')
|
@ -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
27
bin/inverter_mqtt_util.py
Executable 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()
|
@ -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
141
bin/ipcam_capture.py
Executable 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())
|
@ -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=
|
@ -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
207
bin/lugovaya_pump_mqtt_bot.py
Executable 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
68
bin/mqtt_node_util.py
Executable 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
79
bin/openwrt_log_analyzer.py
Executable 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
|
@ -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)
|
@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
import __py_include
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('TODO')
|
@ -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,
|
@ -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
|
@ -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
297
bin/pump_bot.py
Executable 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
|
@ -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
164
bin/relay_mqtt_bot.py
Executable 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
134
bin/relay_mqtt_http_proxy.py
Executable 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()
|
@ -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()
|
@ -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()
|
@ -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'])
|
||||
|
@ -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
|
||||
|
@ -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,
|
@ -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
79
bin/temphum_mqtt_node.py
Executable 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...')
|
@ -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()
|
@ -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
|
@ -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__':
|
@ -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'])
|
@ -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
28
doc/openwrt_logger.md
Normal 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.
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -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,
|
12
include/pio/libs/main/library.json
Normal file
12
include/pio/libs/main/library.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
@ -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) {}
|
||||
|
||||
}
|
@ -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;
|
||||
};
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homekit_mqtt",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.11",
|
||||
"build": {
|
||||
"flags": "-I../../include"
|
||||
}
|
@ -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) {
|
@ -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;
|
||||
};
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
@ -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) {
|
@ -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;
|
||||
}
|
11
include/pio/libs/mqtt_module_ota/library.json
Normal file
11
include/pio/libs/mqtt_module_ota/library.json
Normal 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"
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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
|
||||
|
11
include/pio/libs/mqtt_module_relay/library.json
Normal file
11
include/pio/libs/mqtt_module_relay/library.json
Normal 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"
|
||||
}
|
||||
}
|
@ -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())
|
@ -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;
|
||||
};
|
||||
|
11
include/pio/libs/mqtt_module_temphum/library.json
Normal file
11
include/pio/libs/mqtt_module_temphum/library.json
Normal 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"
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.7 KiB |
19
include/py/homekit/api/__init__.py
Normal file
19
include/py/homekit/api/__init__.py
Normal 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
Loading…
x
Reference in New Issue
Block a user