Compare commits

..

1 Commits

Author SHA1 Message Date
Evgeny Zinoviev
2f1c00bed4 wip 2023-05-22 07:11:21 +03:00
453 changed files with 10359 additions and 113475 deletions

View File

@ -1,66 +0,0 @@
# Generated from CLion C/C++ Code Style settings
BasedOnStyle: LLVM
AccessModifierOffset: -4
AlignAfterOpenBracket: Align
AlignConsecutiveAssignments: None
AlignOperands: Align
AllowAllArgumentsOnNextLine: false
AllowAllConstructorInitializersOnNextLine: false
AllowAllParametersOfDeclarationOnNextLine: false
AllowShortBlocksOnASingleLine: Always
AllowShortCaseLabelsOnASingleLine: false
AllowShortFunctionsOnASingleLine: All
AllowShortIfStatementsOnASingleLine: Always
AllowShortLambdasOnASingleLine: All
AllowShortLoopsOnASingleLine: true
AlwaysBreakAfterReturnType: None
AlwaysBreakTemplateDeclarations: Yes
BreakBeforeBraces: Custom
BraceWrapping:
AfterCaseLabel: false
AfterClass: false
AfterControlStatement: Never
AfterEnum: false
AfterFunction: false
AfterNamespace: false
AfterUnion: false
BeforeCatch: false
BeforeElse: false
IndentBraces: false
SplitEmptyFunction: false
SplitEmptyRecord: true
BreakBeforeBinaryOperators: None
BreakBeforeTernaryOperators: true
BreakConstructorInitializers: BeforeColon
BreakInheritanceList: BeforeColon
ColumnLimit: 0
CompactNamespaces: false
ContinuationIndentWidth: 8
IndentCaseLabels: true
IndentPPDirectives: None
IndentWidth: 4
KeepEmptyLinesAtTheStartOfBlocks: true
MaxEmptyLinesToKeep: 2
NamespaceIndentation: All
ObjCSpaceAfterProperty: false
ObjCSpaceBeforeProtocolList: true
PointerAlignment: Left
ReflowComments: false
SpaceAfterCStyleCast: true
SpaceAfterLogicalNot: false
SpaceAfterTemplateKeyword: false
SpaceBeforeAssignmentOperators: true
SpaceBeforeCpp11BracedList: false
SpaceBeforeCtorInitializerColon: true
SpaceBeforeInheritanceColon: true
SpaceBeforeParens: ControlStatements
SpaceBeforeRangeBasedForLoopColon: false
SpaceInEmptyParentheses: false
SpacesBeforeTrailingComments: 0
SpacesInAngles: false
SpacesInCStyleCastParentheses: false
SpacesInContainerLiterals: false
SpacesInParentheses: false
SpacesInSquareBrackets: false
TabWidth: 4
UseTab: Never

15
.gitignore vendored
View File

