Compare commits
1 Commits
master
...
temphum-re
Author | SHA1 | Date | |
---|---|---|---|
![]() |
2f1c00bed4 |
@ -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
15
.gitignore
vendored
@ -1,24 +1,19 @@
|
||||
.idea
|
||||
.vscode
|
||||
/venv
|
||||
/node_modules
|
||||
*.pyc
|
||||
config.def.h
|
||||
__pycache__
|
||||
.DS_Store
|
||||
/include/test/test_inverter_monitor.log
|
||||
/src/test/test_inverter_monitor.log
|
||||
/youtrack-certificate
|
||||
/cpp
|
||||
/test/test.py
|
||||
/bin/test.py
|
||||
/arduino/ESP32CameraWebServer/wifi_password.h
|
||||
/src/test.py
|
||||
/esp32-cam/CameraWebServer/wifi_password.h
|
||||
cmake-build-*
|
||||
.pio
|
||||
platformio.ini
|
||||
CMakeListsPrivate.txt
|
||||
/pio/*/CMakeLists.txt
|
||||
/pio/*/CMakeListsPrivate.txt
|
||||
/pio/*/.gitignore
|
||||
|
||||
*.swp
|
||||
|
||||
/localwebsite/vendor
|
||||
@ -28,4 +23,4 @@ CMakeListsPrivate.txt
|
||||
/localwebsite/test.php
|
||||
|
||||
/watchos/InfiniSolar/Pods
|
||||
xcuserdata
|
||||
xcuserdata
|
85
IDEAInspectionsProfile.xml
Normal file
85
IDEAInspectionsProfile.xml
Normal 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>
|
2
LICENSE
2
LICENSE
@ -1,4 +1,4 @@
|
||||
Copyright 2022-2024, Evgeny Zinoviev
|
||||
Copyright 2022, Evgeny Zinoviev
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
|
@ -5,6 +5,12 @@ a country house, solving real life tasks.
|
||||
|
||||
Mostly undocumented.
|
||||
|
||||
## TODO
|
||||
|
||||
esp8266/esp32 code:
|
||||
|
||||
- move common stuff to the `commom` directory and use it as a framework
|
||||
|
||||
## License
|
||||
|
||||
BSD-3c
|
||||
|
@ -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...')
|
@ -1 +0,0 @@
|
||||
../include_homekit.py
|
@ -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()
|
@ -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())
|
@ -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()
|
@ -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()
|
@ -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()
|
@ -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)
|
@ -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
|
@ -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)
|
@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import include_homekit
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('TODO')
|
140
bin/pio_ini.py
140
bin/pio_ini.py
@ -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)
|
297
bin/pump_bot.py
297
bin/pump_bot.py
@ -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
|
@ -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()
|
@ -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()
|
@ -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()
|
@ -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()
|
610
bin/web_kbn.py
610
bin/web_kbn.py
@ -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)
|
@ -1,4 +1,4 @@
|
||||
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
7
doc/localwebsite.md
Normal file
@ -0,0 +1,7 @@
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
apt install nginx-extras php-fpm php-mbstring php-sqlite3 php-curl php-simplexml php-gmp composer
|
||||
```
|
||||
|
||||
|
@ -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.
|
@ -1 +0,0 @@
|
||||
#define ARRAY_SIZE(X) sizeof((X))/sizeof((X)[0])
|
@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "homekit_config",
|
||||
"version": "1.0.2",
|
||||
"build": {
|
||||
"flags": "-I../../include"
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "homekit_http_server",
|
||||
"version": "1.0.3",
|
||||
"build": {
|
||||
"flags": "-I../../include"
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
||||
}
|
@ -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
|
@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "homekit_led",
|
||||
"version": "1.0.8",
|
||||
"build": {
|
||||
"flags": "-I../../include"
|
||||
}
|
||||
}
|
||||
|
@ -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
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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) {}
|
||||
|
||||
}
|
@ -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
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
@ -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
|
@ -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
|
@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "homekit_mqtt",
|
||||
"version": "1.0.12",
|
||||
"build": {
|
||||
"flags": "-I../../include"
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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
|
@ -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"
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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
|
@ -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"
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
@ -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
|
@ -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"
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
@ -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
|
@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "homekit_relay",
|
||||
"version": "1.0.0",
|
||||
"build": {
|
||||
"flags": "-I../../include"
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "homekit_static",
|
||||
"version": "1.0.1",
|
||||
"build": {
|
||||
"flags": "-I../../include"
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
@ -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) {}
|
||||
};
|
||||
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "homekit_temphum",
|
||||
"version": "1.0.4",
|
||||
"build": {
|
||||
"flags": "-I../../include"
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "homekit_wifi",
|
||||
"version": "1.0.1",
|
||||
"build": {
|
||||
"flags": "-I../../include"
|
||||
}
|
||||
}
|
||||
|
@ -1 +0,0 @@
|
||||
from .isapi import ISAPIClient, ResponseError, AuthError
|
@ -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)
|
@ -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()
|
@ -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}")
|
@ -1,5 +0,0 @@
|
||||
from .web_api_client import (
|
||||
RequestParams as RequestParams,
|
||||
WebApiClient as WebApiClient
|
||||
)
|
||||
from .config import WebApiConfig as WebApiConfig
|
@ -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)
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
from .types import CameraType, VideoContainerType, VideoCodecType, CaptureType
|
||||
from .config import IpcamConfig
|
@ -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
|
@ -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'
|
@ -1,11 +0,0 @@
|
||||
from .config import (
|
||||
Config,
|
||||
ConfigUnit,
|
||||
AppConfigUnit,
|
||||
Translation,
|
||||
Language,
|
||||
config,
|
||||
is_development_mode,
|
||||
setup_logging,
|
||||
CONFIG_DIRECTORIES
|
||||
)
|
@ -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)
|
@ -1,9 +0,0 @@
|
||||
import os
|
||||
|
||||
|
||||
def get_data_root_directory() -> str:
|
||||
return os.path.join(
|
||||
os.environ['HOME'],
|
||||
'.config',
|
||||
'homekit',
|
||||
'data')
|
@ -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()
|
@ -1 +0,0 @@
|
||||
from .http import serve, ajax_ok, HTTPMethod
|
@ -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'
|
@ -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),
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
from .config import LinuxBoardsConfig
|
||||
from .types import LinuxBoardType
|
@ -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'])
|
@ -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
|
@ -1,2 +0,0 @@
|
||||
from .config import ModemsConfig
|
||||
from .e3372 import E3372, MacroNetWorkType
|
@ -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']
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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()
|
@ -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
|
@ -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)
|
@ -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'
|
@ -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
|
||||
|
@ -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
|
||||
)
|
@ -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
Loading…
x
Reference in New Issue
Block a user