@ -1,24 +1,19 @@
.idea .idea
.vscode
/venv /venv
/node_modules /node_modules
*.pyc *.pyc
config.def.h config.def.h
__pycache__ __pycache__
.DS_Store .DS_Store
/include/test/test_inverter_monitor.log /src/test/test_inverter_monitor.log
/youtrack-certificate /youtrack-certificate
/cpp /cpp
/test/test.py /src/test.py
/bin/test.py /esp32-cam/CameraWebServer/wifi_password.h
/arduino/ESP32CameraWebServer/wifi_password.h
cmake-build-* cmake-build-*
.pio .pio
platformio.ini
CMakeListsPrivate.txt CMakeListsPrivate.txt
/pio/*/CMakeLists.txt
/pio/*/CMakeListsPrivate.txt
/pio/*/.gitignore
*.swp *.swp
/localwebsite/vendor /localwebsite/vendor
@ -28,4 +23,4 @@ CMakeListsPrivate.txt
/localwebsite/test.php /localwebsite/test.php
/watchos/InfiniSolar/Pods /watchos/InfiniSolar/Pods
xcuserdata xcuserdata

View File

@ -0,0 +1,85 @@
<profile version="1.0">
<option name="myName" value="IDEAInspectionsProfile" />
<inspection_tool class="CheckTagEmptyBody" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="CssUnknownProperty" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myCustomPropertiesEnabled" value="true" />
<option name="myIgnoreVendorSpecificProperties" value="false" />
<option name="myCustomPropertiesList">
<value>
<list size="2">
<item index="0" class="java.lang.String" itemvalue="line-clamp" />
<item index="1" class="java.lang.String" itemvalue="animation" />
</list>
</value>
</option>
</inspection_tool>
<inspection_tool class="ES6ModulesDependencies" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="HtmlUnknownTarget" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredUrls">
<list>
<option value="http://localhost" />
<option value="http://127.0.0.1" />
<option value="http://0.0.0.0" />
<option value="http://www.w3.org/" />
<option value="http://json-schema.org/draft" />
<option value="http://java.sun.com/" />
<option value="http://xmlns.jcp.org/" />
<option value="http://javafx.com/javafx/" />
<option value="http://javafx.com/fxml" />
<option value="http://maven.apache.org/xsd/" />
<option value="http://maven.apache.org/POM/" />
<option value="http://www.springframework.org/schema/" />
<option value="http://www.springframework.org/tags" />
<option value="http://www.springframework.org/security/tags" />
<option value="http://www.thymeleaf.org" />
<option value="http://www.jboss.org/j2ee/schema/" />
<option value="http://www.jboss.com/xml/ns/" />
<option value="http://www.ibm.com/webservices/xsd" />
<option value="http://activemq.apache.org/schema/" />
<option value="http://schema.cloudfoundry.org/spring/" />
<option value="http://schemas.xmlsoap.org/" />
<option value="http://cxf.apache.org/schemas/" />
<option value="http://primefaces.org/ui" />
<option value="http://tiles.apache.org/" />
<option value="http://{{upstream}};" />
</list>
</option>
</inspection_tool>
<inspection_tool class="JSUnfilteredForInLoop" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="JSUnresolvedFunction" enabled="true" level="ERROR" enabled_by_default="true" />
<inspection_tool class="JSUnresolvedLibraryURL" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="JSUnresolvedVariable" enabled="true" level="ERROR" enabled_by_default="true">
<option name="myStrictlyCheckGlobalDefinitions" value="true" />
<option name="myStrictlyCheckProperties" value="false" />
</inspection_tool>
<inspection_tool class="JSXNamespaceValidation" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="LessResolvedByNameOnly" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="PackageJsonMismatchedDependency" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="PhpDefineCanBeReplacedWithConstInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="PhpIllegalPsrClassPathInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="PhpUndefinedClassInspection" enabled="true" level="ERROR" enabled_by_default="true" />
<inspection_tool class="PhpUndefinedConstantInspection" enabled="true" level="ERROR" enabled_by_default="true" />
<inspection_tool class="PhpUndefinedFieldInspection" enabled="true" level="ERROR" enabled_by_default="true" />
<inspection_tool class="PhpUndefinedFunctionInspection" enabled="true" level="ERROR" enabled_by_default="true" />
<inspection_tool class="PhpUndefinedGotoLabelInspection" enabled="true" level="ERROR" enabled_by_default="true" />
<inspection_tool class="PhpUndefinedMethodInspection" enabled="true" level="ERROR" enabled_by_default="true" />
<inspection_tool class="PhpUndefinedNamespaceInspection" enabled="true" level="ERROR" enabled_by_default="true" />
<inspection_tool class="PyPep8Inspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="E731" />
</list>
</option>
</inspection_tool>
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="N802" />
</list>
</option>
</inspection_tool>
<inspection_tool class="SqlNoDataSourceInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="SqlResolveInspection" enabled="false" level="ERROR" enabled_by_default="false" />
<inspection_tool class="bashproGoogleFileNameStyle" enabled="false" level="WARNING" enabled_by_default="false" />
</profile>

View File

@ -1,4 +1,4 @@
Copyright 2022-2024, Evgeny Zinoviev Copyright 2022, Evgeny Zinoviev
All rights reserved. All rights reserved.
Redistribution and use in source and binary forms, with or without modification, Redistribution and use in source and binary forms, with or without modification,

View File

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

View File

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

View File

@ -1 +0,0 @@
../include_homekit.py

View File

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

View File

@ -1,143 +0,0 @@
#!/usr/bin/env python3
import include_homekit
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 config as homekit_config
from homekit.linux import LinuxBoardsConfig
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'
protocol = 'tcp'
user, pw = ipcam_config.get_rtsp_creds()
ip = ipcam_config.get_camera_ip(cam)
path = ipcam_config.get_camera_type(cam).get_channel_url(channel)
ext = ipcam_config.get_camera_container(cam)
ffmpeg_command = ['ffmpeg', *debug_args,
'-rtsp_transport', protocol,
'-i', f'rtsp://{user}:{pw}@{ip}:554{path}',
'-c', 'copy',]
if worker_type == CaptureType.HLS:
ffmpeg_command.extend(['-bufsize', '1835k',
'-pix_fmt', 'yuv420p',
'-flags', '-global_header',
'-hls_time', '2',
'-hls_list_size', '3',
'-hls_flags', 'delete_segments',
os.path.join(get_hls_directory(cam, channel), 'live.m3u8')])
elif worker_type == CaptureType.RECORD:
ffmpeg_command.extend(['-f', 'segment',
'-strftime', '1',
'-segment_time', '00:10:00',
'-segment_atclocktime', '1',
os.path.join(get_recordings_path(cam), f'record_%Y-%m-%d-%H.%M.%S.{ext.value}')])
else:
raise ValueError(f'invalid worker type: {worker_type}')
while True:
try:
process = await asyncio.create_subprocess_exec(
*ffmpeg_command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
stdout_task = asyncio.create_task(read_output(process.stdout, prefix, sys.stdout))
stderr_task = asyncio.create_task(read_output(process.stderr, prefix, sys.stderr))
await asyncio.gather(stdout_task, stderr_task)
# check the return code of the process
if process.returncode != 0:
raise subprocess.CalledProcessError(process.returncode, ffmpeg_command)
except (FileNotFoundError, PermissionError, subprocess.CalledProcessError) as e:
# an error occurred, print the error message
error_message = f"Error occurred in {prefix}: {e}"
print(error_message, file=sys.stderr)
# sleep for 5 seconds before restarting the process
await asyncio.sleep(restart_delay)
async def run():
kwargs = {}
if worker_type == CaptureType.RECORD:
kwargs['filter_by_server'] = gethostname()
for cam in ipcam_config.get_all_cam_names(**kwargs):
for channel in channels:
task = asyncio.create_task(run_ffmpeg(cam, channel))
tasks.append(task)
try:
await asyncio.gather(*tasks)
except KeyboardInterrupt:
print('KeyboardInterrupt: stopping processes...', file=sys.stderr)
for task in tasks:
task.cancel()
# wait for subprocesses to terminate
await asyncio.gather(*tasks, return_exceptions=True)
# send termination signal to all subprocesses
for task in tasks:
process = task.get_stack()
if process:
process.send_signal(signal.SIGTERM)
if __name__ == '__main__':
capture_types = [t.value for t in CaptureType]
parser = ArgumentParser()
parser.add_argument('type', type=str, metavar='CAPTURE_TYPE', choices=tuple(capture_types),
help='capture type (variants: '+', '.join(capture_types)+')')
arg = homekit_config.load_app(no_config=True, parser=parser)
worker_type = CaptureType(arg['type'])
asyncio.run(run())

View File

@ -1,122 +0,0 @@
#!/usr/bin/env python3
import include_homekit
import hikvision, xmeye
from enum import Enum, auto
from typing import Optional
from argparse import ArgumentParser, ArgumentError
from homekit.util import validate_ipv4_or_hostname
from homekit.camera import IpcamConfig, CameraType
ipcam_config = IpcamConfig()
class Action(Enum):
GET_NTP = auto()
SET_NTP = auto()
def process_camera(host: str,
action: Action,
login: str,
password: str,
camera_type: CameraType,
ntp_server: Optional[str] = None):
if camera_type.is_hikvision():
client = hikvision.ISAPIClient(host)
try:
client.auth(login, password)
if action == Action.GET_NTP:
print(f'[{host}] {client.get_ntp_server()}')
return
client.set_ntp_server(ntp_server)
print(f'[{host}] done')
except hikvision.AuthError as e:
print(f'[{host}] ({str(e)})')
except hikvision.ResponseError as e:
print(f'[{host}] ({str(e)})')
elif camera_type.is_xmeye():
try:
client = xmeye.XMEyeCamera(hostname=host, username=login, password=password)
client.login()
if action == Action.GET_NTP:
print(f'[{host}] {client.get_ntp_server()}')
return
client.set_ntp_server(ntp_server)
print(f'[{host}] done')
except OSError as e:
print(f'[{host}] ({str(e)})')
def main():
camera_types = ['hikvision', 'xmeye']
parser = ArgumentParser()
parser.add_argument('--camera', type=str)
parser.add_argument('--camera-type', type=str, choices=camera_types)
parser.add_argument('--all', action='store_true')
parser.add_argument('--all-of-type', type=str, choices=camera_types)
parser.add_argument('--get-ntp-server', action='store_true')
parser.add_argument('--set-ntp-server', type=str)
parser.add_argument('--username', type=str)
parser.add_argument('--password', type=str)
args = parser.parse_args()
if args.all and args.all_of_type:
raise ArgumentError(None, 'you can\'t pass both --all and --all-of-type')
if not args.camera and not args.all and not args.all_of_type:
raise ArgumentError(None, 'either --all, --all-of-type <TYPE> or --camera <NUM> is required')
if not args.get_ntp_server and not args.set_ntp_server:
raise ArgumentError(None, 'either --get-ntp-server or --set-ntp-server is required')
action = Action.GET_NTP if args.get_ntp_server else Action.SET_NTP
login = args.username if args.username else ipcam_config['web_creds']['login']
password = args.password if args.password else ipcam_config['web_creds']['password']
if action == Action.SET_NTP:
if not args.set_ntp_server:
raise ArgumentError(None, '--set-ntp-server is required')
if not validate_ipv4_or_hostname(args.set_ntp_server):
raise ArgumentError(None, 'input ntp server is neither ip address nor a valid hostname')
kwargs = {}
if args.set_ntp_server:
kwargs['ntp_server'] = args.set_ntp_server
if not args.all and not args.all_of_type:
if not args.camera_type:
raise ArgumentError(None, '--camera-type is required')
if not ipcam_config.has_camera(int(args.camera)):
raise ArgumentError(None, f'invalid camera {args.camera}')
camera_host = ipcam_config.get_camera_ip(args.camera)
if args.camera_type == 'hikvision':
camera_type = CameraType.HIKVISION_264
elif args.camera_type == 'xmeye':
camera_type = CameraType.XMEYE
else:
raise ValueError('invalid --camera-type')
process_camera(camera_host, action, login, password, camera_type, **kwargs)
else:
for cam in ipcam_config.get_all_cam_names():
if not ipcam_config.is_camera_enabled(cam):
continue
cam_type = ipcam_config.get_camera_type(cam)
if args.all_of_type == 'hikvision' and not cam_type.is_hikvision():
continue
if args.all_of_type == 'xmeye' and not ipcam_config.get_camera_type(cam).is_xmeye():
continue
process_camera(ipcam_config.get_camera_ip(cam), action, login, password, cam_type, **kwargs)
if __name__ == '__main__':
main()

View File

@ -1,207 +0,0 @@
#!/usr/bin/env python3
import datetime
import include_homekit
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()

View File

@ -1,120 +0,0 @@
#!/usr/bin/env python3
import os.path
import include_homekit
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, MqttNodesConfig
from homekit.mqtt.module.relay import MqttRelayModule
from homekit.mqtt.module.ota import MqttOtaModule
mqtt_node: Optional[MqttNode] = None
mqtt: Optional[MqttWrapper] = None
relay_module: Optional[MqttOtaModule] = None
relay_val = None
ota_module: Optional[MqttRelayModule] = None
ota_val = False
no_wait = False
stop_loop = False
def on_mqtt_connect():
global stop_loop
if relay_module:
relay_module.switchpower(relay_val == 1)
if ota_val:
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)
if no_wait:
stop_loop = True
if __name__ == '__main__':
nodes_config = MqttNodesConfig()
node_names = nodes_config.get_nodes(only_names=True)
parser = ArgumentParser()
parser.add_argument('--node-id', type=str, required=True,
help='one of: '+', '.join(node_names))
parser.add_argument('--node-id-no-check', action='store_true',
help='when enabled, the script will not check for definition of the node in the mqtt_nodes.yaml config and will use the default password')
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('--push-ota', type=str, metavar='OTA_FILENAME',
help='push OTA, receives path to firmware.bin (not .elf!)')
parser.add_argument('--custom-ota-topic', type=str,
help='only needed for update very old devices')
parser.add_argument('--no-wait', action='store_true',
help='execute command and exit')
config.load_app(parser=parser, no_config=True)
arg = parser.parse_args()
if not arg.node_id_no_check and arg.node_id not in node_names:
raise ArgumentError(None, f'invalid node_id {arg.node_id}')
if arg.no_wait:
no_wait = True
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.add_connect_callback(on_mqtt_connect)
try:
node_password = nodes_config.get_node(arg.node_id)['password']
except KeyError as e:
if arg.node_id_no_check:
node_password = nodes_config['common']['password']
else:
raise e
mqtt_node = MqttNode(node_id=arg.node_id,
node_secret=node_password)
mqtt.add_node(mqtt_node)
# must-have modules
ota_kwargs = {}
if arg.custom_ota_topic:
ota_kwargs['custom_ota_topic'] = arg.custom_ota_topic
ota_module = mqtt_node.load_module('ota', **ota_kwargs)
ota_val = arg.push_ota
mqtt_node.load_module('diagnostics')
if arg.modules:
for m in arg.modules:
kwargs = {}
if m == 'relay' and MqttNodesConfig().node_uses_legacy_relay_power_payload(arg.node_id):
kwargs['legacy_topics'] = True
if m == 'temphum' and MqttNodesConfig().node_uses_legacy_temphum_data_payload(arg.node_id):
kwargs['legacy_payload'] = True
module_instance = mqtt_node.load_module(m, **kwargs)
if m == 'relay' and arg.switch_relay is not None:
relay_module = module_instance
relay_val = arg.switch_relay
try:
mqtt.connect_and_loop(loop_forever=False)
while not stop_loop:
sleep(0.1)
except KeyboardInterrupt:
pass
finally:
mqtt.disconnect()

View File

@ -1,87 +0,0 @@
#!/usr/bin/env python3
import include_homekit
import sys
import asyncio
import logging
from enum import Enum
from typing import Optional, Union
from telegram import ReplyKeyboardMarkup, User
from time import time
from datetime import datetime
from aiohttp import web
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 import http
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
class MqttSensorsListenerConfig(AppConfigUnit):
NAME = 'mqtt_sensors_listeners'
@classmethod
def schema(cls) -> Optional[dict]:
return {
'http_listen': cls._addr_schema(required=True),
'nodes': {
'type': 'list',
'required': True,
'empty': False,
'schema': {
'type': 'string',
'allowed': MqttNodesConfig.get_nodes(only_names=True)
}
}
}
# config.load_app(MqttSensorsListenerConfig)
routes = web.RouteTableDef()
logger = logging.getLogger(__name__)
mqtt: MqttWrapper
mqtt_node: MqttNode
mqtt_relay_module: Union[MqttRelayModule, MqttModule]
time_format = '%d.%m.%Y, %H:%M:%S'
class SensorStatus:
last_time: int = 0
last_boot_time: int = 0
class TemphumStatus(SensorStatus):
temp: float = 0.0
rh: float = 0.0
class RelayStatus(SensorStatus):
opened = False
class WateringMcuStatus(RelayStatus):
ambient_temp: float = 0.0
ambient_rh: float = 0.0
@routes.get('/sensors')
async def http_sensors_get(req: web.Request):
return await http.ajax_ok({
'hello': 'world'
})
if __name__ == '__main__':
mqtt = MqttWrapper(client_id='mqtt_sensors_listener',
randomize_client_id=is_development_mode())
http.serve(addr=config.app_config['http_addr'],
routes=routes)

View File

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

View File

@ -1,73 +0,0 @@
#!/usr/bin/env python3
import os
import include_homekit
from datetime import datetime
from typing import Tuple, List, Optional
from argparse import ArgumentParser
from homekit.config import config, AppConfigUnit
from homekit.database import SimpleState
from homekit.api import WebApiClient
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]:
space_pos = line.index(' ')
date = line[:space_pos]
rest = line[space_pos+1:]
return (
int(datetime.strptime(date, "%Y-%m-%dT%H:%M:%S%z").timestamp()),
rest
)
if __name__ == '__main__':
parser = ArgumentParser()
parser.add_argument('--file', type=str, required=True,
help='openwrt log file')
parser.add_argument('--access-point', type=int, required=True,
help='access point number')
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
with open(arg.file, 'r') as f:
if state['seek']:
# jump to the latest read position
f.seek(state['seek'])
# read till the end of the file
content = f.read()
# save new position
state['seek'] = f.tell()
state['size'] = fsize
lines: List[Tuple[int, str]] = []
if content != '':
for line in content.strip().split('\n'):
if not line:
continue
try:
lines.append(parse_line(line))
except ValueError:
lines.append((0, line))
api = WebApiClient()
api.log_openwrt(lines, arg.access_point)

View File

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

View File

@ -1,140 +0,0 @@
#!/usr/bin/env python3
import os
import yaml
import re
import include_homekit
from argparse import ArgumentParser, ArgumentError
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:
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)
def bsd_walk(product_config: dict,
f: callable):
try:
for define_name, define_extra_params in product_config['build_specific_defines'].items():
define_name = re.sub(r'^CONFIG_', '', define_name)
kwargs = {}
if isinstance(define_extra_params, dict):
kwargs = define_extra_params
f(define_name, **kwargs)
except KeyError:
pass
# 'bsd' means 'build_specific_defines'
def bsd_parser(product_config: dict,
parser: ArgumentParser):
def f(define_name, **kwargs):
arg_kwargs = {}
define_name = define_name.lower().replace('_', '-')
if 'type' in kwargs:
if kwargs['type'] in ('str', 'enum'):
arg_kwargs['type'] = str
if kwargs['type'] == 'enum' and 'list_config_key' in kwargs:
if not isinstance(product_config[kwargs['list_config_key']], list):
raise TypeError(f'product_config[{kwargs["list_config_key"]}] enum is not list')
if not product_config[kwargs['list_config_key']]:
raise ValueError(f'product_config[{kwargs["list_config_key"]}] enum cannot be empty')
arg_kwargs['choices'] = product_config[kwargs['list_config_key']]
if isinstance(product_config[kwargs['list_config_key']][0], int):
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'
if 'required' not in arg_kwargs:
arg_kwargs['required'] = True
parser.add_argument(f'--{define_name}', **arg_kwargs)
bsd_walk(product_config, f)
def bsd_get(product_config: dict,
arg: object):
defines = {}
enums = []
def f(define_name, **kwargs):
attr_name = define_name.lower()
attr_value = getattr(arg, attr_name)
if 'type' in kwargs:
if kwargs['type'] == 'enum':
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
if __name__ == '__main__':
products = get_products()
# first, get the product
product_parser = ArgumentParser(add_help=False)
product_parser.add_argument('--product', type=str, choices=products, required=True,
help='PIO product name')
arg, _ = product_parser.parse_known_args()
if not arg.product:
product = os.path.basename(os.path.realpath(os.getcwd()))
if product not in products:
raise ArgumentError(None, 'invalid product')
else:
product = arg.product
product_config = get_config(product)
# then everything else
parser = ArgumentParser(parents=[product_parser])
parser.add_argument('--target', type=str, required=True, choices=product_config['targets'],
help='PIO build target')
parser.add_argument('--platform', default='espressif8266', type=str)
parser.add_argument('--framework', default='arduino', type=str)
parser.add_argument('--upload-port', default='/dev/ttyUSB0', type=str)
parser.add_argument('--monitor-speed', default=115200)
parser.add_argument('--debug', action='store_true')
parser.add_argument('--debug-network', action='store_true')
bsd_parser(product_config, parser)
arg = parser.parse_args()
if arg.target not in product_config['targets']:
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,
build_specific_defines_enums=bsd_enums,
platform=arg.platform,
framework=arg.framework,
upload_port=arg.upload_port,
monitor_speed=arg.monitor_speed,
debug=arg.debug,
debug_network=arg.debug_network)
print(ini)

View File

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

View File

@ -1,164 +0,0 @@
#!/usr/bin/env python3
import sys
import include_homekit
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()

View File

@ -1,139 +0,0 @@
#!/usr/bin/env python3
import logging
import include_homekit
from aiohttp import web
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)
# -=-=-=-=-=-=- #
# Web interface #
# -=-=-=-=-=-=- #
routes = web.RouteTableDef()
async def _relay_on_off(self,
enable: Optional[bool],
req: web.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()
@routes.get('/relay/{id}/on')
async def relay_on(self, req: web.Request):
return await self._relay_on_off(True, req)
@routes.get('/relay/{id}/off')
async def relay_off(self, req: web.Request):
return await self._relay_on_off(False, req)
@routes.get('/relay/{id}/toggle')
async def relay_toggle(self, req: web.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)
try:
http.serve(config.app_config['listen_addr'], routes=routes)
except KeyboardInterrupt:
mqtt.disconnect()

View File

@ -1,96 +0,0 @@
#!/usr/bin/env python3
import include_homekit
import asyncio
import logging
from datetime import datetime
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from typing import Optional
from argparse import ArgumentParser
from homekit.config import config
from homekit.mqtt import MqttNodesConfig, MqttNode, MqttWrapper
from homekit.mqtt.module.temphum import MqttTempHumModule, MqttTemphumDataPayload, DATA_TOPIC
from homekit.temphum import SensorType, BaseSensor
from homekit.temphum.i2c import create_sensor
_logger = logging.getLogger(__name__)
_sensor: Optional[BaseSensor] = None
_lock = asyncio.Lock()
_mqtt: MqttWrapper
_mqtt_ndoe: MqttNode
_mqtt_temphum: MqttTempHumModule
_stopped = True
_scheduler = AsyncIOScheduler()
_sched_task_added = False
async def get_measurements():
async with _lock:
temp = _sensor.temperature()
rh = _sensor.humidity()
return rh, temp
def on_mqtt_connect():
global _stopped, _sched_task_added
_stopped = False
if not _sched_task_added:
_scheduler.add_job(on_sched_task, 'interval', seconds=60, next_run_time=datetime.now())
_scheduler.start()
_sched_task_added = True
elif _scheduler:
_scheduler.resume()
def on_mqtt_disconnect():
global _stopped
_stopped = True
if _scheduler:
_scheduler.pause()
async def on_sched_task():
if _stopped:
return
rh, temp = await get_measurements()
payload = MqttTemphumDataPayload(temp=temp, rh=rh)
_mqtt_node.publish(DATA_TOPIC, payload.pack())
if __name__ == '__main__':
parser = ArgumentParser()
parser.add_argument('--node-id',
type=str,
required=True,
choices=MqttNodesConfig().get_nodes(only_names=True),
help='node id must be defined in the config')
args = config.load_app(parser=parser)
node_cfg = MqttNodesConfig()[args.node_id]
_sensor = create_sensor(SensorType(node_cfg['temphum']['module']),
int(node_cfg['temphum']['i2c_bus']))
_mqtt = MqttWrapper(client_id=args.node_id)
_mqtt.add_connect_callback(on_mqtt_connect)
_mqtt.add_disconnect_callback(on_mqtt_disconnect)
_mqtt_node = MqttNode(node_id=args.node_id,
node_secret=MqttNodesConfig.get_node(args.node_id)['password'])
_mqtt.add_node(_mqtt_node)
_mqtt_temphum = _mqtt_node.load_module('temphum')
try:
_mqtt.connect_and_loop(loop_forever=True)
except (KeyboardInterrupt, SystemExit):
if _scheduler:
_scheduler.shutdown()
_logger.info('Exiting...')
finally:
_mqtt.disconnect()

View File

@ -1,75 +0,0 @@
#!/usr/bin/env python3
import include_homekit
import re
from html import escape
from typing import Optional
from homekit.config import AppConfigUnit, config
from homekit.modem import ModemsConfig, E3372
from homekit.database import MySQLHomeDatabase
from homekit.telegram import send_message
db: Optional[MySQLHomeDatabase] = None
class VkSmsCheckerConfig(AppConfigUnit):
NAME = 'vk_sms_checker'
@classmethod
def schema(cls) -> Optional[dict]:
return {
'modem': {'type': 'string', 'required': True}
}
@staticmethod
def custom_validator(data):
if data['modem'] not in ModemsConfig():
raise ValueError('invalid modem')
def get_last_time() -> int:
cur = db.cursor()
cur.execute("SELECT last_message_time FROM vk_sms LIMIT 1")
return int(cur.fetchone()[0])
def set_last_time(timestamp: int) -> None:
cur = db.cursor()
cur.execute("UPDATE vk_sms SET last_message_time=%s", (timestamp,))
db.commit()
def check_sms():
modem = ModemsConfig()[config.app_config['modem']]
cl = E3372(modem['ip'], legacy_token_auth=modem['legacy_auth'])
messages = cl.sms_list()
messages.reverse()
last_time = get_last_time()
new_last_time = None
results = []
if not messages:
return
for m in messages:
if m['UnixTime'] <= last_time:
continue
new_last_time = m['UnixTime']
if re.match(r'^vk', m['Phone'], flags=re.IGNORECASE) or re.match(r'vk', m['Content'], flags=re.IGNORECASE):
results.append(m)
if results:
for m in results:
text = '<b>'+escape(m['Phone'])+'</b> ('+m['Date']+')'
text += "\n"+escape(m['Content'])
send_message(text=text, chat='vk_sms_checker')
if new_last_time:
set_last_time(new_last_time)
if __name__ == '__main__':
db = MySQLHomeDatabase()
config.load_app(VkSmsCheckerConfig)
check_sms()

View File

@ -1,610 +0,0 @@
#!/usr/bin/env python3
import include_homekit
import asyncio
import logging
import jinja2
import aiohttp_jinja2
import json
import re
import inverterd
import phonenumbers
import time
import os.path
from io import StringIO
from aiohttp import web
from typing import Optional, Union
from urllib.parse import quote_plus
from contextvars import ContextVar
from homekit.config import config, AppConfigUnit, is_development_mode, Translation, Language
from homekit.camera import IpcamConfig
from homekit.util import homekit_path, filesize_fmt, seconds_to_human_readable_string, json_serial, validate_ipv4
from homekit.modem import E3372, ModemsConfig, MacroNetWorkType
from homekit.inverter.config import InverterdConfig
from homekit.relay.sunxi_h3_client import RelayClient
from homekit import openwrt, http
class WebKbnConfig(AppConfigUnit):
NAME = 'web_kbn'
@classmethod
def schema(cls) -> Optional[dict]:
return {
'listen_addr': cls._addr_schema(required=True),
'assets_public_path': {'type': 'string'},
'pump_addr': cls._addr_schema(required=True),
'hls_local_host': cls._addr_schema(required=True, only_ip=True),
'inverter_grafana_url': {'type': 'string'},
'sensors_grafana_url': {'type': 'string'},
}
# files marked with + at the beginning are included by default
common_static_files = {
'+bootstrap.min.css': 1,
'+bootstrap.bundle.min.js': 1,
'+polyfills.js': 1,
'+app.js': 10,
'+app.css': 6,
'hls.js': 1
}
routes = web.RouteTableDef()
logger = logging.getLogger(__name__)
lang_context_var = ContextVar('lang', default=Translation.DEFAULT_LANGUAGE)
def get_js_link(file, version) -> str:
if is_development_mode():
version = int(time.time())
file += f'?version={version}'
return f'<script src="{config.app_config["assets_public_path"]}/{file}" type="text/javascript"></script>'
def get_css_link(file, version) -> str:
if is_development_mode():
version = int(time.time())
file += f'?version={version}'
return f'<link rel="stylesheet" type="text/css" href="{config.app_config["assets_public_path"]}/{file}">'
def get_head_static(additional_files=None) -> str:
buf = StringIO()
if additional_files is None:
additional_files = []
for file, version in common_static_files.items():
enabled_by_default = file.startswith('+')
if not enabled_by_default and file not in additional_files:
continue
if enabled_by_default:
file = file[1:]
if file.endswith('.js'):
buf.write(get_js_link(file, version))
else:
buf.write(get_css_link(file, version))
return buf.getvalue()
def get_modem_client(modem_cfg: dict) -> E3372:
return E3372(modem_cfg['ip'], legacy_token_auth=modem_cfg['legacy_auth'])
def get_modem_data(modem_cfg: dict, get_raw=False) -> Union[dict, tuple]:
cl = get_modem_client(modem_cfg)
signal = cl.device_signal
status = cl.monitoring_status
traffic = cl.traffic_stats
if get_raw:
device_info = cl.device_information
dialup_conn = cl.dialup_connection
return signal, status, traffic, device_info, dialup_conn
else:
network_type_label = re.sub('^MACRO_NET_WORK_TYPE(_EX)?_', '', MacroNetWorkType(int(status['CurrentNetworkType'])).name)
return {
'type': network_type_label,
'level': int(status['SignalIcon']) if 'SignalIcon' in status else 0,
'rssi': signal['rssi'],
'sinr': signal['sinr'],
'connected_time': seconds_to_human_readable_string(int(traffic['CurrentConnectTime'])),
'downloaded': filesize_fmt(int(traffic['CurrentDownload'])),
'uploaded': filesize_fmt(int(traffic['CurrentUpload']))
}
def get_pump_client() -> RelayClient:
addr = config.app_config['pump_addr']
cl = RelayClient(host=addr.host, port=addr.port)
cl.connect()
return cl
def get_inverter_client() -> inverterd.Client:
cl = inverterd.Client(host=InverterdConfig()['remote_addr'].host)
cl.connect()
cl.format(inverterd.Format.JSON)
return cl
def get_inverter_data() -> tuple:
cl = get_inverter_client()
status = json.loads(cl.exec('get-status'))['data']
rated = json.loads(cl.exec('get-rated'))['data']
power_direction = status['battery_power_direction'].lower()
power_direction = re.sub('ge$', 'ging', power_direction)
charging_rate = ''
if power_direction == 'charging':
charging_rate = ' @ %s %s' % (
status['battery_charge_current']['value'],
status['battery_charge_current']['unit'])
elif power_direction == 'discharging':
charging_rate = ' @ %s %s' % (
status['battery_discharge_current']['value'],
status['battery_discharge_current']['unit'])
html = '<b>Battery:</b> %s %s' % (
status['battery_voltage']['value'],
status['battery_voltage']['unit'])
html += ' (%s%s, ' % (
status['battery_capacity']['value'],
status['battery_capacity']['unit'])
html += '%s%s)' % (power_direction, charging_rate)
html += "\n"
html += '<b>Load:</b> %s %s' % (
status['ac_output_active_power']['value'],
status['ac_output_active_power']['unit'])
html += ' (%s%%)' % (status['output_load_percent']['value'],)
if status['pv1_input_power']['value'] > 0:
html += "\n"
html += '<b>Input power:</b> %s %s' % (
status['pv1_input_power']['value'],
status['pv1_input_power']['unit'])
if status['grid_voltage']['value'] > 0 or status['grid_freq']['value'] > 0:
html += "\n"
html += '<b>AC input:</b> %s %s' % (
status['grid_voltage']['value'],
status['grid_voltage']['unit'])
html += ', %s %s' % (
status['grid_freq']['value'],
status['grid_freq']['unit'])
html += "\n"
html += '<b>Priority:</b> %s' % (rated['output_source_priority'],)
html = html.replace("\n", '<br>')
return status, rated, html
def get_current_upstream() -> str:
r = openwrt.get_default_route()
logger.info(f'default route: {r}')
mc = ModemsConfig()
for k, v in mc.items():
if 'gateway_ip' in v and v['gateway_ip'] == r:
r = v['ip']
break
upstream = None
for k, v in mc.items():
if r == v['ip']:
upstream = k
if not upstream:
raise RuntimeError('failed to determine current upstream!')
return upstream
def get_preferred_lang(req: web.Request) -> Language:
lang_cookie = req.cookies.get('lang', None)
if lang_cookie is None:
return Translation.DEFAULT_LANGUAGE
try:
return Language(lang_cookie)
except ValueError:
logger.debug(f"unsupported lang_cookie value: {lang_cookie}")
return Translation.DEFAULT_LANGUAGE
@web.middleware
async def language_middleware(request, handler):
lang_context_var.set(get_preferred_lang(request))
return await handler(request)
def lang(key, unit='web_kbn'):
strings = Translation(unit)
if isinstance(key, str) and '.' in key:
return strings.get(lang_context_var.get()).get(key)
else:
return strings.get(lang_context_var.get())[key]
async def render(req: web.Request,
template_name: str,
title: Optional[str] = None,
context: Optional[dict] = None,
assets: Optional[list] = None):
if context is None:
context = {}
context = {
**context,
'head_static': get_head_static(assets),
'user_lang': lang_context_var.get().value
}
if title is not None:
context['title'] = title
response = aiohttp_jinja2.render_template(template_name+'.j2', req, context=context)
return response
@routes.get('/')
async def index0(req: web.Request):
raise web.HTTPFound('main.cgi')
@routes.get('/main.cgi')
async def index(req: web.Request):
tabs = ['zones', 'list']
tab = req.query.get('tab', None)
if tab and (tab not in tabs or tab == tabs[0]):
raise web.HTTPFound('main.cgi')
if tab is None:
tab = tabs[0]
ctx = {}
for k in 'inverter', 'sensors':
ctx[f'{k}_grafana_url'] = config.app_config[f'{k}_grafana_url']
cc = IpcamConfig()
ctx['camzones'] = cc['zones'].keys()
ctx['allcams'] = cc.get_all_cam_names()
ctx['lang_enum'] = Language
ctx['lang_selected'] = lang_context_var.get()
ctx['tab_selected'] = tab
ctx['tabs'] = tabs
return await render(req, 'index',
title=lang('sitename'),
context=ctx)
@routes.get('/modems.cgi')
async def modems(req: web.Request):
return await render(req, 'modems',
title=lang('modem_statuses'),
context=dict(modems=ModemsConfig(),
modems_js_list=[key for key, value in ModemsConfig().items() if value['type'] == 'e3372']))
@routes.get('/modems_info.ajx')
async def modems_ajx(req: web.Request):
mc = ModemsConfig()
modem = req.query.get('id', None)
if modem not in mc.keys():
raise ValueError('invalid modem id')
modem_cfg = mc.get(modem)
if modem_cfg['type'] != 'e3372':
raise ValueError('invalid modem type')
loop = asyncio.get_event_loop()
modem_data = await loop.run_in_executor(None, lambda: get_modem_data(modem_cfg))
html = aiohttp_jinja2.render_string('modem_data.j2', req, context=dict(
modem_data=modem_data,
modem=modem
))
return http.ajax_ok({'html': html})
@routes.get('/modems_verbose.cgi')
async def modems_verbose(req: web.Request):
modem = req.query.get('id', None)
if modem not in ModemsConfig().keys():
raise ValueError('invalid modem id')
modem_cfg = ModemsConfig().get(modem)
if modem_cfg['type'] != 'e3372':
raise ValueError('invalid modem type')
loop = asyncio.get_event_loop()
signal, status, traffic, device, dialup_conn = await loop.run_in_executor(None, lambda: get_modem_data(modem_cfg, True))
data = [
['Signal', signal],
['Connection', status],
['Traffic', traffic],
['Device info', device],
['Dialup connection', dialup_conn]
]
modem_name = Translation('modems').get(lang_context_var.get())[modem]['full']
return await render(req, 'modem_verbose',
title=lang('modem_verbose_info_about_modem') % (modem_name,),
context=dict(data=data, modem_name=modem_name))
@routes.get('/sms.cgi')
async def sms(req: web.Request):
modem = req.query.get('id', list(ModemsConfig().keys())[0])
is_outbox = int(req.query.get('outbox', 0)) == 1
error = req.query.get('error', None)
sent = int(req.query.get('sent', 0)) == 1
input_modem = ModemsConfig()[modem]
if input_modem['type'] != 'e3372':
raise ValueError('invalid modem')
cl = get_modem_client(input_modem)
messages = cl.sms_list(1, 20, is_outbox)
return await render(req, 'sms',
title=lang('sms_page_title') % (lang('sms_outbox') if is_outbox else lang('sms_inbox'), modem),
context=dict(
modems=ModemsConfig(),
selected_modem=modem,
is_outbox=is_outbox,
error=error,
is_sent=sent,
messages=messages
))
@routes.post('/sms.cgi')
async def sms_post(req: web.Request):
modem = req.query.get('id', list(ModemsConfig().keys())[0])
is_outbox = int(req.query.get('outbox', 0)) == 1
fd = await req.post()
phone = fd.get('phone', None)
text = fd.get('text', None)
return_url = f'sms.cgi?id={modem}&outbox={int(is_outbox)}'
phone = re.sub(r'\s+', '', phone)
if len(phone) > 4:
country = None
if not phone.startswith('+'):
country = 'RU'
number = phonenumbers.parse(phone, country)
if not phonenumbers.is_valid_number(number):
raise web.HTTPFound(f'{return_url}&error=Неверный+номер')
phone = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
cl = get_modem_client(ModemsConfig()[modem])
cl.sms_send(phone, text)
raise web.HTTPFound(return_url)
@routes.get('/inverter.cgi')
async def inverter(req: web.Request):
action = req.query.get('do', None)
if action == 'set-osp':
val = req.query.get('value')
if val not in ('sub', 'sbu'):
raise ValueError('invalid osp value')
cl = get_inverter_client()
cl.exec('set-output-source-priority',
arguments=(val.upper(),))
raise web.HTTPFound('inverter.cgi')
status, rated, html = await asyncio.get_event_loop().run_in_executor(None, get_inverter_data)
return await render(req, 'inverter',
title=lang('inverter'),
context=dict(status=status, rated=rated, html=html))
@routes.get('/inverter.ajx')
async def inverter_ajx(req: web.Request):
status, rated, html = await asyncio.get_event_loop().run_in_executor(None, get_inverter_data)
return http.ajax_ok({'html': html})
@routes.get('/pump.cgi')
async def pump(req: web.Request):
# TODO
# these are blocking calls
# should be rewritten using aio
cl = get_pump_client()
action = req.query.get('set', None)
if action in ('on', 'off'):
getattr(cl, action)()
raise web.HTTPFound('pump.cgi')
status = cl.status()
return await render(req, 'pump',
title=lang('pump'),
context=dict(status=status))
@routes.get('/cams.cgi')
async def cams(req: web.Request):
cc = IpcamConfig()
cam = req.query.get('id', None)
zone = req.query.get('zone', None)
debug_hls = bool(req.query.get('debug_hls', False))
debug_video_events = bool(req.query.get('debug_video_events', False))
if cam is not None:
if not cc.has_camera(int(cam)):
raise ValueError('invalid camera id')
cams = [int(cam)]
mode = {'type': 'single', 'cam': int(cam)}
elif zone is not None:
if not cc.has_zone(zone):
raise ValueError('invalid zone')
cams = cc['zones'][zone]
mode = {'type': 'zone', 'zone': zone}
else:
cams = cc.get_all_cam_names()
mode = {'type': 'all'}
if req.headers.get('Host').endswith('.manor.id'):
hls_pfx = 'https://'+req.headers.get('Host')
hls_pfx += re.sub(r'/home/?$', '/ipcam/', os.path.dirname(req.headers.get('X-Real-URI')))
else:
hls_pfx = 'http://'+str(config.app_config['hls_local_host'])+'/ipcam/'
js_config = {
'pfx': hls_pfx,
# 'host': config.app_config['hls_local_host'],
# 'proto': 'http',
'cams': cams,
'hlsConfig': {
'opts': {
'startPosition': -1,
# https://github.com/video-dev/hls.js/issues/3884#issuecomment-842380784
'liveSyncDuration': 2,
'liveMaxLatencyDuration': 3,
'maxLiveSyncPlaybackRate': 2,
'liveDurationInfinity': True
},
'debugVideoEvents': debug_video_events,
'debug': debug_hls
}
}
return await render(req, 'cams',
title=lang('cams'),
assets=['hls.js'],
context=dict(
mode=mode,
js_config=js_config,
))
@routes.get('/routing_main.cgi')
async def routing_main(req: web.Request):
upstream = get_current_upstream()
set_upstream_to = req.query.get('set-upstream-to', None)
mc = ModemsConfig()
if set_upstream_to and set_upstream_to in mc and set_upstream_to != upstream:
modem = mc[set_upstream_to]
new_upstream = str(modem['gateway_ip'] if 'gateway_ip' in modem else modem['ip'])
openwrt.set_upstream(new_upstream)
raise web.HTTPFound('routing_main.cgi')
context = dict(
upstream=upstream,
selected_tab='main',
modems=mc.keys()
)
return await render(req, 'routing_main', title=lang('routing'), context=context)
@routes.get('/routing_rules.cgi')
async def routing_rules(req: web.Request):
mc = ModemsConfig()
action = req.query.get('action', None)
error = req.query.get('error', None)
set_name = req.query.get('set', None)
ip = req.query.get('ip', None)
def validate_input():
# validate set
if not set_name or set_name not in mc:
raise ValueError(f'invalid set \'{set_name}\'')
# validate ip
if not isinstance(ip, str):
raise ValueError('invalid ip')
slash_pos = None
try:
slash_pos = ip.index('/')
except ValueError:
pass
if slash_pos is not None:
ip_without_mask = ip[0:slash_pos]
else:
ip_without_mask = ip
if not validate_ipv4(ip_without_mask):
raise ValueError(f'invalid ip \'{ip}\'')
base_url = 'routing_rules.cgi'
if action in ('add', 'del'):
try:
validate_input()
except ValueError as e:
raise web.HTTPFound(f'{base_url}?error='+quote_plus(str(e)))
f = getattr(openwrt, f'ipset_{action}')
output = f(set_name, ip)
url = base_url
if output != '':
url += '?error='+quote_plus(output)
raise web.HTTPFound(url)
ipsets = openwrt.ipset_list_all()
context = dict(
sets=ipsets,
selected_tab='rules',
error=error
)
return await render(req, 'routing_rules',
title=lang('routing') + ' // ' + lang('routing_rules'),
context=context)
@routes.get('/routing_dhcp.cgi')
async def routing_dhcp(req: web.Request):
leases = openwrt.get_dhcp_leases()
return await render(req, 'routing_dhcp',
title=lang('routing') + ' // DHCP',
context=dict(leases=leases, selected_tab='dhcp'))
@routes.get('/debug.cgi')
async def debug(req: web.Request):
info = dict(
headers=dict(req.headers),
host=req.headers.get('Host'),
url=str(req.url),
method=req.method,
)
return http.ajax_ok(info)
def init_web_app(app: web.Application):
app.middlewares.append(language_middleware)
aiohttp_jinja2.setup(
app,
loader=jinja2.FileSystemLoader(homekit_path('web', 'kbn_templates')),
autoescape=jinja2.select_autoescape(['html', 'xml']),
)
env = aiohttp_jinja2.get_env(app)
# @pass_context is used only to prevent jinja2 from caching the result of lang filter results of constant values.
# as of now i don't know a better way of doing it
@jinja2.pass_context
def filter_lang(ctx, key, unit='web_kbn'):
return lang(key, unit)
env.filters['tojson'] = lambda obj: json.dumps(obj, separators=(',', ':'), default=json_serial)
env.filters['lang'] = filter_lang
app.router.add_static('/assets/', path=homekit_path('web', 'kbn_assets'))
if __name__ == '__main__':
config.load_app(WebKbnConfig)
http.serve(addr=config.app_config['listen_addr'],
routes=routes,
before_start=init_web_app)

View File

@ -1,4 +1,4 @@
Debian packages: Debian packages:
``` ```
apt-get install git cmake build-essential python3-dev python3-wheel python3-pip python3-build python3-yaml python3-toml python3-psutil python3-aiohttp python3-requests python3-apscheduler python3-smbus traceroute tcpdump apt-get install git cmake build-essential python3-dev python3-wheel python3-pip python3-build python3-yaml python3-toml python3-psutil python3-aiohttp python3-requests python3-apscheduler python3-smbus
``` ```

7
doc/localwebsite.md Normal file
View File

@ -0,0 +1,7 @@
## Dependencies
```
apt install nginx-extras php-fpm php-mbstring php-sqlite3 php-curl php-simplexml php-gmp composer
```

View File

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

View File

@ -1 +0,0 @@
#define ARRAY_SIZE(X) sizeof((X))/sizeof((X)[0])

View File

@ -1,8 +0,0 @@
{
"name": "homekit_config",
"version": "1.0.2",
"build": {
"flags": "-I../../include"
}
}

View File

@ -1,8 +0,0 @@
{
"name": "homekit_http_server",
"version": "1.0.3",
"build": {
"flags": "-I../../include"
}
}

View File

@ -1,27 +0,0 @@
#include "led.h"
namespace homekit::led {
void Led::on_off(uint16_t delay_ms, bool last_delay) const {
on();
delay(delay_ms);
off();
if (last_delay)
delay(delay_ms);
}
void Led::blink(uint8_t count, uint16_t delay_ms) const {
for (uint8_t i = 0; i < count; i++) {
on_off(delay_ms, i < count-1);
}
}
#ifdef CONFIG_TARGET_NODEMCU
const Led* board_led = new Led(CONFIG_BOARD_LED_GPIO);
#endif
const Led* mcu_led = new Led(CONFIG_MCU_LED_GPIO);
}

View File

@ -1,33 +0,0 @@
#ifndef HOMEKIT_LIB_LED_H
#define HOMEKIT_LIB_LED_H
#include <Arduino.h>
#include <stdint.h>
namespace homekit::led {
class Led {
private:
uint8_t _pin;
public:
explicit Led(uint8_t pin) : _pin(pin) {
pinMode(_pin, OUTPUT);
off();
}
inline void off() const { digitalWrite(_pin, HIGH); }
inline void on() const { digitalWrite(_pin, LOW); }
void on_off(uint16_t delay_ms, bool last_delay = false) const;
void blink(uint8_t count, uint16_t delay_ms) const;
};
#ifdef CONFIG_TARGET_NODEMCU
extern const Led* board_led;
#endif
extern const Led* mcu_led;
}
#endif //HOMEKIT_LIB_LED_H

View File

@ -1,8 +0,0 @@
{
"name": "homekit_led",
"version": "1.0.8",
"build": {
"flags": "-I../../include"
}
}

View File

@ -1,52 +0,0 @@
#ifndef HOMEKIT_LIB_MAIN_H
#define HOMEKIT_LIB_MAIN_H
#include <Arduino.h>
#include <ESP8266WiFi.h>
#include <DNSServer.h>
#include <Ticker.h>
#include <Wire.h>
#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>
#include <functional>
namespace homekit::main {
#ifndef CONFIG_TARGET_ESP01
#ifndef CONFIG_NO_RECOVERY
enum class WorkingMode {
RECOVERY, // AP mode, http server with configuration
NORMAL, // MQTT client
};
extern enum WorkingMode working_mode;
#endif
#endif
enum class WiFiConnectionState {
WAITING = 0,
JUST_CONNECTED = 1,
CONNECTED = 2
};
struct LoopConfig {
std::function<void(mqtt::Mqtt&)> onMqttCreated;
};
void setup();
void loop(LoopConfig* config);
}
#endif //HOMEKIT_LIB_MAIN_H

View File

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

View File

@ -1,26 +0,0 @@
#include "./module.h"
#include <homekit/logging.h>
namespace homekit::mqtt {
bool MqttModule::tickElapsed() {
if (!tickSw.elapsed(tickInterval*1000))
return false;
tickSw.save();
return true;
}
void MqttModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t* payload, size_t length,
size_t index, size_t total) {
if (length != total)
PRINTLN("mqtt: received partial message, not supported");
// TODO
}
void MqttModule::handleOnPublish(uint16_t packetId) {}
void MqttModule::onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) {}
}

View File

@ -1,56 +0,0 @@
#ifndef HOMEKIT_LIB_MQTT_MODULE_H
#define HOMEKIT_LIB_MQTT_MODULE_H
#include "./mqtt.h"
#include "./payload.h"
#include <homekit/stopwatch.h>
namespace homekit::mqtt {
class Mqtt;
class MqttModule {
protected:
bool initialized;
StopWatch tickSw;
short tickInterval;
bool receiveOnPublish;
bool receiveOnDisconnect;
bool tickElapsed();
public:
MqttModule(short _tickInterval, bool _receiveOnPublish = false, bool _receiveOnDisconnect = false)
: initialized(false)
, tickInterval(_tickInterval)
, receiveOnPublish(_receiveOnPublish)
, receiveOnDisconnect(_receiveOnDisconnect) {}
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);
inline void setInitialized() {
initialized = true;
}
inline void unsetInitialized() {
initialized = false;
}
inline short getTickInterval() const {
return tickInterval;
}
friend class Mqtt;
};
}
#endif //HOMEKIT_LIB_MQTT_MODULE_H

View File

@ -1,162 +0,0 @@
#include "./mqtt.h"
#include <homekit/config.h>
#include <homekit/wifi.h>
#include <homekit/logging.h>
namespace homekit::mqtt {
const uint8_t MQTT_CA_FINGERPRINT[] = { \
0x0e, 0xb6, 0x3a, 0x02, 0x1f, \
0x4e, 0x1e, 0xe1, 0x6a, 0x67, \
0x62, 0xec, 0x64, 0xd4, 0x84, \
0x8a, 0xb0, 0xc9, 0x9c, 0xbb \
};;
const char MQTT_SERVER[] = "mqtt.solarmon.ru";
const uint16_t MQTT_PORT = 8883;
const char MQTT_USERNAME[] = CONFIG_MQTT_USERNAME;
const char MQTT_PASSWORD[] = CONFIG_MQTT_PASSWORD;
const char MQTT_CLIENT_ID[] = CONFIG_MQTT_CLIENT_ID;
const char MQTT_SECRET[CONFIG_NODE_SECRET_SIZE+1] = CONFIG_NODE_SECRET;
static const uint16_t MQTT_KEEPALIVE = 30;
using namespace espMqttClientTypes;
Mqtt::Mqtt() {
auto cfg = config::read();
nodeId = String(cfg.flags.node_configured ? cfg.node_id : wifi::NODE_ID);
randomSeed(micros());
client.onConnect([&](bool sessionPresent) {
PRINTLN("mqtt: connected");
for (auto* module: modules) {
if (!module->initialized) {
module->onConnect(*this);
module->setInitialized();
}
}
connected = true;
});
client.onDisconnect([&](DisconnectReason reason) {
PRINTF("mqtt: disconnected, reason=%d\n", static_cast<int>(reason));
#ifdef DEBUG
if (reason == DisconnectReason::TLS_BAD_FINGERPRINT)
PRINTLN("reason: bad fingerprint");
#endif
for (auto* module: modules) {
module->onDisconnect(*this, reason);
module->unsetInitialized();
}
reconnectTimer.once(2, [&]() {
reconnect();
});
});
client.onSubscribe([&](uint16_t packetId, const SubscribeReturncode* returncodes, size_t len) {
PRINTF("mqtt: subscribe ack, packet_id=%d\n", packetId);
for (size_t i = 0; i < len; i++) {
PRINTF(" return code: %u\n", static_cast<unsigned int>(*(returncodes+i)));
}
});
client.onUnsubscribe([&](uint16_t packetId) {
PRINTF("mqtt: unsubscribe ack, packet_id=%d\n", packetId);
});
client.onMessage([&](const MessageProperties& properties, const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total) {
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() + 4;
String relevantTopic(ptr);
auto it = moduleSubscriptions.find(relevantTopic);
if (it != moduleSubscriptions.end()) {
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", relevantTopic.c_str());
}
});
client.onPublish([&](uint16_t packetId) {
PRINTF("mqtt: publish ack, packet_id=%d\n", packetId);
for (auto* module: modules) {
if (module->receiveOnPublish) {
module->handleOnPublish(packetId);
}
}
});
client.setServer(MQTT_SERVER, MQTT_PORT);
client.setClientId(MQTT_CLIENT_ID);
client.setCredentials(MQTT_USERNAME, MQTT_PASSWORD);
client.setCleanSession(true);
client.setFingerprint(MQTT_CA_FINGERPRINT);
client.setKeepAlive(MQTT_KEEPALIVE);
}
void Mqtt::connect() {
reconnect();
}
void Mqtt::reconnect() {
if (client.connected()) {
PRINTLN("warning: already connected");
return;
}
client.connect();
}
void Mqtt::disconnect() {
// TODO test how this works???
reconnectTimer.detach();
client.disconnect(true);
}
void Mqtt::loop() {
client.loop();
for (auto& module: modules) {
if (module->getTickInterval() != 0)
module->tick(*this);
}
}
uint16_t Mqtt::publish(const String& topic, uint8_t* payload, size_t length) {
String fullTopic = "hk/" + nodeId + "/" + topic;
return client.publish(fullTopic.c_str(), 1, false, payload, length);
}
uint16_t Mqtt::subscribe(const String& topic, uint8_t qos) {
String fullTopic = "hk/" + nodeId + "/" + topic;
PRINTF("mqtt: subscribing to %s...\n", fullTopic.c_str());
uint16_t packetId = client.subscribe(fullTopic.c_str(), qos);
if (!packetId)
PRINTF("error: failed to subscribe to %s\n", fullTopic.c_str());
return packetId;
}
void Mqtt::addModule(MqttModule* module) {
modules.emplace_back(module);
if (connected) {
module->onConnect(*this);
module->setInitialized();
}
}
void Mqtt::subscribeModule(String& topic, MqttModule* module, uint8_t qos) {
moduleSubscriptions[topic] = module;
subscribe(topic, qos);
}
}

View File

@ -1,48 +0,0 @@
#ifndef HOMEKIT_LIB_MQTT_H
#define HOMEKIT_LIB_MQTT_H
#include <vector>
#include <map>
#include <cstdint>
#include <espMqttClient.h>
#include <Ticker.h>
#include "./module.h"
namespace homekit::mqtt {
extern const uint8_t MQTT_CA_FINGERPRINT[];
extern const char MQTT_SERVER[];
extern const uint16_t MQTT_PORT;
extern const char MQTT_USERNAME[];
extern const char MQTT_PASSWORD[];
extern const char MQTT_CLIENT_ID[];
extern const char MQTT_SECRET[CONFIG_NODE_SECRET_SIZE+1];
class MqttModule;
class Mqtt {
private:
String nodeId;
WiFiClientSecure httpsSecureClient;
espMqttClientSecure client;
Ticker reconnectTimer;
std::vector<MqttModule*> modules;
std::map<String, MqttModule*> moduleSubscriptions;
bool connected;
uint16_t subscribe(const String& topic, uint8_t qos = 0);
public:
Mqtt();
void connect();
void disconnect();
void reconnect();
void loop();
void addModule(MqttModule* module);
void subscribeModule(String& topic, MqttModule* module, uint8_t qos = 0);
uint16_t publish(const String& topic, uint8_t* payload, size_t length);
};
}
#endif //HOMEKIT_LIB_MQTT_H

View File

@ -1,15 +0,0 @@
#ifndef HOMEKIT_MQTT_PAYLOAD_H
#define HOMEKIT_MQTT_PAYLOAD_H
#include <unistd.h>
namespace homekit::mqtt {
struct MqttPayload {
virtual ~MqttPayload() = default;
virtual size_t size() const = 0;
};
}
#endif

View File

@ -1,7 +0,0 @@
{
"name": "homekit_mqtt",
"version": "1.0.12",
"build": {
"flags": "-I../../include"
}
}

View File

@ -1,56 +0,0 @@
#include "./diagnostics.h"
#include <homekit/wifi.h>
#include <ESP8266WiFi.h>
namespace homekit::mqtt {
static const char TOPIC_DIAGNOSTICS[] = "diag";
static const char TOPIC_INITIAL_DIAGNOSTICS[] = "d1ag";
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) {
MqttInitialDiagnosticsPayload stat{
.ip = wifi::getIPAsInteger(),
.fw_version = CONFIG_FW_VERSION,
.rssi = wifi::getRSSI(),
.free_heap = ESP.getFreeHeap(),
.flags = DiagnosticsFlags{
.state = 1,
.config_changed_value_present = 1,
.config_changed = static_cast<uint8_t>(cfg.flags.node_configured ||
cfg.flags.wifi_configured ? 1 : 0)
}
};
mqtt.publish(TOPIC_INITIAL_DIAGNOSTICS, reinterpret_cast<uint8_t*>(&stat), sizeof(stat));
initialSent = true;
} else {
MqttDiagnosticsPayload stat{
.rssi = wifi::getRSSI(),
.free_heap = ESP.getFreeHeap(),
.flags = DiagnosticsFlags{
.state = 1,
.config_changed_value_present = 0,
.config_changed = 0
}
};
mqtt.publish(TOPIC_DIAGNOSTICS, reinterpret_cast<uint8_t*>(&stat), sizeof(stat));
}
}
}

View File

@ -1,49 +0,0 @@
#ifndef HOMEKIT_LIB_MQTT_MODULE_DIAGNOSTICS_H
#define HOMEKIT_LIB_MQTT_MODULE_DIAGNOSTICS_H
#include <stdint.h>
#include <homekit/mqtt/module.h>
namespace homekit::mqtt {
struct DiagnosticsFlags {
uint8_t state: 1;
uint8_t config_changed_value_present: 1;
uint8_t config_changed: 1;
uint8_t reserved: 5;
} __attribute__((packed));
struct MqttInitialDiagnosticsPayload {
uint32_t ip;
uint8_t fw_version;
int8_t rssi;
uint32_t free_heap;
DiagnosticsFlags flags;
} __attribute__((packed));
struct MqttDiagnosticsPayload {
int8_t rssi;
uint32_t free_heap;
DiagnosticsFlags flags;
} __attribute__((packed));
class MqttDiagnosticsModule: public MqttModule {
private:
bool initialSent;
void sendDiagnostics(Mqtt& mqtt);
public:
MqttDiagnosticsModule()
: MqttModule(30)
, initialSent(false) {}
void onConnect(Mqtt& mqtt) override;
void onDisconnect(Mqtt& mqtt, espMqttClientTypes::DisconnectReason reason) override;
void tick(Mqtt& mqtt) override;
};
}
#endif //HOMEKIT_LIB_MQTT_MODULE_DIAGNOSTICS_H

View File

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

View File

@ -1,160 +0,0 @@
#include "./ota.h"
#include <homekit/logging.h>
#include <homekit/util.h>
#include <homekit/led.h>
namespace homekit::mqtt {
using homekit::led::mcu_led;
#define MD5_SIZE 16
static const char TOPIC_OTA[] = "ota";
static const char TOPIC_OTA_RESPONSE[] = "otares";
void MqttOtaModule::onConnect(Mqtt& mqtt) {
String topic(TOPIC_OTA);
mqtt.subscribeModule(topic, this);
}
void MqttOtaModule::tick(Mqtt& mqtt) {
if (!tickElapsed())
return;
}
void MqttOtaModule::handlePayload(Mqtt& mqtt, String& topic, uint16_t packetId, const uint8_t *payload, size_t length, size_t index, size_t total) {
char md5[33];
char* md5Ptr = md5;
if (index != 0 && ota.dataPacketId != packetId) {
PRINTLN("mqtt/ota: non-matching packet id");
return;
}
Update.runAsync(true);
if (index == 0) {
if (length < CONFIG_NODE_SECRET_SIZE + MD5_SIZE) {
PRINTLN("mqtt/ota: failed to check secret, first packet size is too small");
return;
}
if (memcmp((const char*)payload, CONFIG_NODE_SECRET, CONFIG_NODE_SECRET_SIZE) != 0) {
PRINTLN("mqtt/ota: invalid secret");
return;
}
PRINTF("mqtt/ota: starting update, total=%ul\n", total-CONFIG_NODE_SECRET_SIZE);
for (int i = 0; i < MD5_SIZE; i++) {
md5Ptr += sprintf(md5Ptr, "%02x", *((unsigned char*)(payload+CONFIG_NODE_SECRET_SIZE+i)));
}
md5[32] = '\0';
PRINTF("mqtt/ota: md5 is %s\n", md5);
PRINTF("mqtt/ota: first packet is %ul bytes length\n", length);
md5[32] = '\0';
if (Update.isRunning()) {
Update.end();
Update.clearError();
}
if (!Update.setMD5(md5)) {
PRINTLN("mqtt/ota: setMD5 failed");
return;
}
ota.dataPacketId = packetId;
if (!Update.begin(total - CONFIG_NODE_SECRET_SIZE - MD5_SIZE)) {
ota.clean();
#ifdef DEBUG
Update.printError(Serial);
#endif
sendResponse(mqtt, OtaResult::UPDATE_ERROR, Update.getError());
}
ota.written = Update.write(const_cast<uint8_t*>(payload)+CONFIG_NODE_SECRET_SIZE + MD5_SIZE, length-CONFIG_NODE_SECRET_SIZE - MD5_SIZE);
ota.written += CONFIG_NODE_SECRET_SIZE + MD5_SIZE;
mcu_led->blink(1, 1);
PRINTF("mqtt/ota: updating %u/%u\n", ota.written, Update.size());
} else {
if (!Update.isRunning()) {
PRINTLN("mqtt/ota: update is not running");
return;
}
if (index == ota.written) {
size_t written;
if ((written = Update.write(const_cast<uint8_t*>(payload), length)) != length) {
PRINTF("mqtt/ota: error: tried to write %ul bytes, write() returned %ul\n",
length, written);
ota.clean();
Update.end();
Update.clearError();
sendResponse(mqtt, OtaResult::WRITE_ERROR);
return;
}
ota.written += length;
mcu_led->blink(1, 1);
PRINTF("mqtt/ota: updating %u/%u\n",
ota.written - CONFIG_NODE_SECRET_SIZE - MD5_SIZE,
Update.size());
} else {
PRINTF("mqtt/ota: position is invalid, expected %ul, got %ul\n", ota.written, index);
ota.clean();
Update.end();
Update.clearError();
}
}
if (Update.isFinished()) {
ota.dataPacketId = 0;
if (Update.end()) {
ota.finished = true;
ota.publishResultPacketId = sendResponse(mqtt, OtaResult::OK);
PRINTF("mqtt/ota: ok, otares packet_id=%d\n", ota.publishResultPacketId);
} else {
ota.clean();
PRINTF("mqtt/ota: error: %u\n", Update.getError());
#ifdef DEBUG
Update.printError(Serial);
#endif
Update.clearError();
sendResponse(mqtt, OtaResult::UPDATE_ERROR, Update.getError());
}
}
}
uint16_t MqttOtaModule::sendResponse(Mqtt& mqtt, OtaResult status, uint8_t error_code) const {
MqttOtaResponsePayload resp{
.status = status,
.error_code = error_code
};
return mqtt.publish(TOPIC_OTA_RESPONSE, reinterpret_cast<uint8_t*>(&resp), sizeof(resp));
}
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();
}
}
void MqttOtaModule::handleOnPublish(uint16_t packetId) {
if (ota.finished && packetId == ota.publishResultPacketId) {
ota.readyToRestart = true;
}
}
}

View File

@ -1,75 +0,0 @@
#ifndef HOMEKIT_LIB_MQTT_MODULE_OTA_H
#define HOMEKIT_LIB_MQTT_MODULE_OTA_H
#include <stdint.h>
#include <Ticker.h>
#include <homekit/mqtt/module.h>
namespace homekit::mqtt {
enum class OtaResult: uint8_t {
OK = 0,
UPDATE_ERROR = 1,
WRITE_ERROR = 2,
};
struct OtaStatus {
uint16_t dataPacketId;
uint16_t publishResultPacketId;
bool finished;
bool readyToRestart;
size_t written;
OtaStatus()
: dataPacketId(0)
, publishResultPacketId(0)
, finished(false)
, readyToRestart(false)
, written(0)
{}
inline void clean() {
dataPacketId = 0;
publishResultPacketId = 0;
finished = false;
readyToRestart = false;
written = 0;
}
inline bool started() const {
return dataPacketId != 0;
}
};
struct MqttOtaResponsePayload {
OtaResult status;
uint8_t error_code;
} __attribute__((packed));
class MqttOtaModule: public MqttModule {
private:
OtaStatus ota;
Ticker restartTimer;
uint16_t sendResponse(Mqtt& mqtt, OtaResult status, uint8_t error_code = 0) const;
public:
MqttOtaModule() : MqttModule(0, true, true) {}
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;
inline bool isReadyToRestart() const {
return ota.readyToRestart;
}
};
}
#endif //HOMEKIT_LIB_MQTT_MODULE_OTA_H

View File

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

View File

@ -1,58 +0,0 @@
#include "./relay.h"
#include <homekit/relay.h>
#include <homekit/logging.h>
namespace homekit::mqtt {
static const char TOPIC_RELAY_SWITCH[] = "relay/switch";
static const char TOPIC_RELAY_STATUS[] = "relay/status";
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 (length != sizeof(MqttRelaySwitchPayload)) {
PRINTF("error: size of payload (%ul) does not match expected (%ul)\n",
length, sizeof(MqttRelaySwitchPayload));
return;
}
auto pd = reinterpret_cast<const struct MqttRelaySwitchPayload*>(payload);
if (strncmp(pd->secret, MQTT_SECRET, sizeof(pd->secret)) != 0) {
PRINTLN("error: invalid secret");
return;
}
MqttRelayStatusPayload resp{};
if (pd->state == 1) {
PRINTLN("mqtt: turning relay on");
relay::on();
} else if (pd->state == 0) {
PRINTLN("mqtt: turning relay off");
relay::off();
} else {
PRINTLN("error: unexpected state value");
}
resp.opened = relay::state();
mqtt.publish(TOPIC_RELAY_STATUS, reinterpret_cast<uint8_t*>(&resp), sizeof(resp));
}
}

View File

@ -1,29 +0,0 @@
#ifndef HOMEKIT_LIB_MQTT_MODULE_RELAY_H
#define HOMEKIT_LIB_MQTT_MODULE_RELAY_H
#include <homekit/mqtt/module.h>
namespace homekit::mqtt {
struct MqttRelaySwitchPayload {
char secret[12];
uint8_t state;
} __attribute__((packed));
struct MqttRelayStatusPayload {
uint8_t opened;
} __attribute__((packed));
class MqttRelayModule : public MqttModule {
public:
MqttRelayModule() : MqttModule(0) {}
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;
};
}
#endif //HOMEKIT_LIB_MQTT_MODULE_RELAY_H

View File

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

View File

@ -1,23 +0,0 @@
#include "temphum.h"
namespace homekit::mqtt {
static const char TOPIC_TEMPHUM_DATA[] = "temphum/data";
void MqttTemphumModule::onConnect(Mqtt &mqtt) {}
void MqttTemphumModule::tick(homekit::mqtt::Mqtt& mqtt) {
if (!tickElapsed())
return;
temphum::SensorData sd = sensor->read();
MqttTemphumPayload payload {
.temp = sd.temp,
.rh = sd.rh,
.error = sd.error
};
mqtt.publish(TOPIC_TEMPHUM_DATA, reinterpret_cast<uint8_t*>(&payload), sizeof(payload));
}
}

View File

@ -1,28 +0,0 @@
#ifndef HOMEKIT_LIB_MQTT_MODULE_TEMPHUM_H
#define HOMEKIT_LIB_MQTT_MODULE_TEMPHUM_H
#include <homekit/mqtt/module.h>
#include <homekit/temphum.h>
namespace homekit::mqtt {
struct MqttTemphumPayload {
double temp = 0;
double rh = 0;
uint8_t error = 0;
} __attribute__((packed));
class MqttTemphumModule : public MqttModule {
private:
temphum::Sensor* sensor;
public:
MqttTemphumModule(temphum::Sensor* _sensor) : MqttModule(10), sensor(_sensor) {}
void onConnect(Mqtt& mqtt) override;
void tick(Mqtt& mqtt) override;
};
}
#endif //HOMEKIT_LIB_MQTT_MODULE_TEMPHUM_H

View File

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

View File

@ -1,22 +0,0 @@
#include <Arduino.h>
#include "./relay.h"
namespace homekit::relay {
void init() {
pinMode(CONFIG_RELAY_GPIO, OUTPUT);
}
bool state() {
return digitalRead(CONFIG_RELAY_GPIO) == HIGH;
}
void on() {
digitalWrite(CONFIG_RELAY_GPIO, HIGH);
}
void off() {
digitalWrite(CONFIG_RELAY_GPIO, LOW);
}
}

View File

@ -1,13 +0,0 @@
#ifndef HOMEKIT_LIB_RELAY_H
#define HOMEKIT_LIB_RELAY_H
namespace homekit::relay {
void init();
bool state();
void on();
void off();
}
#endif //HOMEKIT_LIB_RELAY_H

View File

@ -1,8 +0,0 @@
{
"name": "homekit_relay",
"version": "1.0.0",
"build": {
"flags": "-I../../include"
}
}

View File

@ -1,8 +0,0 @@
{
"name": "homekit_static",
"version": "1.0.1",
"build": {
"flags": "-I../../include"
}
}

View File

@ -1,89 +0,0 @@
#ifndef CONFIG_TARGET_ESP01
#include <Arduino.h>
#endif
#include <homekit/logging.h>
#include "temphum.h"
namespace homekit::temphum {
void Sensor::setup() const {
#ifndef CONFIG_TARGET_ESP01
pinMode(CONFIG_SDA_GPIO, OUTPUT);
pinMode(CONFIG_SCL_GPIO, OUTPUT);
Wire.begin(CONFIG_SDA_GPIO, CONFIG_SCL_GPIO);
#else
Wire.begin();
#endif
}
void Sensor::writeCommand(int reg) const {
Wire.beginTransmission(dev_addr);
Wire.write(reg);
Wire.endTransmission();
delay(500); // wait for the measurement to be ready
}
SensorData Si7021::read() {
uint8_t error = 0;
writeCommand(0xf3); // command to measure temperature
Wire.requestFrom(dev_addr, 2);
if (Wire.available() < 2) {
PRINTLN("Si7021: 0xf3: could not read 2 bytes");
error = 1;
}
uint16_t temp_raw = Wire.read() << 8 | Wire.read();
double temperature = ((175.72 * temp_raw) / 65536.0) - 46.85;
writeCommand(0xf5); // command to measure humidity
Wire.requestFrom(dev_addr, 2);
if (Wire.available() < 2) {
PRINTLN("Si7021: 0xf5: could not read 2 bytes");
error = 1;
}
uint16_t hum_raw = Wire.read() << 8 | Wire.read();
double humidity = ((125.0 * hum_raw) / 65536.0) - 6.0;
return {
.error = error,
.temp = temperature,
.rh = humidity
};
}
SensorData DHT12::read() {
SensorData sd;
byte raw[5];
sd.error = 1;
writeCommand(0);
Wire.requestFrom(dev_addr, 5);
if (Wire.available() < 5) {
PRINTLN("DHT12: could not read 5 bytes");
goto end;
}
// Parse the received data
for (uint8_t i = 0; i < 5; i++)
raw[i] = Wire.read();
if (((raw[0] + raw[1] + raw[2] + raw[3]) & 0xff) != raw[4]) {
PRINTLN("DHT12: checksum error");
goto end;
}
// Calculate temperature and humidity values
sd.temp = raw[2] + (raw[3] & 0x7f) * 0.1;
if (raw[3] & 0x80)
sd.temp *= -1;
sd.rh = raw[0] + raw[1] * 0.1;
sd.error = 0;
end:
return sd;
}
}

View File

@ -1,38 +0,0 @@
#pragma once
#include <Wire.h>
namespace homekit::temphum {
struct SensorData {
uint8_t error = 0;
double temp = 0; // celsius
double rh = 0; // relative humidity percentage
};
class Sensor {
protected:
int dev_addr;
public:
explicit Sensor(int dev) : dev_addr(dev) {}
void setup() const;
void writeCommand(int reg) const;
virtual SensorData read() = 0;
};
class Si7021 : public Sensor {
public:
SensorData read() override;
Si7021() : Sensor(0x40) {}
};
class DHT12 : public Sensor {
public:
SensorData read() override;
DHT12() : Sensor(0x5c) {}
};
}

View File

@ -1,8 +0,0 @@
{
"name": "homekit_temphum",
"version": "1.0.4",
"build": {
"flags": "-I../../include"
}
}

View File

@ -1,8 +0,0 @@
{
"name": "homekit_wifi",
"version": "1.0.1",
"build": {
"flags": "-I../../include"
}
}

View File

@ -1 +0,0 @@
from .isapi import ISAPIClient, ResponseError, AuthError

View File

@ -1,137 +0,0 @@
import requests
from time import time
from .util import xml_to_dict, sha256_hex
from homekit.util import validate_ipv4
from homekit.http import HTTPMethod
from typing import Optional, Union
class ResponseError(RuntimeError):
pass
class AuthError(ResponseError):
pass
class ISAPIClient:
def __init__(self, host):
self.host = host
self.cookies = {}
def call(self,
path: str,
method: HTTPMethod = HTTPMethod.GET,
data: Optional[Union[dict, str, bytes]] = None,
raise_for_status=True,
check_put_response=False):
f = getattr(requests, method.value.lower())
headers = {
'X-Requested-With': 'XMLHttpRequest',
}
if method in (HTTPMethod.PUT, HTTPMethod.POST):
headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
kwargs = {}
if data:
kwargs['data' if method is not HTTPMethod.GET else 'params'] = data
if len(self.cookies) > 0:
kwargs['cookies'] = self.cookies
r = f(f'http://{self.host}/ISAPI/{path}', headers=headers, **kwargs)
if raise_for_status or check_put_response:
r.raise_for_status()
parsed_xml = None
if check_put_response:
parsed_xml = xml_to_dict(r.text)
resp = parsed_xml['ResponseStatus']
status_code = int(resp['statusCode'][0])
status_string = resp['statusString'][0]
if status_code != 1 or status_string.lower() != 'ok':
raise ResponseError('response status looks bad')
self.cookies.update(r.cookies.get_dict())
if parsed_xml is None:
parsed_xml = xml_to_dict(r.text)
return parsed_xml
def auth(self, username: str, password: str):
xml = self.call('Security/sessionLogin/capabilities', data={'username': username})
caps = xml['SessionLoginCap']
is_irreversible = caps['isIrreversible'][0].lower() == 'true'
# https://github.com/JakeVincet/nvt/blob/master/2018/hikvision/gb_hikvision_ip_camera_default_credentials.nasl
# also look into webAuth.js and utils.js
if 'salt' in caps and is_irreversible:
p = sha256_hex(username + caps['salt'][0] + password)
p = sha256_hex(p + caps['challenge'][0])
for i in range(int(caps['iterations'][0])-2):
p = sha256_hex(p)
else:
p = sha256_hex(password) + caps['challenge'][0]
for i in range(int(caps['iterations'][0])-1):
p = sha256_hex(p)
data = '<SessionLogin>'
data += f'<userName>{username}</userName>'
data += f'<password>{p}</password>'
data += f'<sessionID>{caps["sessionID"][0]}</sessionID>'
data += '<isSessionIDValidLongTerm>false</isSessionIDValidLongTerm>'
data += f'<sessionIDVersion>{caps["sessionIDVersion"][0]}</sessionIDVersion>'
data += '</SessionLogin>'
resp = self.call(f'Security/sessionLogin?timeStamp={int(time())}', HTTPMethod.POST, data=data)['SessionLogin']
status_value = int(resp['statusValue'][0])
status_string = resp['statusString'][0]
if status_value != 200:
raise AuthError(f'{status_value}: {status_string}')
def get_ntp_server(self) -> str:
try:
# works on newer 1080p cams
xml = self.call('System/time/ntpServers/capabilities')
ntp_server = xml['NTPServerList']['NTPServer'][0]
if ntp_server['addressingFormatType'][0]['#text'] == 'hostname':
ntp_host = ntp_server['hostName'][0]
else:
ntp_host = ntp_server['ipAddress'][0]
except requests.exceptions.HTTPError:
# works on older 720p cams
ntp_server = self.call('System/time/ntpServers/1')['NTPServer']
if ntp_server['addressingFormatType'][0] == 'hostname':
ntp_host = ntp_server['hostName'][0]
else:
ntp_host = ntp_server['ipAddress'][0]
return ntp_host
def set_timezone(self):
data = '<?xml version="1.0" encoding="UTF-8"?>'
data += '<Time><timeMode>NTP</timeMode><timeZone>CST-3:00:00</timeZone></Time>'
self.call('System/time', HTTPMethod.PUT, data=data, check_put_response=True)
def set_ntp_server(self,
ntp_host: str,
ntp_port: int = 123):
format = 'ipaddress' if validate_ipv4(ntp_host) else 'hostname'
# test ntp server first
data = f'<?xml version="1.0" encoding="UTF-8"?><NTPTestDescription><addressingFormatType>{format}</addressingFormatType><ipAddress>{ntp_host}</ipAddress><portNo>{ntp_port}</portNo></NTPTestDescription>'
resp = self.call('System/time/ntpServers/test', HTTPMethod.POST, data=data)['NTPTestResult']
error_code = int(resp['errorCode'][0])
error_description = resp['errorDescription'][0]
if error_code != 0 or error_description.lower() != 'ok':
raise ResponseError('response status looks bad')
# then set it
data = '<?xml version="1.0" encoding="UTF-8"?>'
data += f'<NTPServer><id>1</id><addressingFormatType>{format}</addressingFormatType><ipAddress>{ntp_host}</ipAddress><portNo>{ntp_port}</portNo><synchronizeInterval>1440</synchronizeInterval></NTPServer>'
self.call('System/time/ntpServers/1', HTTPMethod.PUT, data=data, check_put_response=True)

View File

@ -1,48 +0,0 @@
import requests
import hashlib
import xml.etree.ElementTree as ET
def xml_to_dict(xml_data: str) -> dict:
# Parse the XML data
root = ET.fromstring(xml_data)
# Function to remove namespace from the tag name
def remove_namespace(tag):
return tag.split('}')[-1] # Splits on '}' and returns the last part, the actual tag name without namespace
# Function to recursively convert XML elements to a dictionary
def elem_to_dict(elem):
tag = remove_namespace(elem.tag)
elem_dict = {tag: {}}
# If the element has attributes, add them to the dictionary
elem_dict[tag].update({'@' + remove_namespace(k): v for k, v in elem.attrib.items()})
# Handle the element's text content, if present and not just whitespace
text = elem.text.strip() if elem.text and elem.text.strip() else None
if text:
elem_dict[tag]['#text'] = text
# Process child elements
for child in elem:
child_dict = elem_to_dict(child)
child_tag = remove_namespace(child.tag)
if child_tag not in elem_dict[tag]:
elem_dict[tag][child_tag] = []
elem_dict[tag][child_tag].append(child_dict[child_tag])
# Simplify structure if there's only text or no children and no attributes
if len(elem_dict[tag]) == 1 and '#text' in elem_dict[tag]:
return {tag: elem_dict[tag]['#text']}
elif not elem_dict[tag]:
return {tag: ''}
return elem_dict
# Convert the root element to dictionary
return elem_to_dict(root)
def sha256_hex(input_string: str) -> str:
return hashlib.sha256(input_string.encode()).hexdigest()

View File

@ -1,19 +0,0 @@
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}")

View File

@ -1,5 +0,0 @@
from .web_api_client import (
RequestParams as RequestParams,
WebApiClient as WebApiClient
)
from .config import WebApiConfig as WebApiConfig

View File

@ -1,15 +0,0 @@
from ..config import ConfigUnit
from typing import Optional, Union
class WebApiConfig(ConfigUnit):
NAME = 'web_api'
@classmethod
def schema(cls) -> Optional[dict]:
return {
'listen_addr': cls._addr_schema(required=True),
'host': cls._addr_schema(required=True),
'token': dict(type='string', required=True),
'recordings_dir': dict(type='string', required=True)
}

View File

@ -1,2 +0,0 @@
from .types import CameraType, VideoContainerType, VideoCodecType, CaptureType
from .config import IpcamConfig

View File

@ -1,161 +0,0 @@
import socket
from ..config import ConfigUnit
from ..linux import LinuxBoardsConfig
from typing import Optional
from .types import CameraType, VideoContainerType, VideoCodecType
_lbc = LinuxBoardsConfig()
def _validate_roi_line(field, value, error) -> bool:
p = value.split(' ')
if len(p) != 4:
error(field, f'{field}: must contain four coordinates separated by space')
for n in p:
if not n.isnumeric():
error(field, f'{field}: invalid coordinates (not a number)')
return True
class IpcamConfig(ConfigUnit):
NAME = 'ipcam'
@classmethod
def schema(cls) -> Optional[dict]:
return {
'cameras': {
'type': 'dict',
'keysrules': {'type': ['string', 'integer']},
'valuesrules': {
'type': 'dict',
'schema': {
'type': {'type': 'string', 'allowed': [t.value for t in CameraType], 'required': True},
'enabled': {'type': 'boolean'},
'motion': {
'type': 'dict',
'schema': {
'threshold': {'type': ['float', 'integer']},
'roi': {
'type': 'list',
'schema': {'type': 'string', 'check_with': _validate_roi_line}
}
}
},
}
}
},
'zones': {
'type': 'dict',
'keysrules': {'type': 'string'},
'valuesrules': {
'type': 'list',
'schema': {'type': ['string', 'integer']} # same type as for 'cameras' keysrules
}
},
'camera_ip_template': {'type': 'string', 'required': True},
'motion_padding': {'type': 'integer', 'required': True},
'motion_telegram': {'type': 'boolean', 'required': True},
'fix_interval': {'type': 'integer', 'required': True},
'fix_enabled': {'type': 'boolean', 'required': True},
'cleanup_min_gb': {'type': 'integer', 'required': True},
'cleanup_interval': {'type': 'integer', 'required': True},
# TODO FIXME
'fragment_url_templates': cls._url_templates_schema(),
'original_file_url_templates': cls._url_templates_schema(),
'hls_path': {'type': 'string', 'required': True},
'motion_processing_tmpfs_path': {'type': 'string', 'required': True},
'rtsp_creds': {
'required': True,
'type': 'dict',
'schema': {
'login': {'type': 'string', 'required': True},
'password': {'type': 'string', 'required': True},
}
},
'web_creds': {
'required': True,
'type': 'dict',
'schema': {
'login': {'type': 'string', 'required': True},
'password': {'type': 'string', 'required': True},
}
}
}
@staticmethod
def custom_validator(data):
pass
# FIXME rewrite or delete, looks kinda obsolete
# for n, cam in data['cameras'].items():
# linux_box = _lbc[cam['server']]
# if 'ext_hdd' not in linux_box:
# raise ValueError(f'cam-{n}: linux box {cam["server"]} must have ext_hdd defined')
# disk = cam['disk']-1
# if disk < 0 or disk >= len(linux_box['ext_hdd']):
# raise ValueError(f'cam-{n}: invalid disk index for linux box {cam["server"]}')
@classmethod
def _url_templates_schema(cls) -> dict:
return {
'type': 'list',
'empty': False,
'schema': {
'type': 'list',
'empty': False,
'schema': {'type': 'string'}
}
}
# FIXME
def get_all_cam_names(self,
filter_by_server: Optional[str] = None,
filter_by_disk: Optional[int] = None,
only_enabled=True) -> list[int]:
cams = []
if filter_by_server is not None and filter_by_server not in _lbc:
raise ValueError(f'invalid filter_by_server: {filter_by_server} not found in {_lbc.__class__.__name__}')
for cam, params in self['cameras'].items():
if only_enabled and not self.is_camera_enabled(cam):
continue
if filter_by_server is None or params['server'] == filter_by_server:
if filter_by_disk is None or params['disk'] == filter_by_disk:
cams.append(int(cam))
return cams
# def get_all_cam_names_for_this_server(self,
# filter_by_disk: Optional[int] = None):
# return self.get_all_cam_names(filter_by_server=socket.gethostname(),
# filter_by_disk=filter_by_disk)
# def get_cam_server_and_disk(self, cam: int) -> tuple[str, int]:
# return self['cameras'][cam]['server'], self['cameras'][cam]['disk']
def has_camera(self, camera: int) -> bool:
return camera in tuple(self['cameras'].keys())
def has_zone(self, zone: str) -> bool:
return zone in tuple(self['zones'].keys())
def get_camera_container(self, camera: int) -> VideoContainerType:
return self.get_camera_type(camera).get_container()
def get_camera_type(self, camera: int) -> CameraType:
return CameraType(self['cameras'][camera]['type'])
def get_rtsp_creds(self) -> tuple[str, str]:
return self['rtsp_creds']['login'], self['rtsp_creds']['password']
def get_camera_ip(self, camera: int) -> str:
return self['camera_ip_template'] % (str(camera),)
def is_camera_enabled(self, camera: int) -> bool:
try:
return self['cameras'][camera]['enabled']
except KeyError:
return True

View File

@ -1,64 +0,0 @@
from enum import Enum
class VideoContainerType(Enum):
MP4 = 'mp4'
MOV = 'mov'
class VideoCodecType(Enum):
H264 = 'h264'
H265 = 'h265'
class CameraType(Enum):
ESP32 = 'esp32'
XMEYE = 'xmeye'
HIKVISION_264 = 'hik_264'
HIKVISION_265 = 'hik_265'
def get_channel_url(self, channel: int) -> str:
if channel not in (1, 2):
raise ValueError(f'channel {channel} is invalid')
if channel == 1:
return ''
elif channel == 2:
if self.is_hikvision():
return '/Streaming/Channels/2'
elif self.is_xmeye():
return '/?stream=1.sdp'
else:
raise ValueError(f'unsupported camera type {self.value}')
def get_codec(self, channel: int) -> VideoCodecType:
if channel == 1:
return VideoCodecType.H264 if self == CameraType.HIKVISION_264 else VideoCodecType.H265
elif channel == 2:
return VideoCodecType.H265 if self == CameraType.XMEYE else VideoCodecType.H264
else:
raise ValueError(f'unexpected channel {channel}')
def get_container(self) -> VideoContainerType:
return VideoContainerType.MP4 if self.get_codec(1) == VideoCodecType.H264 else VideoContainerType.MOV
def is_hikvision(self) -> bool:
return self in (CameraType.HIKVISION_264, CameraType.HIKVISION_265)
def is_xmeye(self) -> bool:
return self == CameraType.XMEYE
class TimeFilterType(Enum):
FIX = 'fix'
MOTION = 'motion'
MOTION_START = 'motion_start'
class TelegramLinkType(Enum):
FRAGMENT = 'fragment'
ORIGINAL_FILE = 'original_file'
class CaptureType(Enum):
HLS = 'hls'
RECORD = 'record'

View File

@ -1,11 +0,0 @@
from .config import (
Config,
ConfigUnit,
AppConfigUnit,
Translation,
Language,
config,
is_development_mode,
setup_logging,
CONFIG_DIRECTORIES
)

View File

@ -1,475 +0,0 @@
import yaml
import logging
import os
import cerberus
import cerberus.errors
import re
from abc import ABC
from typing import Optional, Any, MutableMapping, Union
from argparse import ArgumentParser
from enum import Enum, auto
from os.path import join, isdir, isfile
from ..util import Addr
class MyValidator(cerberus.Validator):
def _normalize_coerce_addr(self, value):
return Addr.fromstring(value)
MyValidator.types_mapping['addr'] = cerberus.TypeDefinition('Addr', (Addr,), ())
CONFIG_DIRECTORIES = (
join(os.environ['HOME'], '.config', 'homekit'),
'/etc/homekit'
)
class RootSchemaType(Enum):
DEFAULT = auto()
DICT = auto()
LIST = auto()
class BaseConfigUnit(ABC):
_data: MutableMapping[str, Any]
_logger: logging.Logger
def __init__(self):
self._data = {}
self._logger = logging.getLogger(self.__class__.__name__)
def __iter__(self):
return iter(self._data)
def __getitem__(self, key):
return self._data[key]
def __setitem__(self, key, value):
raise NotImplementedError('overwriting config values is prohibited')
def __contains__(self, key):
return key in self._data
def load_from(self, path: str):
with open(path, 'r') as fd:
self._data = yaml.safe_load(fd)
if self._data is None:
raise TypeError(f'config file {path} is empty')
def get(self,
key: Optional[str] = None,
default=None):
if key is None:
return self._data
cur = self._data
pts = str(key).split('.')
for i in range(len(pts)):
k = pts[i]
if i < len(pts)-1:
if k not in cur:
raise KeyError(f'key {k} not found')
else:
return cur[k] if k in cur else default
cur = self._data[k]
raise KeyError(f'option {key} not found')
def values(self):
return self._data.values()
def keys(self):
return self._data.keys()
def items(self):
return self._data.items()
class ConfigUnit(BaseConfigUnit):
NAME = 'dumb'
_instance = None
__initialized: bool
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
cls._instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super(ConfigUnit, cls).__new__(cls, *args, **kwargs)
if cls._instance is not None:
cls._instance.__initialized = False
return cls._instance
def __init__(self, name=None, load=True):
if self.__initialized:
return
super().__init__()
self._data = {}
self._logger = logging.getLogger(self.__class__.__name__)
if self.NAME != 'dumb' and load:
self.load_from(self.get_config_path())
self.validate()
elif name is not None:
self.NAME = name
self.__initialized = True
@classmethod
def get_config_path(cls, name=None) -> str:
if name is None:
name = cls.NAME
if name is None:
raise ValueError('get_config_path: name is none')
for dirname in CONFIG_DIRECTORIES:
if isdir(dirname):
filename = join(dirname, f'{name}.yaml')
if isfile(filename):
return filename
raise IOError(f'\'{name}.yaml\' not found')
@classmethod
def schema(cls) -> Optional[dict]:
return None
@classmethod
def _addr_schema(cls, required=False, mac=False, only_ip=False, **kwargs):
def validate_mac_address(field, value, error):
if not re.match("[0-9a-fA-F]{2}([-:])[0-9a-fA-F]{2}(\\1[0-9a-fA-F]{2}){4}$", value):
error(field, "Invalid MAC address format")
if mac:
l_kwargs = {
'type': 'string',
'check_with': validate_mac_address
}
else:
l_kwargs = {
'type': 'addr',
'coerce': Addr.fromstring if not only_ip else Addr.fromipstring,
}
return {
'required': required,
**l_kwargs,
**kwargs
}
def validate(self):
schema = self.schema()
if not schema:
self._logger.warning('validate: no schema')
return
if isinstance(self, AppConfigUnit):
schema['logging'] = {
'type': 'dict',
'schema': {
'verbose': {'type': 'boolean'}
}
}
rst = RootSchemaType.DEFAULT
try:
if schema['type'] == 'dict':
rst = RootSchemaType.DICT
elif schema['type'] == 'list':
rst = RootSchemaType.LIST
elif schema['roottype'] == 'dict':
del schema['roottype']
rst = RootSchemaType.DICT
except KeyError:
pass
v = MyValidator()
need_document = False
if rst == RootSchemaType.DICT:
normalized = v.validated({'document': self._data},
{'document': {
'type': 'dict',
'keysrules': {'type': 'string'},
'valuesrules': schema
}})
need_document = True
elif rst == RootSchemaType.LIST:
v = MyValidator()
normalized = v.validated({'document': self._data}, {'document': schema})
need_document = True
else:
normalized = v.validated(self._data, schema)
if not normalized:
raise cerberus.DocumentError(f'validation failed: {v.errors}')
if need_document:
normalized = normalized['document']
self._data = normalized
try:
self.custom_validator(self._data)
except Exception as e:
raise cerberus.DocumentError(f'{self.__class__.__name__}: {str(e)}')
@staticmethod
def custom_validator(data):
pass
def get_addr(self, key: str):
return Addr.fromstring(self.get(key))
class AppConfigUnit(ConfigUnit):
_logging_verbose: bool
_logging_fmt: Optional[str]
_logging_file: Optional[str]
def __init__(self, *args, **kwargs):
super().__init__(load=False, *args, **kwargs)
self._logging_verbose = False
self._logging_fmt = None
self._logging_file = None
def logging_set_fmt(self, fmt: str) -> None:
self._logging_fmt = fmt
def logging_get_fmt(self) -> Optional[str]:
try:
return self['logging']['default_fmt']
except (KeyError, TypeError):
return self._logging_fmt
def logging_set_file(self, file: str) -> None:
self._logging_file = file
def logging_get_file(self) -> Optional[str]:
try:
return self['logging']['file']
except (KeyError, TypeError):
return self._logging_file
def logging_set_verbose(self):
self._logging_verbose = True
def logging_is_verbose(self) -> bool:
try:
return bool(self['logging']['verbose'])
except (KeyError, TypeError):
return self._logging_verbose
class Language(Enum):
RU = 'ru'
EN = 'en'
def name(self):
if self == Language.RU:
return 'Русский'
elif self == Language.EN:
return 'English'
class TranslationUnit(BaseConfigUnit):
pass
TranslationInstances = {}
class Translation:
DEFAULT_LANGUAGE = Language.RU
_langs: dict[Language, TranslationUnit]
__initialized: bool
# def __init_subclass__(cls, **kwargs):
# super().__init_subclass__(**kwargs)
# cls._instance = None
def __new__(cls, *args, **kwargs):
unit = args[0]
if unit not in TranslationInstances:
TranslationInstances[unit] = super(Translation, cls).__new__(cls)
TranslationInstances[unit].__initialized = False
return TranslationInstances[unit]
def __init__(self, name: str):
if self.__initialized:
return
self._langs = {}
for lang in Language:
for dirname in CONFIG_DIRECTORIES:
if isdir(dirname):
filename = join(dirname, f'i18n-{lang.value}', f'{name}.yaml')
if lang in self._langs:
raise RuntimeError(f'{name}: translation unit for lang \'{lang}\' already loaded')
self._langs[lang] = TranslationUnit()
self._langs[lang].load_from(filename)
diff = set()
for data in self._langs.values():
diff ^= data.get().keys()
if len(diff) > 0:
raise RuntimeError(f'{name}: translation units have difference in keys: ' + ', '.join(diff))
self.__initialized = True
def get(self, lang: Language = DEFAULT_LANGUAGE) -> TranslationUnit:
return self._langs[lang]
class Config:
app_name: Optional[str]
app_config: AppConfigUnit
def __init__(self):
self.app_name = None
self.app_config = AppConfigUnit()
def load_app(self,
name: Optional[Union[str, AppConfigUnit, bool]] = None,
use_cli=True,
parser: ArgumentParser = None,
no_config=False):
global app_config
if not no_config \
and not isinstance(name, str) \
and not isinstance(name, bool) \
and issubclass(name, AppConfigUnit) or name == AppConfigUnit:
self.app_name = name.NAME
self.app_config = name()
app_config = self.app_config
else:
self.app_name = name if isinstance(name, str) else None
if self.app_name is None and not use_cli:
raise RuntimeError('either config name must be none or use_cli must be True')
no_config = name is False or no_config
path = None
if use_cli:
if parser is None:
parser = ArgumentParser()
if not no_config:
parser.add_argument('-c', '--config', type=str, required=name is None,
help='Path to the config in TOML or YAML format')
parser.add_argument('-V', '--verbose', action='store_true')
parser.add_argument('--log-file', type=str)
parser.add_argument('--log-default-fmt', action='store_true')
args = parser.parse_args()
if not no_config and args.config:
path = args.config
if args.verbose:
self.app_config.logging_set_verbose()
if args.log_file:
self.app_config.logging_set_file(args.log_file)
if args.log_default_fmt:
self.app_config.logging_set_fmt(args.log_default_fmt)
if not isinstance(name, ConfigUnit):
if not no_config and path is None:
path = ConfigUnit.get_config_path(name=self.app_name)
if not no_config:
self.app_config.load_from(path)
self.app_config.validate()
setup_logging(self.app_config.logging_is_verbose(),
self.app_config.logging_get_file(),
self.app_config.logging_get_fmt())
if use_cli:
return args
config = Config()
def is_development_mode() -> bool:
if 'HK_MODE' in os.environ and os.environ['HK_MODE'] == 'dev':
return True
return ('logging' in config.app_config) and ('verbose' in config.app_config['logging']) and (config.app_config['logging']['verbose'] is True)
def setup_logging(verbose=False, log_file=None, default_fmt=None):
logging_level = logging.INFO
if is_development_mode() or verbose:
logging_level = logging.DEBUG
_add_logging_level('TRACE', logging.DEBUG-5)
log_config = {'level': logging_level}
if not default_fmt:
log_config['format'] = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
if log_file is not None:
log_config['filename'] = log_file
log_config['encoding'] = 'utf-8'
logging.basicConfig(**log_config)
# https://stackoverflow.com/questions/2183233/how-to-add-a-custom-loglevel-to-pythons-logging-facility/35804945#35804945
def _add_logging_level(levelName, levelNum, methodName=None):
"""
Comprehensively adds a new logging level to the `logging` module and the
currently configured logging class.
`levelName` becomes an attribute of the `logging` module with the value
`levelNum`. `methodName` becomes a convenience method for both `logging`
itself and the class returned by `logging.getLoggerClass()` (usually just
`logging.Logger`). If `methodName` is not specified, `levelName.lower()` is
used.
To avoid accidental clobberings of existing attributes, this method will
raise an `AttributeError` if the level name is already an attribute of the
`logging` module or if the method name is already present
Example
-------
>>> addLoggingLevel('TRACE', logging.DEBUG - 5)
>>> logging.getLogger(__name__).setLevel("TRACE")
>>> logging.getLogger(__name__).trace('that worked')
>>> logging.trace('so did this')
>>> logging.TRACE
5
"""
if not methodName:
methodName = levelName.lower()
if hasattr(logging, levelName):
raise AttributeError('{} already defined in logging module'.format(levelName))
if hasattr(logging, methodName):
raise AttributeError('{} already defined in logging module'.format(methodName))
if hasattr(logging.getLoggerClass(), methodName):
raise AttributeError('{} already defined in logger class'.format(methodName))
# This method was inspired by the answers to Stack Overflow post
# http://stackoverflow.com/q/2183233/2988730, especially
# http://stackoverflow.com/a/13638084/2988730
def logForLevel(self, message, *args, **kwargs):
if self.isEnabledFor(levelNum):
self._log(levelNum, message, args, **kwargs)
def logToRoot(message, *args, **kwargs):
logging.log(levelNum, message, *args, **kwargs)
logging.addLevelName(levelNum, levelName)
setattr(logging, levelName, levelNum)
setattr(logging.getLoggerClass(), methodName, logForLevel)
setattr(logging, methodName, logToRoot)

View File

@ -1,9 +0,0 @@
import os
def get_data_root_directory() -> str:
return os.path.join(
os.environ['HOME'],
'.config',
'homekit',
'data')

View File

@ -1,74 +0,0 @@
import time
import logging
from abc import ABC, abstractmethod
from mysql.connector import connect, MySQLConnection, Error
from typing import Optional
from ..config import ConfigUnit
logger = logging.getLogger(__name__)
datetime_fmt = '%Y-%m-%d %H:%M:%S'
class MySQLCredsConfig(ConfigUnit, ABC):
@classmethod
def schema(cls) -> Optional[dict]:
schema = {}
for k in ('host', 'database', 'user', 'password'):
schema[k] = dict(type='string', required=True)
return schema
class MySQLHomeCredsConfig(MySQLCredsConfig):
NAME = 'mysql_home_creds'
class MySQLCloudCredsConfig(MySQLCredsConfig):
NAME = 'mysql_cloud_creds'
def mysql_now() -> str:
return time.strftime('%Y-%m-%d %H:%M:%S')
class MySQLDatabase(ABC):
_enable_pings: bool
_link: MySQLConnection
_time_zone: Optional[str]
@abstractmethod
def creds(self) -> MySQLCredsConfig:
pass
def __init__(self, enable_pings=False, time_zone='+01:00'):
self._enable_pings = enable_pings
self._time_zone = time_zone
self._connect()
def _connect(self):
c = self.creds()
self._link = connect(
host=c['host'],
user=c['user'],
password=c['password'],
database=c['database'],
)
if self._time_zone:
self._link.time_zone = self._time_zone
def cursor(self, **kwargs):
if self._enable_pings:
try:
self._link.ping(reconnect=True, attempts=2)
except Error as e:
logger.exception(e)
self._connect()
return self._link.cursor(**kwargs)
def commit(self):
self._link.commit()
class MySQLHomeDatabase(MySQLDatabase):
def creds(self) -> MySQLCredsConfig:
return MySQLHomeCredsConfig()

View File

@ -1 +0,0 @@
from .http import serve, ajax_ok, HTTPMethod

View File

@ -1,115 +0,0 @@
import logging
import asyncio
import html
from enum import Enum
from aiohttp import web
from aiohttp.web import HTTPFound, HTTPMovedPermanently, HTTPException
from aiohttp.web_exceptions import HTTPNotFound
from ..util import stringify, format_tb, Addr
from ..config import is_development_mode
_logger = logging.getLogger(__name__)
def _render_error(error_type, error_message, traceback=None, code=500):
traceback_html = ''
if traceback:
traceback = '\n\n'.join(traceback)
traceback_html = f"""
<div class="error_traceback">
<div class="error_title">Traceback</div>
<div class="error_traceback_content">{html.escape(traceback)}</div>
</div>
"""
buf = f"""
<!doctype html>
<html lang=en>
<head>
<title>Error: {html.escape(error_type)}</title>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="stylesheet" type="text/css" href="/assets/error_page.css">
</head>
<body>
<div class="error_title">{html.escape(error_type)}</div>
<div class="error_message">{html.escape(error_message)}</div>
{traceback_html}
</body>
</html>
"""
return web.Response(text=buf, status=code, content_type='text/html')
@web.middleware
async def errors_handler_middleware(request, handler):
try:
response = await handler(request)
return response
except HTTPNotFound:
return _render_error(
error_type='Not Found',
error_message='The page you requested has not been found.',
code=404
)
except (HTTPFound, HTTPMovedPermanently) as exc:
raise exc
except HTTPException as exc:
_logger.exception(exc)
return _render_error(
error_type=exc.reason,
error_message=exc.text,
traceback=format_tb(exc)
)
except Exception as exc:
_logger.exception(exc)
return _render_error(
error_type=exc.__class__.__name__,
error_message=exc.message if hasattr(exc, 'message') else str(exc),
traceback=format_tb(exc)
)
def serve(addr: Addr, before_start=None, handle_signals=True, routes=None, event_loop=None):
logging.getLogger('aiohttp').setLevel(logging.DEBUG if is_development_mode() else logging.WARNING)
app = web.Application()
app.middlewares.append(errors_handler_middleware)
if routes is not None:
app.add_routes(routes)
if callable(before_start):
before_start(app)
if not event_loop:
event_loop = asyncio.get_event_loop()
runner = web.AppRunner(app, handle_signals=handle_signals)
event_loop.run_until_complete(runner.setup())
host, port = addr
site = web.TCPSite(runner, host=host, port=port)
event_loop.run_until_complete(site.start())
_logger.info(f'Server started at http://{host}:{port}')
event_loop.run_forever()
def ajax_ok(data=None):
if data is None:
data = 1
response = {'response': data}
return web.json_response(response, dumps=stringify)
class HTTPMethod(Enum):
GET = 'GET'
POST = 'POST'
PUT = 'PUT'

View File

@ -1,13 +0,0 @@
from ..config import ConfigUnit
from typing import Optional
class InverterdConfig(ConfigUnit):
NAME = 'inverterd'
@classmethod
def schema(cls) -> Optional[dict]:
return {
'remote_addr': cls._addr_schema(required=True),
'local_addr': cls._addr_schema(required=True),
}

View File

@ -1,2 +0,0 @@
from .config import LinuxBoardsConfig
from .types import LinuxBoardType

View File

@ -1,120 +0,0 @@
from ..config import ConfigUnit
from .types import LinuxBoardType
from typing import Optional
class ServicesListConfig(ConfigUnit):
NAME = 'services_list'
@classmethod
def schema(cls) -> Optional[dict]:
return {
'type': 'dict',
'schema': {
'system': dict(type='boolean'),
'exec': dict(type='string'),
'root': dict(type='boolean'),
'after': dict(type='string'),
'cron': {
'type': 'dict',
'schema': {
'time': dict(type='string', required=True),
'exec': dict(type='string', required=True),
'args': dict(type='string'),
}
}
}
}
@classmethod
def validate(self):
pass
class LinuxBoardsConfig(ConfigUnit):
NAME = 'linux_boards'
@classmethod
def schema(cls) -> Optional[dict]:
services_list = list(ServicesListConfig().keys())
return {
'type': 'dict',
'schema': {
'board': {
'type': 'string',
'required': True,
'allowed': [t.value for t in LinuxBoardType]
},
'role': {'type': 'string', 'required': False},
'location': {'type': 'string', 'required': True},
'notes': {'type': 'string', 'required': False},
'network': {
'type': 'dict',
'schema': {
'ethernet': cls._network_device_schema(),
'wifi': cls._network_device_schema(),
}
},
'online': {'type': 'boolean', 'required': False},
'services': {
'type': 'list',
'schema': {
'oneof': [
{'type': 'string', 'allowed': services_list},
{
'type': 'dict',
'schema': {
'keyschema': {
'type': 'string',
'allowed': services_list
}
},
'allow_unknown': True
}
]
}
},
'ext_hdd': {
'type': 'list',
'schema': {
'type': 'dict',
'schema': {
'mountpoint': {'type': 'string', 'required': True},
'size': {'type': 'integer', 'required': True},
'uuid': {
'type': 'string',
'regex': '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
'required': True
},
'ssd': {'required': False, 'type': 'boolean'}
}
},
}
}
}
@classmethod
def _network_device_schema(cls, required=False) -> dict:
return {
'type': 'list',
'required': required,
'schema': {
'type': 'dict',
'schema': {
'mac': cls._addr_schema(mac=True, required=True),
'mac_fake': {'type': 'boolean', 'required': False},
'ip': cls._addr_schema(only_ip=True, required=True),
'usb': {'type': 'boolean', 'required': False}
}
}
}
def get_board_disks(self, name: str) -> list[dict]:
return self[name]['ext_hdd']
def get_board_disks_count(self, name: str) -> int:
return len(self[name]['ext_hdd'])

View File

@ -1,25 +0,0 @@
from enum import Enum
class LinuxBoardType(Enum):
ORANGE_PI_ONE = 'opione'
ORANGE_PI_ONE_PLUS = 'opioneplus'
ORANGE_PI_LITE = 'opilite'
ORANGE_PI_ZERO = 'opizero'
ORANGE_PI_ZERO2 = 'opizero2'
ORANGE_PI_PC = 'opipc'
ORANGE_PI_PC2 = 'opipc2'
ORANGE_PI_3 = 'opi3'
ORANGE_PI_3_LTS = 'opi3lts'
ORANGE_PI_5 = 'opi5'
@property
def ram(self) -> int:
if self in (LinuxBoardType.ORANGE_PI_ONE, LinuxBoardType.ORANGE_PI_LITE, LinuxBoardType.ORANGE_PI_ZERO):
return 512
elif self in (LinuxBoardType.ORANGE_PI_ZERO2, LinuxBoardType.ORANGE_PI_PC, LinuxBoardType.ORANGE_PI_PC2, LinuxBoardType.ORANGE_PI_ONE_PLUS):
return 1024
elif self in (LinuxBoardType.ORANGE_PI_3, LinuxBoardType.ORANGE_PI_3_LTS):
return 2048
elif self in (LinuxBoardType.ORANGE_PI_5,):
return 8192

View File

@ -1,2 +0,0 @@
from .config import ModemsConfig
from .e3372 import E3372, MacroNetWorkType

View File

@ -1,36 +0,0 @@
from ..config import ConfigUnit, Translation
from typing import Optional
from enum import Enum
class ModemType(Enum):
E3372 = 'e3372'
GPON = 'gpon'
class ModemsConfig(ConfigUnit):
NAME = 'modems'
_strings: Translation
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._strings = Translation('modems')
@classmethod
def schema(cls) -> Optional[dict]:
return {
'type': 'dict',
'schema': {
'ip': cls._addr_schema(required=True, only_ip=True),
'gateway_ip': cls._addr_schema(required=False, only_ip=True),
'legacy_auth': {'type': 'boolean', 'required': True},
'type': {'type': 'string', 'allowed': [t.value for t in ModemType], 'required': True}
}
}
def getshortname(self, modem: str, lang=Translation.DEFAULT_LANGUAGE):
return self._strings.get(lang)[modem]['short']
def getfullname(self, modem: str, lang=Translation.DEFAULT_LANGUAGE):
return self._strings.get(lang)[modem]['full']

View File

@ -1,255 +0,0 @@
import requests
import xml.etree.ElementTree as ElementTree
from datetime import datetime
from ..util import Addr
from enum import Enum
from ..http import HTTPMethod
from typing import Union
class Error(Enum):
ERROR_SYSTEM_NO_SUPPORT = 100002
ERROR_SYSTEM_NO_RIGHTS = 100003
ERROR_SYSTEM_BUSY = 100004
ERROR_LOGIN_USERNAME_WRONG = 108001
ERROR_LOGIN_PASSWORD_WRONG = 108002
ERROR_LOGIN_ALREADY_LOGIN = 108003
ERROR_LOGIN_USERNAME_PWD_WRONG = 108006
ERROR_LOGIN_USERNAME_PWD_ORERRUN = 108007
ERROR_LOGIN_TOUCH_ALREADY_LOGIN = 108009
ERROR_VOICE_BUSY = 120001
ERROR_WRONG_TOKEN = 125001
ERROR_WRONG_SESSION = 125002
ERROR_WRONG_SESSION_TOKEN = 125003
class WifiStatus(Enum):
WIFI_CONNECTING = '900'
WIFI_CONNECTED = '901'
WIFI_DISCONNECTED = '902'
WIFI_DISCONNECTING = '903'
class Cradle(Enum):
CRADLE_CONNECTING = '900'
CRADLE_CONNECTED = '901'
CRADLE_DISCONNECTED = '902'
CRADLE_DISCONNECTING = '903'
CRADLE_CONNECTFAILED = '904'
CRADLE_CONNECTSTATUSNULL = '905'
CRANDLE_CONNECTSTATUSERRO = '906'
class MacroEVDOLevel(Enum):
MACRO_EVDO_LEVEL_ZERO = '0'
MACRO_EVDO_LEVEL_ONE = '1'
MACRO_EVDO_LEVEL_TWO = '2'
MACRO_EVDO_LEVEL_THREE = '3'
MACRO_EVDO_LEVEL_FOUR = '4'
MACRO_EVDO_LEVEL_FIVE = '5'
class MacroNetWorkType(Enum):
MACRO_NET_WORK_TYPE_NOSERVICE = 0
MACRO_NET_WORK_TYPE_GSM = 1
MACRO_NET_WORK_TYPE_GPRS = 2
MACRO_NET_WORK_TYPE_EDGE = 3
MACRO_NET_WORK_TYPE_WCDMA = 4
MACRO_NET_WORK_TYPE_HSDPA = 5
MACRO_NET_WORK_TYPE_HSUPA = 6
MACRO_NET_WORK_TYPE_HSPA = 7
MACRO_NET_WORK_TYPE_TDSCDMA = 8
MACRO_NET_WORK_TYPE_HSPA_PLUS = 9
MACRO_NET_WORK_TYPE_EVDO_REV_0 = 10
MACRO_NET_WORK_TYPE_EVDO_REV_A = 11
MACRO_NET_WORK_TYPE_EVDO_REV_B = 12
MACRO_NET_WORK_TYPE_1xRTT = 13
MACRO_NET_WORK_TYPE_UMB = 14
MACRO_NET_WORK_TYPE_1xEVDV = 15
MACRO_NET_WORK_TYPE_3xRTT = 16
MACRO_NET_WORK_TYPE_HSPA_PLUS_64QAM = 17
MACRO_NET_WORK_TYPE_HSPA_PLUS_MIMO = 18
MACRO_NET_WORK_TYPE_LTE = 19
MACRO_NET_WORK_TYPE_EX_NOSERVICE = 0
MACRO_NET_WORK_TYPE_EX_GSM = 1
MACRO_NET_WORK_TYPE_EX_GPRS = 2
MACRO_NET_WORK_TYPE_EX_EDGE = 3
MACRO_NET_WORK_TYPE_EX_IS95A = 21
MACRO_NET_WORK_TYPE_EX_IS95B = 22
MACRO_NET_WORK_TYPE_EX_CDMA_1x = 23
MACRO_NET_WORK_TYPE_EX_EVDO_REV_0 = 24
MACRO_NET_WORK_TYPE_EX_EVDO_REV_A = 25
MACRO_NET_WORK_TYPE_EX_EVDO_REV_B = 26
MACRO_NET_WORK_TYPE_EX_HYBRID_CDMA_1x = 27
MACRO_NET_WORK_TYPE_EX_HYBRID_EVDO_REV_0 = 28
MACRO_NET_WORK_TYPE_EX_HYBRID_EVDO_REV_A = 29
MACRO_NET_WORK_TYPE_EX_HYBRID_EVDO_REV_B = 30
MACRO_NET_WORK_TYPE_EX_EHRPD_REL_0 = 31
MACRO_NET_WORK_TYPE_EX_EHRPD_REL_A = 32
MACRO_NET_WORK_TYPE_EX_EHRPD_REL_B = 33
MACRO_NET_WORK_TYPE_EX_HYBRID_EHRPD_REL_0 = 34
MACRO_NET_WORK_TYPE_EX_HYBRID_EHRPD_REL_A = 35
MACRO_NET_WORK_TYPE_EX_HYBRID_EHRPD_REL_B = 36
MACRO_NET_WORK_TYPE_EX_WCDMA = 41
MACRO_NET_WORK_TYPE_EX_HSDPA = 42
MACRO_NET_WORK_TYPE_EX_HSUPA = 43
MACRO_NET_WORK_TYPE_EX_HSPA = 44
MACRO_NET_WORK_TYPE_EX_HSPA_PLUS = 45
MACRO_NET_WORK_TYPE_EX_DC_HSPA_PLUS = 46
MACRO_NET_WORK_TYPE_EX_TD_SCDMA = 61
MACRO_NET_WORK_TYPE_EX_TD_HSDPA = 62
MACRO_NET_WORK_TYPE_EX_TD_HSUPA = 63
MACRO_NET_WORK_TYPE_EX_TD_HSPA = 64
MACRO_NET_WORK_TYPE_EX_TD_HSPA_PLUS = 65
MACRO_NET_WORK_TYPE_EX_802_16E = 81
MACRO_NET_WORK_TYPE_EX_LTE = 101
def post_data_to_xml(data: dict, depth: int = 1) -> str:
if depth == 1:
return '<?xml version: "1.0" encoding="UTF-8"?>'+post_data_to_xml({'request': data}, depth+1)
items = []
for k, v in data.items():
if isinstance(v, dict):
v = post_data_to_xml(v, depth+1)
elif isinstance(v, list):
raise TypeError('list type is unsupported here')
items.append(f'<{k}>{v}</{k}>')
return ''.join(items)
class E3372:
_addr: Addr
_need_auth: bool
_legacy_token_auth: bool
_get_raw_data: bool
_headers: dict[str, str]
_authorized: bool
def __init__(self,
addr: Addr,
need_auth: bool = True,
legacy_token_auth: bool = False,
get_raw_data: bool = False):
self._addr = addr
self._need_auth = need_auth
self._legacy_token_auth = legacy_token_auth
self._get_raw_data = get_raw_data
self._authorized = False
self._headers = {}
@property
def device_information(self):
self.auth()
return self.request('device/information')
@property
def device_signal(self):
self.auth()
return self.request('device/signal')
@property
def monitoring_status(self):
self.auth()
return self.request('monitoring/status')
@property
def notifications(self):
self.auth()
return self.request('monitoring/check-notifications')
@property
def dialup_connection(self):
self.auth()
return self.request('dialup/connection')
@property
def traffic_stats(self):
self.auth()
return self.request('monitoring/traffic-statistics')
@property
def sms_count(self):
self.auth()
return self.request('sms/sms-count')
def sms_send(self, phone: str, text: str):
self.auth()
return self.request('sms/send-sms', HTTPMethod.POST, {
'Index': -1,
'Phones': {
'Phone': phone
},
'Sca': '',
'Content': text,
'Length': -1,
'Reserved': 1,
'Date': -1
})
def sms_list(self, page: int = 1, count: int = 20, outbox: bool = False):
self.auth()
xml = self.request('sms/sms-list', HTTPMethod.POST, {
'PageIndex': page,
'ReadCount': count,
'BoxType': 1 if not outbox else 2,
'SortType': 0,
'Ascending': 0,
'UnreadPreferred': 1 if not outbox else 0
}, return_body=True)
root = ElementTree.fromstring(xml)
messages = []
for message_elem in root.find('Messages').findall('Message'):
message_dict = {child.tag: child.text for child in message_elem}
message_dict['UnixTime'] = int(datetime.strptime(message_dict['Date'], '%Y-%m-%d %H:%M:%S').timestamp())
messages.append(message_dict)
return messages
def auth(self):
if self._authorized:
return
if not self._legacy_token_auth:
data = self.request('webserver/SesTokInfo')
self._headers = {
'Cookie': data['SesInfo'],
'__RequestVerificationToken': data['TokInfo'],
'Content-Type': 'text/xml'
}
else:
data = self.request('webserver/token')
self._headers = {
'__RequestVerificationToken': data['token'],
'Content-Type': 'text/xml'
}
self._authorized = True
def request(self,
method: str,
http_method: HTTPMethod = HTTPMethod.GET,
data: dict = {},
return_body: bool = False) -> Union[str, dict]:
url = f'http://{self._addr}/api/{method}'
if http_method == HTTPMethod.POST:
data = post_data_to_xml(data)
f = requests.post
else:
data = None
f = requests.get
r = f(url, data=data, headers=self._headers)
r.raise_for_status()
r.encoding = 'utf-8'
if return_body:
return r.text
root = ElementTree.fromstring(r.text)
data_dict = {}
for elem in root:
data_dict[elem.tag] = elem.text
return data_dict

View File

@ -1,7 +0,0 @@
from ._mqtt import Mqtt
from ._node import MqttNode
from ._module import MqttModule
from ._wrapper import MqttWrapper
from ._config import MqttConfig, MqttCreds, MqttNodesConfig
from ._payload import MqttPayload, MqttPayloadCustomField
from ._util import get_modules as get_mqtt_modules

View File

@ -1,183 +0,0 @@
from ..config import ConfigUnit
from typing import Optional, Union
from ..util import Addr
from collections import namedtuple
MqttCreds = namedtuple('MqttCreds', 'username, password')
class MqttConfig(ConfigUnit):
NAME = 'mqtt'
@classmethod
def schema(cls) -> Optional[dict]:
addr_schema = {
'type': 'dict',
'required': True,
'schema': {
'host': {'type': 'string', 'required': True},
'port': {'type': 'integer', 'required': True}
}
}
schema = {}
for key in ('local', 'remote'):
schema[f'{key}_addr'] = addr_schema
schema['creds'] = {
'type': 'dict',
'required': True,
'keysrules': {'type': 'string'},
'valuesrules': {
'type': 'dict',
'schema': {
'username': {'type': 'string', 'required': True},
'password': {'type': 'string', 'required': True},
}
}
}
for key in ('client', 'server'):
schema[f'default_{key}_creds'] = {'type': 'string', 'required': True}
return schema
def remote_addr(self) -> Addr:
return Addr(host=self['remote_addr']['host'],
port=self['remote_addr']['port'])
def local_addr(self) -> Addr:
return Addr(host=self['local_addr']['host'],
port=self['local_addr']['port'])
def creds_by_name(self, name: str) -> MqttCreds:
return MqttCreds(username=self['creds'][name]['username'],
password=self['creds'][name]['password'])
def creds(self) -> MqttCreds:
return self.creds_by_name(self['default_client_creds'])
def server_creds(self) -> MqttCreds:
return self.creds_by_name(self['default_server_creds'])
class MqttNodesConfig(ConfigUnit):
NAME = 'mqtt_nodes'
@classmethod
def schema(cls) -> Optional[dict]:
return {
'common': {
'type': 'dict',
'schema': {
'temphum': {
'type': 'dict',
'schema': {
'interval': {'type': 'integer'}
}
},
'password': {'type': 'string'}
}
},
'nodes': {
'type': 'dict',
'required': True,
'keysrules': {'type': 'string'},
'valuesrules': {
'type': 'dict',
'schema': {
'type': {'type': 'string', 'required': True, 'allowed': ['esp8266', 'linux', 'none'],},
'board': {'type': 'string', 'allowed': ['nodemcu', 'd1_mini_lite', 'esp12e']},
'temphum': {
'type': 'dict',
'schema': {
'module': {'type': 'string', 'required': True, 'allowed': ['si7021', 'dht12']},
'legacy_payload': {'type': 'boolean', 'required': False, 'default': False},
'interval': {'type': 'integer'},
'i2c_bus': {'type': 'integer'},
'tcpserver': {
'type': 'dict',
'schema': {
'port': {'type': 'integer', 'required': True}
}
}
}
},
'relay': {
'type': 'dict',
'schema': {
'device_type': {'type': 'string', 'allowed': ['lamp', 'pump', 'solenoid', 'cooler'], 'required': True},
'legacy_topics': {'type': 'boolean'}
}
},
'password': {'type': 'string'},
'defines': {
'type': 'dict',
'keysrules': {'type': 'string'},
'valuesrules': {'type': ['string', 'integer']}
}
}
}
}
}
@staticmethod
def custom_validator(data):
for name, node in data['nodes'].items():
if 'temphum' in node:
if node['type'] == 'linux':
if 'i2c_bus' not in node['temphum']:
raise KeyError(f'nodes.{name}.temphum: i2c_bus is missing but required for type=linux')
if node['type'] in ('esp8266',) and 'board' not in node:
raise KeyError(f'nodes.{name}: board is missing but required for type={node["type"]}')
def get_node(self, name: str) -> dict:
node = self['nodes'][name]
if node['type'] == 'none':
return node
try:
if 'password' not in node:
node['password'] = self['common']['password']
except KeyError:
pass
try:
if 'temphum' in node:
for ckey, cval in self['common']['temphum'].items():
if ckey not in node['temphum']:
node['temphum'][ckey] = cval
except KeyError:
pass
return node
def get_nodes(self,
filters: Optional[Union[list[str], tuple[str]]] = None,
only_names=False) -> Union[dict, list[str]]:
if filters:
for f in filters:
if f not in ('temphum', 'relay'):
raise ValueError(f'{self.__class__.__name__}::get_node(): invalid filter {f}')
reslist = []
resdict = {}
for name in self['nodes'].keys():
node = self.get_node(name)
if (not filters) or ('temphum' in filters and 'temphum' in node) or ('relay' in filters and 'relay' in node):
if only_names:
reslist.append(name)
else:
resdict[name] = node
return reslist if only_names else resdict
def node_uses_legacy_temphum_data_payload(self, node_id: str) -> bool:
try:
return self.get_node(node_id)['temphum']['legacy_payload']
except KeyError:
return False
def node_uses_legacy_relay_power_payload(self, node_id: str) -> bool:
try:
return self.get_node(node_id)['relay']['legacy_topics']
except KeyError:
return False

View File

@ -1,70 +0,0 @@
from __future__ import annotations
import abc
import logging
import threading
from time import sleep
from ..util import next_tick_gen
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from ._node import MqttNode
from ._payload import MqttPayload
class MqttModule(abc.ABC):
_tick_interval: int
_initialized: bool
_connected: bool
_ticker: Optional[threading.Thread]
_mqtt_node_ref: Optional[MqttNode]
def __init__(self, tick_interval=0):
self._tick_interval = tick_interval
self._initialized = False
self._ticker = None
self._logger = logging.getLogger(self.__class__.__name__)
self._connected = False
self._mqtt_node_ref = None
def on_connect(self, mqtt: MqttNode):
self._connected = True
self._mqtt_node_ref = mqtt
if self._tick_interval:
self._start_ticker()
def on_disconnect(self, mqtt: MqttNode):
self._connected = False
self._mqtt_node_ref = None
def is_initialized(self):
return self._initialized
def set_initialized(self):
self._initialized = True
def unset_initialized(self):
self._initialized = False
def tick(self):
pass
def _tick(self):
g = next_tick_gen(self._tick_interval)
while self._connected:
sleep(next(g))
if not self._connected:
break
self.tick()
def _start_ticker(self):
if not self._ticker or not self._ticker.is_alive():
name_part = f'{self._mqtt_node_ref.id}/' if self._mqtt_node_ref else ''
self._ticker = None
self._ticker = threading.Thread(target=self._tick,
name=f'mqtt:{self.__class__.__name__}/{name_part}ticker')
self._ticker.start()
def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]:
pass

View File

@ -1,92 +0,0 @@
import logging
import importlib
from typing import List, TYPE_CHECKING, Optional
from ._payload import MqttPayload
from ._module import MqttModule
if TYPE_CHECKING:
from ._wrapper import MqttWrapper
else:
MqttWrapper = None
class MqttNode:
_modules: List[MqttModule]
_module_subscriptions: dict[str, MqttModule]
_node_id: str
_node_secret: str
_payload_callbacks: list[callable]
_wrapper: Optional[MqttWrapper]
def __init__(self,
node_id: str,
node_secret: Optional[str] = None):
self._modules = []
self._module_subscriptions = {}
self._node_id = node_id
self._node_secret = node_secret
self._payload_callbacks = []
self._logger = logging.getLogger(self.__class__.__name__)
self._wrapper = None
def on_connect(self, wrapper: MqttWrapper):
self._wrapper = wrapper
for module in self._modules:
if not module.is_initialized():
module.on_connect(self)
module.set_initialized()
def on_disconnect(self):
self._wrapper = None
for module in self._modules:
module.unset_initialized()
def on_message(self, topic, payload):
if topic in self._module_subscriptions:
payload = self._module_subscriptions[topic].handle_payload(self, topic, payload)
if isinstance(payload, MqttPayload):
for f in self._payload_callbacks:
f(self, payload)
def load_module(self, module_name: str, *args, **kwargs) -> MqttModule:
module = importlib.import_module(f'..module.{module_name}', __name__)
if not hasattr(module, 'MODULE_NAME'):
raise RuntimeError(f'MODULE_NAME not found in module {module}')
cl = getattr(module, getattr(module, 'MODULE_NAME'))
instance = cl(*args, **kwargs)
self.add_module(instance)
return instance
def add_module(self, module: MqttModule):
self._modules.append(module)
if self._wrapper and self._wrapper._connected:
module.on_connect(self)
module.set_initialized()
def subscribe_module(self, topic: str, module: MqttModule, qos: int = 1):
if not self._wrapper or not self._wrapper._connected:
raise RuntimeError('not connected')
self._module_subscriptions[topic] = module
self._wrapper.subscribe(self.id, topic, qos)
def publish(self,
topic: str,
payload: bytes,
qos: int = 1):
self._wrapper.publish(self.id, topic, payload, qos)
def add_payload_callback(self, callback: callable):
self._payload_callbacks.append(callback)
@property
def id(self) -> str:
return self._node_id
@property
def secret(self) -> str:
return self._node_secret
@secret.setter
def secret(self, secret: str) -> None:
self._node_secret = secret

View File

@ -1,15 +0,0 @@
import os
import re
from typing import List
def get_modules() -> List[str]:
modules = []
modules_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'module')
for name in os.listdir(modules_dir):
if os.path.isdir(os.path.join(modules_dir, name)):
continue
name = re.sub(r'\.py$', '', name)
modules.append(name)
return modules

View File

@ -1,80 +0,0 @@
import paho.mqtt.client as mqtt
from ._mqtt import Mqtt
from ._node import MqttNode
from ..util import strgen
class MqttWrapper(Mqtt):
_nodes: list[MqttNode]
_connect_callbacks: list[callable]
_disconnect_callbacks: list[callable]
def __init__(self,
client_id: str,
topic_prefix='hk',
randomize_client_id=False,
clean_session=True):
if randomize_client_id:
client_id += '_'+strgen(6)
super().__init__(clean_session=clean_session,
client_id=client_id)
self._nodes = []
self._connect_callbacks = []
self._disconnect_callbacks = []
self._topic_prefix = topic_prefix
def on_connect(self, client: mqtt.Client, userdata, flags, rc):
super().on_connect(client, userdata, flags, rc)
for node in self._nodes:
node.on_connect(self)
for f in self._connect_callbacks:
try:
f()
except Exception as e:
self._logger.exception(e)
def on_disconnect(self, client: mqtt.Client, userdata, rc):
super().on_disconnect(client, userdata, rc)
for node in self._nodes:
node.on_disconnect()
for f in self._disconnect_callbacks:
try:
f()
except Exception as e:
self._logger.exception(e)
def on_message(self, client: mqtt.Client, userdata, msg):
try:
topic = msg.topic
topic_node = topic[len(self._topic_prefix)+1:topic.find('/', len(self._topic_prefix)+1)]
for node in self._nodes:
if node.id in ('+', topic_node):
node.on_message(topic[len(f'{self._topic_prefix}/{node.id}/'):], msg.payload)
except Exception as e:
self._logger.exception(str(e))
def add_connect_callback(self, f: callable):
self._connect_callbacks.append(f)
def add_disconnect_callback(self, f: callable):
self._disconnect_callbacks.append(f)
def add_node(self, node: MqttNode):
self._nodes.append(node)
if self._connected:
node.on_connect(self)
def subscribe(self,
node_id: str,
topic: str,
qos: int):
self._client.subscribe(f'{self._topic_prefix}/{node_id}/{topic}', qos)
def publish(self,
node_id: str,
topic: str,
payload: bytes,
qos: int):
self._client.publish(f'{self._topic_prefix}/{node_id}/{topic}', payload, qos)
self._client.loop_write()

View File

@ -1,195 +0,0 @@
import time
import json
import datetime
try:
import inverterd
except:
pass
from typing import Optional
from .._module import MqttModule
from .._node import MqttNode
from .._payload import MqttPayload, bit_field
try:
from homekit.database import InverterDatabase
except:
pass
_mult_10 = lambda n: int(n*10)
_div_10 = lambda n: n/10
MODULE_NAME = 'MqttInverterModule'
STATUS_TOPIC = 'status'
GENERATION_TOPIC = 'generation'
class MqttInverterStatusPayload(MqttPayload):
# 46 bytes
FORMAT = 'IHHHHHHBHHHHHBHHHHHHHH'
PACKER = {
'grid_voltage': _mult_10,
'grid_freq': _mult_10,
'ac_output_voltage': _mult_10,
'ac_output_freq': _mult_10,
'battery_voltage': _mult_10,
'battery_voltage_scc': _mult_10,
'battery_voltage_scc2': _mult_10,
'pv1_input_voltage': _mult_10,
'pv2_input_voltage': _mult_10
}
UNPACKER = {
'grid_voltage': _div_10,
'grid_freq': _div_10,
'ac_output_voltage': _div_10,
'ac_output_freq': _div_10,
'battery_voltage': _div_10,
'battery_voltage_scc': _div_10,
'battery_voltage_scc2': _div_10,
'pv1_input_voltage': _div_10,
'pv2_input_voltage': _div_10
}
time: int
grid_voltage: float
grid_freq: float
ac_output_voltage: float
ac_output_freq: float
ac_output_apparent_power: int
ac_output_active_power: int
output_load_percent: int
battery_voltage: float
battery_voltage_scc: float
battery_voltage_scc2: float
battery_discharge_current: int
battery_charge_current: int
battery_capacity: int
inverter_heat_sink_temp: int
mppt1_charger_temp: int
mppt2_charger_temp: int
pv1_input_power: int
pv2_input_power: int
pv1_input_voltage: float
pv2_input_voltage: float
# H
mppt1_charger_status: bit_field(0, 16, 2)
mppt2_charger_status: bit_field(0, 16, 2)
battery_power_direction: bit_field(0, 16, 2)
dc_ac_power_direction: bit_field(0, 16, 2)
line_power_direction: bit_field(0, 16, 2)
load_connected: bit_field(0, 16, 1)
class MqttInverterGenerationPayload(MqttPayload):
# 8 bytes
FORMAT = 'II'
time: int
wh: int
class MqttInverterModule(MqttModule):
_status_poll_freq: int
_generation_poll_freq: int
_inverter: Optional[inverterd.Client]
_database: Optional[InverterDatabase]
_gen_prev: float
def __init__(self, status_poll_freq=0, generation_poll_freq=0):
super().__init__(tick_interval=status_poll_freq)
self._status_poll_freq = status_poll_freq
self._generation_poll_freq = generation_poll_freq
# this defines whether this is a publisher or a subscriber
if status_poll_freq > 0:
self._inverter = inverterd.Client()
self._inverter.connect()
self._inverter.format(inverterd.Format.SIMPLE_JSON)
self._database = None
else:
self._inverter = None
self._database = InverterDatabase()
self._gen_prev = 0
def on_connect(self, mqtt: MqttNode):
super().on_connect(mqtt)
if not self._inverter:
mqtt.subscribe_module(STATUS_TOPIC, self)
mqtt.subscribe_module(GENERATION_TOPIC, self)
def tick(self):
if not self._inverter:
return
# read status
now = time.time()
try:
raw = self._inverter.exec('get-status')
except inverterd.InverterError as e:
self._logger.error(f'inverter error: {str(e)}')
# TODO send to server
return
data = json.loads(raw)['data']
status = MqttInverterStatusPayload(time=round(now), **data)
self._mqtt_node_ref.publish(STATUS_TOPIC, status.pack())
# read today's generation stat
now = time.time()
if self._gen_prev == 0 or now - self._gen_prev >= self._generation_poll_freq:
self._gen_prev = now
today = datetime.date.today()
try:
raw = self._inverter.exec('get-day-generated', (today.year, today.month, today.day))
except inverterd.InverterError as e:
self._logger.error(f'inverter error: {str(e)}')
# TODO send to server
return
data = json.loads(raw)['data']
gen = MqttInverterGenerationPayload(time=round(now), wh=data['wh'])
self._mqtt_node_ref.publish(GENERATION_TOPIC, gen.pack())
def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]:
home_id = 1 # legacy compat
if topic == STATUS_TOPIC:
s = MqttInverterStatusPayload.unpack(payload)
self._database.add_status(home_id=home_id,
client_time=s.time,
grid_voltage=int(s.grid_voltage*10),
grid_freq=int(s.grid_freq * 10),
ac_output_voltage=int(s.ac_output_voltage * 10),
ac_output_freq=int(s.ac_output_freq * 10),
ac_output_apparent_power=s.ac_output_apparent_power,
ac_output_active_power=s.ac_output_active_power,
output_load_percent=s.output_load_percent,
battery_voltage=int(s.battery_voltage * 10),
battery_voltage_scc=int(s.battery_voltage_scc * 10),
battery_voltage_scc2=int(s.battery_voltage_scc2 * 10),
battery_discharge_current=s.battery_discharge_current,
battery_charge_current=s.battery_charge_current,
battery_capacity=s.battery_capacity,
inverter_heat_sink_temp=s.inverter_heat_sink_temp,
mppt1_charger_temp=s.mppt1_charger_temp,
mppt2_charger_temp=s.mppt2_charger_temp,
pv1_input_power=s.pv1_input_power,
pv2_input_power=s.pv2_input_power,
pv1_input_voltage=int(s.pv1_input_voltage * 10),
pv2_input_voltage=int(s.pv2_input_voltage * 10),
mppt1_charger_status=s.mppt1_charger_status,
mppt2_charger_status=s.mppt2_charger_status,
battery_power_direction=s.battery_power_direction,
dc_ac_power_direction=s.dc_ac_power_direction,
line_power_direction=s.line_power_direction,
load_connected=s.load_connected)
return s
elif topic == GENERATION_TOPIC:
gen = MqttInverterGenerationPayload.unpack(payload)
self._database.add_generation(home_id, gen.time, gen.wh)
return gen

View File

@ -1,79 +0,0 @@
import hashlib
from typing import Optional
from .._payload import MqttPayload
from .._node import MqttModule, MqttNode
MODULE_NAME = 'MqttOtaModule'
class OtaResultPayload(MqttPayload):
FORMAT = '=BB'
result: int
error_code: int
class OtaPayload(MqttPayload):
secret: str
filename: str
# structure of returned data:
#
# uint8_t[len(secret)] secret;
# uint8_t[16] md5;
# *uint8_t data
def pack(self):
buf = bytearray(self.secret.encode())
m = hashlib.md5()
with open(self.filename, 'rb') as fd:
content = fd.read()
m.update(content)
buf.extend(m.digest())
buf.extend(content)
return buf
def unpack(cls, buf: bytes):
raise RuntimeError(f'{cls.__class__.__name__}.unpack: not implemented')
# secret = buf[:12].decode()
# filename = buf[12:].decode()
# return OTAPayload(secret=secret, filename=filename)
class MqttOtaModule(MqttModule):
_ota_request: Optional[tuple[str, int]]
_custom_ota_topic: Optional[str]
def __init__(self, custom_ota_topic=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self._ota_request = None
self._custom_ota_topic = custom_ota_topic
def on_connect(self, mqtt: MqttNode):
super().on_connect(mqtt)
mqtt.subscribe_module("otares", self)
if self._ota_request is not None:
filename, qos = self._ota_request
self._ota_request = None
self.do_push_ota(self._mqtt_node_ref.secret, filename, qos)
def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]:
if topic == 'otares':
message = OtaResultPayload.unpack(payload)
self._logger.debug(message)
return message
def do_push_ota(self, secret: str, filename: str, qos: int):
payload = OtaPayload(secret=secret, filename=filename)
self._mqtt_node_ref.publish('ota' if not self._custom_ota_topic else self._custom_ota_topic,
payload=payload.pack(),
qos=qos)
def push_ota(self,
filename: str,
qos: int):
if not self._initialized:
self._ota_request = (filename, qos)
else:
self.do_push_ota(self._mqtt_node_ref.secret, filename, qos)

View File

@ -1,91 +0,0 @@
import datetime
from typing import Optional
from .. import MqttModule, MqttPayload, MqttNode
MODULE_NAME = 'MqttRelayModule'
class MqttPowerSwitchPayload(MqttPayload):
FORMAT = '=12sB'
PACKER = {
'state': lambda n: int(n),
'secret': lambda s: s.encode('utf-8')
}
UNPACKER = {
'state': lambda n: bool(n),
'secret': lambda s: s.decode('utf-8')
}
secret: str
state: bool
class MqttPowerStatusPayload(MqttPayload):
FORMAT = '=B'
PACKER = {
'opened': lambda n: int(n),
}
UNPACKER = {
'opened': lambda n: bool(n),
}
opened: bool
class MqttRelayState:
enabled: bool
update_time: datetime.datetime
rssi: int
fw_version: int
ever_updated: bool
def __init__(self):
self.ever_updated = False
self.enabled = False
self.rssi = 0
def update(self,
enabled: bool,
rssi: int,
fw_version=None):
self.ever_updated = True
self.enabled = enabled
self.rssi = rssi
self.update_time = datetime.datetime.now()
if fw_version:
self.fw_version = fw_version
class MqttRelayModule(MqttModule):
_legacy_topics: bool
def __init__(self, legacy_topics=False, *args, **kwargs):
super().__init__(*args, **kwargs)
self._legacy_topics = legacy_topics
def on_connect(self, mqtt: MqttNode):
super().on_connect(mqtt)
mqtt.subscribe_module(self._get_switch_topic(), self)
mqtt.subscribe_module('relay/status', self)
def switchpower(self, enable: bool):
payload = MqttPowerSwitchPayload(secret=self._mqtt_node_ref.secret,
state=enable)
self._mqtt_node_ref.publish(self._get_switch_topic(),
payload=payload.pack())
def handle_payload(self, mqtt: MqttNode, topic: str, payload: bytes) -> Optional[MqttPayload]:
message = None
if topic == self._get_switch_topic():
message = MqttPowerSwitchPayload.unpack(payload)
elif topic == 'relay/status':
message = MqttPowerStatusPayload.unpack(payload)
if message is not None:
self._logger.debug(message)
return message
def _get_switch_topic(self) -> str:
return 'relay/power' if self._legacy_topics else 'relay/switch'

View File

@ -1,75 +0,0 @@
from .._node import MqttNode
from .._module import MqttModule
from .._payload import MqttPayload
from typing import Optional
from ...temphum import BaseSensor
two_digits_precision = lambda x: round(x, 2)
MODULE_NAME = 'MqttTempHumModule'
DATA_TOPIC = 'temphum/data'
class MqttTemphumLegacyDataPayload(MqttPayload):
FORMAT = '=dd'
UNPACKER = {
'temp': two_digits_precision,
'rh': two_digits_precision
}
temp: float
rh: float
class MqttTemphumDataPayload(MqttTemphumLegacyDataPayload):
FORMAT = '=ddb'
temp: float
rh: float
error: int
class MqttTempHumModule(MqttModule):
_legacy_payload: bool
def __init__(self,
sensor: Optional[BaseSensor] = None,
legacy_payload=False,
write_to_database=False,
*args, **kwargs):
if sensor is not None:
kwargs['tick_interval'] = 10
super().__init__(*args, **kwargs)
self._sensor = sensor
self._legacy_payload = legacy_payload
def on_connect(self, mqtt: MqttNode):
super().on_connect(mqtt)
mqtt.subscribe_module(DATA_TOPIC, self)
def tick(self):
if not self._sensor:
return
error = 0
temp = 0
rh = 0
try:
temp = self._sensor.temperature()
rh = self._sensor.humidity()
except:
error = 1
pld = self._get_data_payload_cls()(temp=temp, rh=rh, error=error)
self._mqtt_node_ref.publish(DATA_TOPIC, pld.pack())
def handle_payload(self,
mqtt: MqttNode,
topic: str,
payload: bytes) -> Optional[MqttPayload]:
if topic == DATA_TOPIC:
message = self._get_data_payload_cls().unpack(payload)
self._logger.debug(message)
return message
def _get_data_payload_cls(self):
return MqttTemphumLegacyDataPayload if self._legacy_payload else MqttTemphumDataPayload

View File

@ -1,9 +0,0 @@
from .config import OpenwrtConfig
from .openwrt import (
ipset_list_all,
ipset_add,
ipset_del,
set_upstream,
get_default_route,
get_dhcp_leases
)

View File

@ -1,14 +0,0 @@
from typing import Optional
from homekit.config import ConfigUnit
class OpenwrtConfig(ConfigUnit):
NAME = 'openwrt'
@classmethod
def schema(cls) -> Optional[dict]:
return {
'ip': cls._addr_schema(only_ip=True, required=True),
'command_id': {'type': 'string', 'required': True}
}

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