Compare commits
20 Commits
master
...
legacy-ipc
Author | SHA1 | Date | |
---|---|---|---|
![]() |
077495eba6 | ||
![]() |
b83f3e0eb7 | ||
![]() |
3a8961837c | ||
![]() |
40bcc7f5f4 | ||
![]() |
c17410c073 | ||
![]() |
cc67e1e2db | ||
![]() |
f95472b413 | ||
![]() |
9ee4fc4fde | ||
![]() |
4af565b27d | ||
![]() |
75b2517c50 | ||
![]() |
b9de2f2ce5 | ||
![]() |
e505c57464 | ||
![]() |
2ebc4c68ce | ||
![]() |
8d4045f6c3 | ||
![]() |
72a45b8521 | ||
![]() |
014f310353 | ||
![]() |
c712beb699 | ||
![]() |
c857f58b40 | ||
![]() |
ae2787b3ae | ||
![]() |
e26851a600 |
12
.gitignore
vendored
12
.gitignore
vendored
@ -6,19 +6,17 @@
|
||||
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
|
||||
/platformio/*/CMakeLists.txt
|
||||
/platformio/*/CMakeListsPrivate.txt
|
||||
*.swp
|
||||
|
||||
/localwebsite/vendor
|
||||
|
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
|
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)
|
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,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,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,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,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 +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}
|
||||
}
|
@ -1,90 +0,0 @@
|
||||
import requests
|
||||
import logging
|
||||
|
||||
from datetime import datetime
|
||||
from collections import namedtuple
|
||||
from urllib.parse import quote_plus
|
||||
from .config import OpenwrtConfig
|
||||
from ..modem.config import ModemsConfig
|
||||
|
||||
DHCPLease = namedtuple('DHCPLease', 'time, time_s, mac, ip, hostname')
|
||||
_config = OpenwrtConfig()
|
||||
_modems_config = ModemsConfig()
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ipset_list_all() -> list:
|
||||
args = ['ipset-list-all']
|
||||
args += _modems_config.keys()
|
||||
lines = _to_list(_call(args))
|
||||
sets = {}
|
||||
cur_set = None
|
||||
for line in lines:
|
||||
if line.startswith('>'):
|
||||
cur_set = line[1:]
|
||||
if cur_set not in sets:
|
||||
sets[cur_set] = []
|
||||
continue
|
||||
|
||||
if cur_set is None:
|
||||
_logger.error('ipset_list_all: cur_set is not set')
|
||||
continue
|
||||
|
||||
sets[cur_set].append(line)
|
||||
|
||||
return sets
|
||||
|
||||
|
||||
def ipset_add(set_name: str, ip: str):
|
||||
return _call(['ipset-add', set_name, ip])
|
||||
|
||||
|
||||
def ipset_del(set_name: str, ip: str):
|
||||
return _call(['ipset-del', set_name, ip])
|
||||
|
||||
|
||||
def set_upstream(ip: str):
|
||||
return _call(['homekit-set-default-upstream', ip])
|
||||
|
||||
|
||||
def get_default_route():
|
||||
return _call(['get-default-route'])
|
||||
|
||||
|
||||
def get_dhcp_leases() -> list[DHCPLease]:
|
||||
return list(map(lambda item: _to_dhcp_lease(item), _to_list(_call(['dhcp-leases']))))
|
||||
|
||||
|
||||
def _call(arguments: list[str]) -> str:
|
||||
url = _get_link(arguments)
|
||||
r = requests.get(url)
|
||||
r.raise_for_status()
|
||||
return r.text.strip()
|
||||
|
||||
|
||||
def _get_link(arguments: list[str]) -> str:
|
||||
url = f'http://{_config["ip"]}/cgi-bin/luci/command/{_config["command_id"]}'
|
||||
if arguments:
|
||||
url += '/'
|
||||
url += '%20'.join(list(map(lambda arg: quote_plus(arg.replace('/', '_')), arguments)))
|
||||
return url
|
||||
|
||||
|
||||
def _to_list(s: str) -> list:
|
||||
return [] if s == '' else s.split('\n')
|
||||
|
||||
|
||||
def _to_dhcp_lease(s: str) -> DHCPLease:
|
||||
words = s.split(' ')
|
||||
time = int(words.pop(0))
|
||||
mac = words.pop(0)
|
||||
ip = words.pop(0)
|
||||
words.pop()
|
||||
hostname = (' '.join(words)).strip()
|
||||
if not hostname or hostname == '*':
|
||||
hostname = '?'
|
||||
return DHCPLease(time=time,
|
||||
time_s=datetime.fromtimestamp(time).strftime('%d %b, %H:%M:%S'),
|
||||
mac=mac,
|
||||
ip=ip,
|
||||
hostname=hostname)
|
@ -1,78 +0,0 @@
|
||||
from ..config import ConfigUnit
|
||||
from typing import Optional, Union
|
||||
from abc import ABC
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TelegramUserListType(Enum):
|
||||
USERS = 'users'
|
||||
NOTIFY = 'notify_users'
|
||||
|
||||
|
||||
class TelegramUserIdsConfig(ConfigUnit):
|
||||
NAME = 'telegram_user_ids'
|
||||
|
||||
@classmethod
|
||||
def schema(cls) -> Optional[dict]:
|
||||
return {
|
||||
'roottype': 'dict',
|
||||
'type': 'integer'
|
||||
}
|
||||
|
||||
|
||||
_user_ids_config = TelegramUserIdsConfig()
|
||||
|
||||
|
||||
def _user_id_mapper(user: Union[str, int]) -> int:
|
||||
if isinstance(user, int):
|
||||
return user
|
||||
return _user_ids_config[user]
|
||||
|
||||
|
||||
class TelegramChatsConfig(ConfigUnit):
|
||||
NAME = 'telegram_chats'
|
||||
|
||||
@classmethod
|
||||
def schema(cls) -> Optional[dict]:
|
||||
return {
|
||||
'type': 'dict',
|
||||
'schema': {
|
||||
'id': {'type': 'string', 'required': True},
|
||||
'token': {'type': 'string', 'required': True},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TelegramBotConfig(ConfigUnit, ABC):
|
||||
@classmethod
|
||||
def schema(cls) -> Optional[dict]:
|
||||
return {
|
||||
'bot': {
|
||||
'type': 'dict',
|
||||
'schema': {
|
||||
'token': {'type': 'string', 'required': True},
|
||||
TelegramUserListType.USERS.value: {**TelegramBotConfig._userlist_schema(), 'required': True},
|
||||
TelegramUserListType.NOTIFY.value: TelegramBotConfig._userlist_schema(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _userlist_schema() -> dict:
|
||||
return {'type': 'list', 'schema': {'type': ['string', 'integer']}}
|
||||
|
||||
@staticmethod
|
||||
def custom_validator(data):
|
||||
for ult in TelegramUserListType:
|
||||
users = data['bot'][ult.value]
|
||||
for user in users:
|
||||
if isinstance(user, str):
|
||||
if user not in _user_ids_config:
|
||||
raise ValueError(f'user {user} not found in {TelegramUserIdsConfig.NAME}')
|
||||
|
||||
def get_user_ids(self,
|
||||
ult: TelegramUserListType = TelegramUserListType.USERS) -> list[int]:
|
||||
try:
|
||||
return list(map(_user_id_mapper, self['bot'][ult.value]))
|
||||
except KeyError:
|
||||
return []
|
@ -1 +0,0 @@
|
||||
from .base import SensorType, BaseSensor
|
@ -1,52 +0,0 @@
|
||||
import abc
|
||||
import smbus
|
||||
|
||||
from .base import BaseSensor, SensorType
|
||||
|
||||
|
||||
class I2CSensor(BaseSensor, abc.ABC):
|
||||
def __init__(self, bus: int):
|
||||
super().__init__()
|
||||
self.bus = smbus.SMBus(bus)
|
||||
|
||||
|
||||
class DHT12(I2CSensor):
|
||||
i2c_addr = 0x5C
|
||||
|
||||
def _measure(self):
|
||||
raw = self.bus.read_i2c_block_data(self.i2c_addr, 0, 5)
|
||||
if (raw[0] + raw[1] + raw[2] + raw[3]) & 0xff != raw[4]:
|
||||
raise ValueError("checksum error")
|
||||
return raw
|
||||
|
||||
def temperature(self) -> float:
|
||||
raw = self._measure()
|
||||
temp = raw[2] + (raw[3] & 0x7f) * 0.1
|
||||
if raw[3] & 0x80:
|
||||
temp *= -1
|
||||
return temp
|
||||
|
||||
def humidity(self) -> float:
|
||||
raw = self._measure()
|
||||
return raw[0] + raw[1] * 0.1
|
||||
|
||||
|
||||
class Si7021(I2CSensor):
|
||||
i2c_addr = 0x40
|
||||
|
||||
def temperature(self) -> float:
|
||||
raw = self.bus.read_i2c_block_data(self.i2c_addr, 0xE3, 2)
|
||||
return 175.72 * (raw[0] << 8 | raw[1]) / 65536.0 - 46.85
|
||||
|
||||
def humidity(self) -> float:
|
||||
raw = self.bus.read_i2c_block_data(self.i2c_addr, 0xE5, 2)
|
||||
return 125.0 * (raw[0] << 8 | raw[1]) / 65536.0 - 6.0
|
||||
|
||||
|
||||
def create_sensor(type: SensorType, bus: int) -> BaseSensor:
|
||||
if type == SensorType.Si7021:
|
||||
return Si7021(bus)
|
||||
elif type == SensorType.DHT12:
|
||||
return DHT12(bus)
|
||||
else:
|
||||
raise ValueError('unexpected sensor type')
|
@ -1,42 +0,0 @@
|
||||
# numenworld-ipcam - Reverse engineering of the NuMenWorld NCV-I536A IP camera
|
||||
# Copyright (C) 2019-2019 Johannes Bauer
|
||||
#
|
||||
# This file is part of numenworld-ipcam (https://github.com/johndoe31415/numenworld-ipcam).
|
||||
#
|
||||
# numenworld-ipcam is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; this program is ONLY licensed under
|
||||
# version 3 of the License, later versions are explicitly excluded.
|
||||
#
|
||||
# numenworld-ipcam is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with numenworld-ipcam; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Johannes Bauer <JohannesBauer@gmx.de>
|
||||
|
||||
import hashlib
|
||||
|
||||
|
||||
class HorrificallyBrokenPasswordFunction():
|
||||
@classmethod
|
||||
def derive(self, passphrase):
|
||||
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
assert (len(alphabet) == 62)
|
||||
passphrase = passphrase.encode("utf-8")
|
||||
hashval = hashlib.md5(passphrase).digest()
|
||||
encoded = ""
|
||||
for i in range(0, 16, 2):
|
||||
index = (hashval[i] + hashval[i + 1]) % len(alphabet)
|
||||
char = alphabet[index]
|
||||
encoded += char
|
||||
return encoded
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
assert (HorrificallyBrokenPasswordFunction.derive("") == "tlJwpbo6")
|
||||
assert (HorrificallyBrokenPasswordFunction.derive("abc") == "LkM7s2Ht")
|
@ -1 +0,0 @@
|
||||
from .nwipcam import XMEyeCamera
|
@ -1,326 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# numenworld-ipcam - Reverse engineering of the NuMenWorld NCV-I536A IP camera
|
||||
# Copyright (C) 2019-2019 Johannes Bauer
|
||||
#
|
||||
# Changes and improvements:
|
||||
# Copyright (C) 2024 Evgeny Zinoviev
|
||||
#
|
||||
# This file is part of numenworld-ipcam (https://github.com/johndoe31415/numenworld-ipcam).
|
||||
#
|
||||
# numenworld-ipcam is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; this program is ONLY licensed under
|
||||
# version 3 of the License, later versions are explicitly excluded.
|
||||
#
|
||||
# numenworld-ipcam is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with numenworld-ipcam; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Johannes Bauer <JohannesBauer@gmx.de>
|
||||
|
||||
import collections
|
||||
import struct
|
||||
import socket
|
||||
import enum
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
from .HorrificallyBrokenPasswordFunction import HorrificallyBrokenPasswordFunction
|
||||
|
||||
|
||||
class XMEyeMsgCode(enum.IntEnum):
|
||||
LoginCmd = 1000
|
||||
LoginReply = LoginCmd + 1
|
||||
|
||||
KeepAliveCmd = 1006
|
||||
KeepAliveReply = KeepAliveCmd + 1
|
||||
|
||||
SetConfigCmd = 1040
|
||||
SetConfigReply = SetConfigCmd + 1
|
||||
|
||||
GetConfigCmd = 1042
|
||||
GetConfidReply = GetConfigCmd + 1
|
||||
|
||||
GetSystemInfoCmd = 1020
|
||||
GetSystemInfoReply = GetSystemInfoCmd + 1
|
||||
|
||||
ChannelTitleCmd = 1048
|
||||
ChannelTitleReply = ChannelTitleCmd + 1
|
||||
|
||||
SystemFunctionCmd = 1360
|
||||
SystemFunctionReply = SystemFunctionCmd + 1
|
||||
|
||||
OPMonitorStartStopCmd = 1410
|
||||
OPMonitorStartStopReply = OPMonitorStartStopCmd + 1
|
||||
|
||||
OPMonitorClaimCmd = 1413
|
||||
OPMonitorClaimReply = OPMonitorClaimCmd + 1
|
||||
|
||||
OPTimeQueryCmd = 1452
|
||||
OPTimeQueryReply = OPTimeQueryCmd + 1
|
||||
|
||||
VideoStreamData = 1412
|
||||
|
||||
|
||||
class AudioVideoDataType(enum.IntEnum):
|
||||
VideoIncomplete = 0xfc
|
||||
VideoComplete = 0xfd
|
||||
AudioComplete = 0xfa
|
||||
|
||||
|
||||
class AudioVideoPayload():
|
||||
_HeaderFields = collections.namedtuple("HeaderFields", ["unknown1", "channel", "datatype", "unknown2", "length"])
|
||||
_HeaderStruct = struct.Struct("< H B B H H")
|
||||
|
||||
def __init__(self, payload, hint=""):
|
||||
self._header = self._HeaderFields(*self._HeaderStruct.unpack(payload[:self._HeaderStruct.size]))
|
||||
print(
|
||||
"%20s [%5d]: %s %s" % (hint, len(payload), " ".join("%02x" % (c) for c in payload[:8]), str(self._header)))
|
||||
# if len(payload) != (self._HeaderStruct.size + self._header.length):
|
||||
# raise Exception("Unexpected AV payload, expected %d bytes but got %d." % (self._HeaderStruct.size + self._header.length, len(payload)))
|
||||
# print(self._header)
|
||||
self._data = payload[self._HeaderStruct.size:]
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self._data
|
||||
|
||||
|
||||
class XMEyeMessage():
|
||||
_HeaderFields = collections.namedtuple("HeaderFields", ["station", "session", "unknown", "msgcode", "length"])
|
||||
_HeaderStruct = struct.Struct("< L L 6s H L")
|
||||
|
||||
def __init__(self, station, session, msgcode, message):
|
||||
if isinstance(message, (bytes, bytearray)):
|
||||
self._message = bytes(message)
|
||||
else:
|
||||
self._message = json.dumps(message).encode("ascii")
|
||||
self._header = self._HeaderFields(station=station,
|
||||
session=session,
|
||||
unknown=bytes(6),
|
||||
msgcode=msgcode,
|
||||
length=len(self._message))
|
||||
|
||||
@property
|
||||
def header(self):
|
||||
return self._header
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return self._message
|
||||
|
||||
@property
|
||||
def payload(self):
|
||||
msg = self.message.rstrip(bytes(1))
|
||||
try:
|
||||
data = json.loads(msg)
|
||||
except (json.JSONDecodeError, UnicodeError):
|
||||
return self.message
|
||||
return data
|
||||
|
||||
@property
|
||||
def length(self):
|
||||
return len(self.message)
|
||||
|
||||
def __bytes__(self):
|
||||
header = self._HeaderStruct.pack(*self._header)
|
||||
return header + self._message
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, msg):
|
||||
header_data = msg[:20]
|
||||
header = cls._HeaderFields(*cls._HeaderStruct.unpack(header_data))
|
||||
payload = msg[20: 20 + header.length]
|
||||
if len(payload) < header.length:
|
||||
payload += bytes(header.length - len(payload))
|
||||
|
||||
try:
|
||||
msgcode = XMEyeMsgCode(header.msgcode)
|
||||
except ValueError:
|
||||
msgcode = header.msgcode
|
||||
return cls(station=header.station, session=header.session, msgcode=msgcode, message=payload)
|
||||
|
||||
@classmethod
|
||||
def deserialize_all(cls, msg):
|
||||
msg = bytearray(msg)
|
||||
while len(msg) >= 20:
|
||||
next_msg = cls.deserialize(msg)
|
||||
yield next_msg
|
||||
msg = msg[20 + next_msg.length:]
|
||||
|
||||
def dump(self):
|
||||
print("%s (%d bytes payload):" % (self._header.msgcode, self.length))
|
||||
if isinstance(self.payload, bytes):
|
||||
print(self.payload)
|
||||
else:
|
||||
print(json.dumps(self.payload, indent=4, sort_keys=True))
|
||||
print()
|
||||
|
||||
def __repr__(self):
|
||||
return "Msg(%s): %s" % (self.header, str(self.payload))
|
||||
|
||||
|
||||
class XMEyeCamera():
|
||||
def __init__(self, hostname, username="admin", password="", port=34567):
|
||||
self._hostname = hostname
|
||||
self._conn = socket.create_connection((hostname, port))
|
||||
self._session = None
|
||||
self._username = username
|
||||
self._password = password
|
||||
|
||||
@property
|
||||
def derived_password(self):
|
||||
return HorrificallyBrokenPasswordFunction.derive(self._password)
|
||||
|
||||
@property
|
||||
def rtsp_uri(self):
|
||||
rtsp_port = 554
|
||||
return "rtsp://%s:%d/user=%s&password=%s&channel=" % (
|
||||
self._hostname, rtsp_port, self._username, self.derived_password)
|
||||
|
||||
def _rx_bytes(self, length):
|
||||
result = bytearray()
|
||||
while len(result) < length:
|
||||
remaining_bytes = length - len(result)
|
||||
rx_data = self._conn.recv(remaining_bytes)
|
||||
result += rx_data
|
||||
return result
|
||||
|
||||
def _rx(self):
|
||||
response_header = self._rx_bytes(20)
|
||||
header = XMEyeMessage.deserialize(response_header)
|
||||
payload_data = self._rx_bytes(header.length)
|
||||
|
||||
response_data = response_header + payload_data
|
||||
response = XMEyeMessage.deserialize(response_data)
|
||||
# print("<-", response)
|
||||
return response
|
||||
|
||||
def _tx(self, command):
|
||||
# print("->", command)
|
||||
data = bytes(command)
|
||||
self._conn.send(data)
|
||||
|
||||
def _tx_rx(self, command):
|
||||
self._tx(command)
|
||||
return self._rx()
|
||||
|
||||
def login(self):
|
||||
data = {
|
||||
"EncryptType": "MD5",
|
||||
"LoginType": "DVRIP-Web",
|
||||
"UserName": self._username,
|
||||
"PassWord": self.derived_password,
|
||||
}
|
||||
command = XMEyeMessage(station=0xff, session=0, msgcode=XMEyeMsgCode.LoginCmd, message=data)
|
||||
response = self._tx_rx(command)
|
||||
if int(response.payload["Ret"]) == 100:
|
||||
# Login successful
|
||||
self._session = int(response.payload["SessionID"], 16)
|
||||
else:
|
||||
raise Exception("Login failed:", response)
|
||||
|
||||
def _generic_cmd(self, name, msgcode):
|
||||
data = {
|
||||
"Name": name,
|
||||
"SessionID": "0x%x" % (self._session,),
|
||||
}
|
||||
command = XMEyeMessage(station=0xff, session=self._session, msgcode=msgcode, message=data)
|
||||
return self._tx_rx(command)
|
||||
|
||||
def get_systeminfo(self):
|
||||
return self._generic_cmd("SystemInfo", XMEyeMsgCode.GetSystemInfoCmd)
|
||||
|
||||
def get_channel_title(self):
|
||||
return self._generic_cmd("ChannelTitle", XMEyeMsgCode.ChannelTitleCmd)
|
||||
|
||||
def get_system_function(self):
|
||||
return self._generic_cmd("SystemFunction", XMEyeMsgCode.SystemFunctionCmd)
|
||||
|
||||
def get_talk_audio_format(self):
|
||||
return self._generic_cmd("TalkAudioFormat", XMEyeMsgCode.SystemFunctionCmd)
|
||||
|
||||
def get_ntp_server(self):
|
||||
ntp_config = self._generic_cmd("NetWork.NetNTP", XMEyeMsgCode.GetConfigCmd)
|
||||
return ntp_config.payload['NetWork.NetNTP']['Server']['Name']
|
||||
|
||||
def set_ntp_server(self,
|
||||
ntp_host: str,
|
||||
ntp_port: int = 123) -> None:
|
||||
data = {
|
||||
'Name': 'NetWork.NetNTP',
|
||||
'NetWork.NetNTP': {
|
||||
'Enable': True,
|
||||
'Server': {
|
||||
'Address': '0x00000000',
|
||||
'Anonymity': False,
|
||||
'Name': ntp_host,
|
||||
'Password': '',
|
||||
'Port': ntp_port,
|
||||
'UserName': ''
|
||||
},
|
||||
'TimeZone': 13, # Moscow time
|
||||
'UpdatePeriod': 60
|
||||
},
|
||||
"SessionID": "0x%x" % (self._session,),
|
||||
}
|
||||
command = XMEyeMessage(station=0xff, session=self._session, msgcode=XMEyeMsgCode.SetConfigCmd, message=data)
|
||||
self._tx_rx(command)
|
||||
|
||||
def _opmonitor_cmd(self, action, msgcode, rx_msg=True):
|
||||
data = {
|
||||
"Name": "OPMonitor",
|
||||
"OPMonitor": {
|
||||
"Action": action,
|
||||
"Parameter": {
|
||||
"Channel": 0,
|
||||
"CombinMode": "CONNECT_ALL",
|
||||
"StreamType": "Main",
|
||||
"TransMode": "TCP",
|
||||
},
|
||||
},
|
||||
"SessionID": "0x%x" % (self._session,),
|
||||
}
|
||||
command = XMEyeMessage(station=0xff, session=self._session, msgcode=msgcode, message=data)
|
||||
if rx_msg:
|
||||
return self._tx_rx(command)
|
||||
else:
|
||||
self._tx(command)
|
||||
|
||||
def get_stream(self, packet_callback):
|
||||
self._opmonitor_cmd("Claim", XMEyeMsgCode.OPMonitorClaimCmd)
|
||||
self._opmonitor_cmd("Start", XMEyeMsgCode.OPMonitorStartStopCmd, rx_msg=False)
|
||||
while True:
|
||||
rx_pkt = self._rx()
|
||||
packet_callback(rx_pkt)
|
||||
|
||||
# def playback_stream(self):
|
||||
# mplayer_process = subprocess.Popen(["mplayer", "-demuxer", "h264es", "-"], stdin=subprocess.PIPE)
|
||||
# with open("audio.raw", "wb") as f, open("video.raw", "wb") as video_f:
|
||||
# def pkt_callback(pkt):
|
||||
# if (pkt.header.station == 511) and (pkt.header.msgcode == XMEyeMsgCode.VideoStreamData):
|
||||
# avpayload = AudioVideoPayload(pkt.payload, hint="video")
|
||||
# mplayer_process.stdin.raw.write(pkt.payload)
|
||||
# video_f.write(pkt.payload)
|
||||
# elif pkt.header.msgcode == XMEyeMsgCode.VideoStreamData:
|
||||
# # Audio data?
|
||||
# avpayload = AudioVideoPayload(pkt.payload, hint="audio")
|
||||
# f.write(avpayload.data)
|
||||
# elif pkt.header.msgcode != XMEyeMsgCode.VideoStreamData:
|
||||
# print(pkt)
|
||||
#
|
||||
# self.get_stream(pkt_callback)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
cam = XMEyeCamera("192.168.0.47", password="DerPr03ess")
|
||||
cam.login()
|
||||
print(cam.get_systeminfo())
|
||||
print(cam.get_channel_title())
|
||||
# print(cam.get_talk_audio_format())
|
||||
# print(cam.get_system_function())
|
@ -1,9 +0,0 @@
|
||||
import sys
|
||||
import os.path
|
||||
|
||||
for _name in ('include/py',):
|
||||
sys.path.extend([
|
||||
os.path.realpath(
|
||||
os.path.join(os.path.dirname(os.path.join(__file__)), '..', _name)
|
||||
)
|
||||
])
|
310
localwebsite/classes/E3372.php
Normal file
310
localwebsite/classes/E3372.php
Normal file
@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
class E3372
|
||||
{
|
||||
|
||||
const WIFI_CONNECTING = '900';
|
||||
const WIFI_CONNECTED = '901';
|
||||
const WIFI_DISCONNECTED = '902';
|
||||
const WIFI_DISCONNECTING = '903';
|
||||
|
||||
const CRADLE_CONNECTING = '900';
|
||||
const CRADLE_CONNECTED = '901';
|
||||
const CRADLE_DISCONNECTED = '902';
|
||||
const CRADLE_DISCONNECTING = '903';
|
||||
const CRADLE_CONNECTFAILED = '904';
|
||||
const CRADLE_CONNECTSTATUSNULL = '905';
|
||||
const CRANDLE_CONNECTSTATUSERRO = '906';
|
||||
|
||||
const MACRO_EVDO_LEVEL_ZERO = '0';
|
||||
const MACRO_EVDO_LEVEL_ONE = '1';
|
||||
const MACRO_EVDO_LEVEL_TWO = '2';
|
||||
const MACRO_EVDO_LEVEL_THREE = '3';
|
||||
const MACRO_EVDO_LEVEL_FOUR = '4';
|
||||
const MACRO_EVDO_LEVEL_FIVE = '5';
|
||||
|
||||
// CurrentNetworkType
|
||||
const MACRO_NET_WORK_TYPE_NOSERVICE = 0;
|
||||
const MACRO_NET_WORK_TYPE_GSM = 1;
|
||||
const MACRO_NET_WORK_TYPE_GPRS = 2;
|
||||
const MACRO_NET_WORK_TYPE_EDGE = 3;
|
||||
const MACRO_NET_WORK_TYPE_WCDMA = 4;
|
||||
const MACRO_NET_WORK_TYPE_HSDPA = 5;
|
||||
const MACRO_NET_WORK_TYPE_HSUPA = 6;
|
||||
const MACRO_NET_WORK_TYPE_HSPA = 7;
|
||||
const MACRO_NET_WORK_TYPE_TDSCDMA = 8;
|
||||
const MACRO_NET_WORK_TYPE_HSPA_PLUS = 9;
|
||||
const MACRO_NET_WORK_TYPE_EVDO_REV_0 = 10;
|
||||
const MACRO_NET_WORK_TYPE_EVDO_REV_A = 11;
|
||||
const MACRO_NET_WORK_TYPE_EVDO_REV_B = 12;
|
||||
const MACRO_NET_WORK_TYPE_1xRTT = 13;
|
||||
const MACRO_NET_WORK_TYPE_UMB = 14;
|
||||
const MACRO_NET_WORK_TYPE_1xEVDV = 15;
|
||||
const MACRO_NET_WORK_TYPE_3xRTT = 16;
|
||||
const MACRO_NET_WORK_TYPE_HSPA_PLUS_64QAM = 17;
|
||||
const MACRO_NET_WORK_TYPE_HSPA_PLUS_MIMO = 18;
|
||||
const MACRO_NET_WORK_TYPE_LTE = 19;
|
||||
const MACRO_NET_WORK_TYPE_EX_NOSERVICE = 0;
|
||||
const MACRO_NET_WORK_TYPE_EX_GSM = 1;
|
||||
const MACRO_NET_WORK_TYPE_EX_GPRS = 2;
|
||||
const MACRO_NET_WORK_TYPE_EX_EDGE = 3;
|
||||
const MACRO_NET_WORK_TYPE_EX_IS95A = 21;
|
||||
const MACRO_NET_WORK_TYPE_EX_IS95B = 22;
|
||||
const MACRO_NET_WORK_TYPE_EX_CDMA_1x = 23;
|
||||
const MACRO_NET_WORK_TYPE_EX_EVDO_REV_0 = 24;
|
||||
const MACRO_NET_WORK_TYPE_EX_EVDO_REV_A = 25;
|
||||
const MACRO_NET_WORK_TYPE_EX_EVDO_REV_B = 26;
|
||||
const MACRO_NET_WORK_TYPE_EX_HYBRID_CDMA_1x = 27;
|
||||
const MACRO_NET_WORK_TYPE_EX_HYBRID_EVDO_REV_0 = 28;
|
||||
const MACRO_NET_WORK_TYPE_EX_HYBRID_EVDO_REV_A = 29;
|
||||
const MACRO_NET_WORK_TYPE_EX_HYBRID_EVDO_REV_B = 30;
|
||||
const MACRO_NET_WORK_TYPE_EX_EHRPD_REL_0 = 31;
|
||||
const MACRO_NET_WORK_TYPE_EX_EHRPD_REL_A = 32;
|
||||
const MACRO_NET_WORK_TYPE_EX_EHRPD_REL_B = 33;
|
||||
const MACRO_NET_WORK_TYPE_EX_HYBRID_EHRPD_REL_0 = 34;
|
||||
const MACRO_NET_WORK_TYPE_EX_HYBRID_EHRPD_REL_A = 35;
|
||||
const MACRO_NET_WORK_TYPE_EX_HYBRID_EHRPD_REL_B = 36;
|
||||
const MACRO_NET_WORK_TYPE_EX_WCDMA = 41;
|
||||
const MACRO_NET_WORK_TYPE_EX_HSDPA = 42;
|
||||
const MACRO_NET_WORK_TYPE_EX_HSUPA = 43;
|
||||
const MACRO_NET_WORK_TYPE_EX_HSPA = 44;
|
||||
const MACRO_NET_WORK_TYPE_EX_HSPA_PLUS = 45;
|
||||
const MACRO_NET_WORK_TYPE_EX_DC_HSPA_PLUS = 46;
|
||||
const MACRO_NET_WORK_TYPE_EX_TD_SCDMA = 61;
|
||||
const MACRO_NET_WORK_TYPE_EX_TD_HSDPA = 62;
|
||||
const MACRO_NET_WORK_TYPE_EX_TD_HSUPA = 63;
|
||||
const MACRO_NET_WORK_TYPE_EX_TD_HSPA = 64;
|
||||
const MACRO_NET_WORK_TYPE_EX_TD_HSPA_PLUS = 65;
|
||||
const MACRO_NET_WORK_TYPE_EX_802_16E = 81;
|
||||
const MACRO_NET_WORK_TYPE_EX_LTE = 101;
|
||||
|
||||
|
||||
const ERROR_SYSTEM_NO_SUPPORT = 100002;
|
||||
const ERROR_SYSTEM_NO_RIGHTS = 100003;
|
||||
const ERROR_SYSTEM_BUSY = 100004;
|
||||
const ERROR_LOGIN_USERNAME_WRONG = 108001;
|
||||
const ERROR_LOGIN_PASSWORD_WRONG = 108002;
|
||||
const ERROR_LOGIN_ALREADY_LOGIN = 108003;
|
||||
const ERROR_LOGIN_USERNAME_PWD_WRONG = 108006;
|
||||
const ERROR_LOGIN_USERNAME_PWD_ORERRUN = 108007;
|
||||
const ERROR_LOGIN_TOUCH_ALREADY_LOGIN = 108009;
|
||||
const ERROR_VOICE_BUSY = 120001;
|
||||
const ERROR_WRONG_TOKEN = 125001;
|
||||
const ERROR_WRONG_SESSION = 125002;
|
||||
const ERROR_WRONG_SESSION_TOKEN = 125003;
|
||||
|
||||
private string $host;
|
||||
private array $headers = [];
|
||||
private bool $authorized = false;
|
||||
private bool $useLegacyTokenAuth = false;
|
||||
|
||||
public function __construct(string $host, bool $legacy_token_auth = false) {
|
||||
$this->host = $host;
|
||||
$this->useLegacyTokenAuth = $legacy_token_auth;
|
||||
}
|
||||
|
||||
public function auth() {
|
||||
if ($this->authorized)
|
||||
return;
|
||||
|
||||
if (!$this->useLegacyTokenAuth) {
|
||||
$data = $this->request('webserver/SesTokInfo');
|
||||
$this->headers = [
|
||||
'Cookie: '.$data['SesInfo'],
|
||||
'__RequestVerificationToken: '.$data['TokInfo'],
|
||||
'Content-Type: text/xml'
|
||||
];
|
||||
} else {
|
||||
$data = $this->request('webserver/token');
|
||||
$this->headers = [
|
||||
'__RequestVerificationToken: '.$data['token'],
|
||||
'Content-Type: text/xml'
|
||||
];
|
||||
}
|
||||
$this->authorized = true;
|
||||
}
|
||||
|
||||
public function getDeviceInformation() {
|
||||
$this->auth();
|
||||
return $this->request('device/information');
|
||||
}
|
||||
|
||||
public function getDeviceSignal() {
|
||||
$this->auth();
|
||||
return $this->request('device/signal');
|
||||
}
|
||||
|
||||
public function getMonitoringStatus() {
|
||||
$this->auth();
|
||||
return $this->request('monitoring/status');
|
||||
}
|
||||
|
||||
public function getNotifications() {
|
||||
$this->auth();
|
||||
return $this->request('monitoring/check-notifications');
|
||||
}
|
||||
|
||||
public function getDialupConnection() {
|
||||
$this->auth();
|
||||
return $this->request('dialup/connection');
|
||||
}
|
||||
|
||||
public function getTrafficStats() {
|
||||
$this->auth();
|
||||
return $this->request('monitoring/traffic-statistics');
|
||||
}
|
||||
|
||||
public function getSMSCount() {
|
||||
$this->auth();
|
||||
return $this->request('sms/sms-count');
|
||||
}
|
||||
|
||||
public function sendSMS(string $phone, string $text) {
|
||||
$this->auth();
|
||||
return $this->request('sms/send-sms', 'POST', [
|
||||
'Index' => -1,
|
||||
'Phones' => [
|
||||
'Phone' => $phone
|
||||
],
|
||||
'Sca' => '',
|
||||
'Content' => $text,
|
||||
'Length' => -1,
|
||||
'Reserved' => 1,
|
||||
'Date' => -1
|
||||
]);
|
||||
}
|
||||
|
||||
public function getSMSList(int $page = 1, int $count = 20, bool $outbox = false) {
|
||||
$this->auth();
|
||||
$xml = $this->request('sms/sms-list', 'POST', [
|
||||
'PageIndex' => $page,
|
||||
'ReadCount' => $count,
|
||||
'BoxType' => !$outbox ? 1 : 2,
|
||||
'SortType' => 0,
|
||||
'Ascending' => 0,
|
||||
'UnreadPreferred' => !$outbox ? 1 : 0
|
||||
], true);
|
||||
$xml = simplexml_load_string($xml);
|
||||
|
||||
$messages = [];
|
||||
foreach ($xml->Messages->Message as $message) {
|
||||
$dt = DateTime::createFromFormat("Y-m-d H:i:s", (string)$message->Date);
|
||||
$messages[] = [
|
||||
'date' => (string)$message->Date,
|
||||
'timestamp' => $dt->getTimestamp(),
|
||||
'phone' => (string)$message->Phone,
|
||||
'content' => (string)$message->Content
|
||||
];
|
||||
}
|
||||
return $messages;
|
||||
}
|
||||
|
||||
private function xmlToAssoc(string $xml): array {
|
||||
$xml = new SimpleXMLElement($xml);
|
||||
$data = [];
|
||||
foreach ($xml as $name => $value) {
|
||||
$data[$name] = (string)$value;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function request(string $method, string $http_method = 'GET', array $data = [], bool $return_body = false) {
|
||||
$ch = curl_init();
|
||||
$url = 'http://'.$this->host.'/api/'.$method;
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
if (!empty($this->headers))
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $this->headers);
|
||||
if ($http_method == 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
|
||||
$post_data = $this->postDataToXML($data);
|
||||
// debugLog('post_data:', $post_data);
|
||||
|
||||
if (!empty($data))
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $post_data);
|
||||
}
|
||||
$body = curl_exec($ch);
|
||||
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
if ($code != 200)
|
||||
throw new Exception('e3372 host returned code '.$code);
|
||||
|
||||
curl_close($ch);
|
||||
return $return_body ? $body : $this->xmlToAssoc($body);
|
||||
}
|
||||
|
||||
private function postDataToXML(array $data, int $depth = 1): string {
|
||||
if ($depth == 1)
|
||||
return '<?xml version: "1.0" encoding="UTF-8"?>'.$this->postDataToXML(['request' => $data], $depth+1);
|
||||
|
||||
$items = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value))
|
||||
$value = $this->postDataToXML($value, $depth+1);
|
||||
$items[] = "<{$key}>{$value}</{$key}>";
|
||||
}
|
||||
|
||||
return implode('', $items);
|
||||
}
|
||||
|
||||
public static function getNetworkTypeLabel($type): string {
|
||||
switch ((int)$type) {
|
||||
case self::MACRO_NET_WORK_TYPE_NOSERVICE: return 'NOSERVICE';
|
||||
case self::MACRO_NET_WORK_TYPE_GSM: return 'GSM';
|
||||
case self::MACRO_NET_WORK_TYPE_GPRS: return 'GPRS';
|
||||
case self::MACRO_NET_WORK_TYPE_EDGE: return 'EDGE';
|
||||
case self::MACRO_NET_WORK_TYPE_WCDMA: return 'WCDMA';
|
||||
case self::MACRO_NET_WORK_TYPE_HSDPA: return 'HSDPA';
|
||||
case self::MACRO_NET_WORK_TYPE_HSUPA: return 'HSUPA';
|
||||
case self::MACRO_NET_WORK_TYPE_HSPA: return 'HSPA';
|
||||
case self::MACRO_NET_WORK_TYPE_TDSCDMA: return 'TDSCDMA';
|
||||
case self::MACRO_NET_WORK_TYPE_HSPA_PLUS: return 'HSPA_PLUS';
|
||||
case self::MACRO_NET_WORK_TYPE_EVDO_REV_0: return 'EVDO_REV_0';
|
||||
case self::MACRO_NET_WORK_TYPE_EVDO_REV_A: return 'EVDO_REV_A';
|
||||
case self::MACRO_NET_WORK_TYPE_EVDO_REV_B: return 'EVDO_REV_B';
|
||||
case self::MACRO_NET_WORK_TYPE_1xRTT: return '1xRTT';
|
||||
case self::MACRO_NET_WORK_TYPE_UMB: return 'UMB';
|
||||
case self::MACRO_NET_WORK_TYPE_1xEVDV: return '1xEVDV';
|
||||
case self::MACRO_NET_WORK_TYPE_3xRTT: return '3xRTT';
|
||||
case self::MACRO_NET_WORK_TYPE_HSPA_PLUS_64QAM: return 'HSPA_PLUS_64QAM';
|
||||
case self::MACRO_NET_WORK_TYPE_HSPA_PLUS_MIMO: return 'HSPA_PLUS_MIMO';
|
||||
case self::MACRO_NET_WORK_TYPE_LTE: return 'LTE';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_NOSERVICE: return 'NOSERVICE';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_GSM: return 'GSM';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_GPRS: return 'GPRS';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_EDGE: return 'EDGE';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_IS95A: return 'IS95A';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_IS95B: return 'IS95B';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_CDMA_1x: return 'CDMA_1x';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_EVDO_REV_0: return 'EVDO_REV_0';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_EVDO_REV_A: return 'EVDO_REV_A';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_EVDO_REV_B: return 'EVDO_REV_B';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_HYBRID_CDMA_1x: return 'HYBRID_CDMA_1x';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_HYBRID_EVDO_REV_0: return 'HYBRID_EVDO_REV_0';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_HYBRID_EVDO_REV_A: return 'HYBRID_EVDO_REV_A';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_HYBRID_EVDO_REV_B: return 'HYBRID_EVDO_REV_B';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_EHRPD_REL_0: return 'EHRPD_REL_0';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_EHRPD_REL_A: return 'EHRPD_REL_A';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_EHRPD_REL_B: return 'EHRPD_REL_B';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_HYBRID_EHRPD_REL_0: return 'HYBRID_EHRPD_REL_0';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_HYBRID_EHRPD_REL_A: return 'HYBRID_EHRPD_REL_A';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_HYBRID_EHRPD_REL_B: return 'HYBRID_EHRPD_REL_B';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_WCDMA: return 'WCDMA';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_HSDPA: return 'HSDPA';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_HSUPA: return 'HSUPA';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_HSPA: return 'HSPA';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_HSPA_PLUS: return 'HSPA_PLUS';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_DC_HSPA_PLUS: return 'DC_HSPA_PLUS';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_TD_SCDMA: return 'TD_SCDMA';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_TD_HSDPA: return 'TD_HSDPA';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_TD_HSUPA: return 'TD_HSUPA';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_TD_HSPA: return 'TD_HSPA';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_TD_HSPA_PLUS: return 'TD_HSPA_PLUS';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_802_16E: return '802_16E';
|
||||
case self::MACRO_NET_WORK_TYPE_EX_LTE: return 'LTE';
|
||||
default: return '?';
|
||||
}
|
||||
}
|
||||
|
||||
}
|
18
localwebsite/classes/GPIORelaydClient.php
Normal file
18
localwebsite/classes/GPIORelaydClient.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
class GPIORelaydClient extends MySimpleSocketClient {
|
||||
|
||||
const STATUS_ON = 'on';
|
||||
const STATUS_OFF = 'off';
|
||||
|
||||
public function setStatus(string $status) {
|
||||
$this->send($status);
|
||||
return $this->recv();
|
||||
}
|
||||
|
||||
public function getStatus() {
|
||||
$this->send('get');
|
||||
return $this->recv();
|
||||
}
|
||||
|
||||
}
|
69
localwebsite/classes/InverterdClient.php
Normal file
69
localwebsite/classes/InverterdClient.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
class InverterdClient extends MySimpleSocketClient {
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function setProtocol(int $v): string
|
||||
{
|
||||
$this->send("v $v");
|
||||
return $this->recv();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function setFormat(string $fmt): string
|
||||
{
|
||||
$this->send("format $fmt");
|
||||
return $this->recv();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function exec(string $command, array $arguments = []): string
|
||||
{
|
||||
$buf = "exec $command";
|
||||
if (!empty($arguments)) {
|
||||
foreach ($arguments as $arg)
|
||||
$buf .= " $arg";
|
||||
}
|
||||
$this->send($buf);
|
||||
return $this->recv();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function recv()
|
||||
{
|
||||
$recv_buf = '';
|
||||
$buf = '';
|
||||
|
||||
while (true) {
|
||||
$result = socket_recv($this->sock, $recv_buf, 1024, 0);
|
||||
if ($result === false)
|
||||
throw new Exception(__METHOD__ . ": socket_recv() failed: " . $this->getSocketError());
|
||||
|
||||
// peer disconnected
|
||||
if ($result === 0)
|
||||
break;
|
||||
|
||||
$buf .= $recv_buf;
|
||||
if (endsWith($buf, "\r\n\r\n"))
|
||||
break;
|
||||
}
|
||||
|
||||
$response = explode("\r\n", $buf);
|
||||
$status = array_shift($response);
|
||||
if (!in_array($status, ['ok', 'err']))
|
||||
throw new Exception(__METHOD__.': unexpected status ('.$status.')');
|
||||
if ($status == 'err')
|
||||
throw new Exception(empty($response) ? 'unknown inverterd error' : $response[0]);
|
||||
|
||||
return trim(implode("\r\n", $response));
|
||||
}
|
||||
|
||||
}
|
131
localwebsite/classes/MyOpenWrtUtils.php
Normal file
131
localwebsite/classes/MyOpenWrtUtils.php
Normal file
@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
class MyOpenWrtUtils {
|
||||
|
||||
// public static function getRoutingTable(?string $table = null): array {
|
||||
// $arguments = ['route-show'];
|
||||
// if ($table)
|
||||
// $arguments[] = $table;
|
||||
//
|
||||
// return self::toList(self::run($arguments));
|
||||
// }
|
||||
//
|
||||
// public static function getRoutingRules(): array {
|
||||
// return self::toList(self::run(['rule-show']));
|
||||
// }
|
||||
//
|
||||
// public static function ipsetList(string $set_name): array {
|
||||
// return self::toList(self::run(['ipset-list', $set_name]));
|
||||
// }
|
||||
|
||||
public static function ipsetListAll(): array {
|
||||
global $config;
|
||||
|
||||
$args = ['ipset-list-all'];
|
||||
$args = array_merge($args, array_keys($config['modems']));
|
||||
|
||||
$lines = self::toList(self::run($args));
|
||||
|
||||
$sets = [];
|
||||
$cur_set = null;
|
||||
foreach ($lines as $line) {
|
||||
if (startsWith($line, '>')) {
|
||||
$cur_set = substr($line, 1);
|
||||
if (!isset($sets[$cur_set]))
|
||||
$sets[$cur_set] = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_null($cur_set)) {
|
||||
debugError(__METHOD__.': cur_set is not set');
|
||||
continue;
|
||||
}
|
||||
|
||||
$sets[$cur_set][] = $line;
|
||||
}
|
||||
|
||||
return $sets;
|
||||
}
|
||||
|
||||
public static function ipsetAdd(string $set_name, string $ip) {
|
||||
return self::run(['ipset-add', $set_name, $ip]);
|
||||
}
|
||||
|
||||
public static function ipsetDel(string $set_name, string $ip) {
|
||||
return self::run(['ipset-del', $set_name, $ip]);
|
||||
}
|
||||
|
||||
public static function getDHCPLeases(): array {
|
||||
$list = self::toList(self::run(['dhcp-leases']));
|
||||
$list = array_map('self::toDHCPLease', $list);
|
||||
return $list;
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// http functions
|
||||
//
|
||||
|
||||
private static function run(array $arguments) {
|
||||
$url = self::getLink($arguments);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
$body = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
if ($code != 200)
|
||||
throw new Exception(__METHOD__.': http code '.$code);
|
||||
|
||||
curl_close($ch);
|
||||
return trim($body);
|
||||
}
|
||||
|
||||
private static function getLink($arguments) {
|
||||
global $config;
|
||||
|
||||
$url = 'http://'.$config['openwrt_ip'].'/cgi-bin/luci/command/cfg099944';
|
||||
if (!empty($arguments)) {
|
||||
$arguments = array_map(function($arg) {
|
||||
$arg = str_replace('/', '_', $arg);
|
||||
return urlencode($arg);
|
||||
}, $arguments);
|
||||
$arguments = implode('%20', $arguments);
|
||||
|
||||
$url .= '/';
|
||||
$url .= $arguments;
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// parsing functions
|
||||
//
|
||||
|
||||
private static function toList(string $s): array {
|
||||
if ($s == '')
|
||||
return [];
|
||||
return explode("\n", $s);
|
||||
}
|
||||
|
||||
private static function toDHCPLease(string $s): array {
|
||||
$words = explode(' ', $s);
|
||||
$time = array_shift($words);
|
||||
$mac = array_shift($words);
|
||||
$ip = array_shift($words);
|
||||
array_pop($words);
|
||||
$hostname = trim(implode(' ', $words));
|
||||
if (!$hostname || $hostname == '*')
|
||||
$hostname = '?';
|
||||
return [
|
||||
'time' => $time,
|
||||
'time_s' => date('d M, H:i:s', $time),
|
||||
'mac' => $mac,
|
||||
'ip' => $ip,
|
||||
'hostname' => $hostname
|
||||
];
|
||||
}
|
||||
|
||||
}
|
90
localwebsite/classes/MySimpleSocketClient.php
Normal file
90
localwebsite/classes/MySimpleSocketClient.php
Normal file
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
class MySimpleSocketClient {
|
||||
|
||||
protected $sock;
|
||||
|
||||
public function __construct(string $host, int $port)
|
||||
{
|
||||
if (($socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP)) === false)
|
||||
throw new Exception("socket_create() failed: ".$this->getSocketError());
|
||||
|
||||
$this->sock = $socket;
|
||||
|
||||
if ((socket_connect($socket, $host, $port)) === false)
|
||||
throw new Exception("socket_connect() failed: ".$this->getSocketError());
|
||||
}
|
||||
|
||||
public function __destruct()
|
||||
{
|
||||
$this->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function send(string $data)
|
||||
{
|
||||
$data .= "\r\n";
|
||||
$remained = strlen($data);
|
||||
|
||||
while ($remained > 0) {
|
||||
$result = socket_write($this->sock, $data);
|
||||
if ($result === false)
|
||||
throw new Exception(__METHOD__ . ": socket_write() failed: ".$this->getSocketError());
|
||||
|
||||
$remained -= $result;
|
||||
if ($remained > 0)
|
||||
$data = substr($data, $result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function recv()
|
||||
{
|
||||
$recv_buf = '';
|
||||
$buf = '';
|
||||
|
||||
while (true) {
|
||||
$result = socket_recv($this->sock, $recv_buf, 1024, 0);
|
||||
if ($result === false)
|
||||
throw new Exception(__METHOD__ . ": socket_recv() failed: " . $this->getSocketError());
|
||||
|
||||
// peer disconnected
|
||||
if ($result === 0)
|
||||
break;
|
||||
|
||||
$buf .= $recv_buf;
|
||||
if (endsWith($buf, "\r\n"))
|
||||
break;
|
||||
}
|
||||
|
||||
return trim($buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close connection.
|
||||
*/
|
||||
public function close()
|
||||
{
|
||||
if (!$this->sock)
|
||||
return;
|
||||
|
||||
socket_close($this->sock);
|
||||
$this->sock = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
protected function getSocketError(): string
|
||||
{
|
||||
$sle_args = [];
|
||||
if ($this->sock !== null)
|
||||
$sle_args[] = $this->sock;
|
||||
return socket_strerror(socket_last_error(...$sle_args));
|
||||
}
|
||||
|
||||
}
|
37
localwebsite/classes/TelegramBotClient.php
Normal file
37
localwebsite/classes/TelegramBotClient.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
class TelegramBotClient {
|
||||
|
||||
protected string $token;
|
||||
|
||||
public function __construct(string $token) {
|
||||
$this->token = $token;
|
||||
}
|
||||
|
||||
public function sendMessage(int $chat_id, string $text): bool {
|
||||
$ch = curl_init();
|
||||
$url = 'https://api.telegram.org/bot'.$this->token.'/sendMessage';
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, [
|
||||
'chat_id' => $chat_id,
|
||||
'text' => $text,
|
||||
'parse_mode' => 'html',
|
||||
'disable_web_page_preview' => 1
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$resp = jsonDecode($body);
|
||||
if (!$resp['ok']) {
|
||||
debugError(__METHOD__ . ': ' . $body);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
41
localwebsite/classes/TemphumdClient.php
Normal file
41
localwebsite/classes/TemphumdClient.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
class TemphumdClient extends MySimpleSocketClient {
|
||||
|
||||
public string $name;
|
||||
public float $temp;
|
||||
public float $humidity;
|
||||
public ?int $flags;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct(string $host, int $port, string $name, ?int $flags = null) {
|
||||
parent::__construct($host, $port);
|
||||
$this->name = $name;
|
||||
$this->flags = $flags;
|
||||
|
||||
socket_set_timeout($this->sock, 3);
|
||||
}
|
||||
|
||||
public function readSensor(): void {
|
||||
$this->send('read');
|
||||
|
||||
$data = jsonDecode($this->recv());
|
||||
|
||||
$temp = round((float)$data['temp'], 3);
|
||||
$hum = round((float)$data['humidity'], 3);
|
||||
|
||||
$this->temp = $temp;
|
||||
$this->humidity = $hum;
|
||||
}
|
||||
|
||||
public function hasTemperature(): bool {
|
||||
return ($this->flags & config::TEMPHUMD_NO_TEMP) == 0;
|
||||
}
|
||||
|
||||
public function hasHumidity(): bool {
|
||||
return ($this->flags & config::TEMPHUMD_NO_HUM) == 0;
|
||||
}
|
||||
|
||||
}
|
11
localwebsite/classes/User.php
Normal file
11
localwebsite/classes/User.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
class User extends model {
|
||||
|
||||
const DB_TABLE = 'users';
|
||||
|
||||
public int $id;
|
||||
public string $username;
|
||||
public string $password;
|
||||
|
||||
}
|
60
localwebsite/classes/auth.php
Normal file
60
localwebsite/classes/auth.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
class auth {
|
||||
|
||||
public static ?User $authorizedUser = null;
|
||||
|
||||
const COOKIE_NAME = 'lws-auth';
|
||||
|
||||
public static function getToken(): ?string {
|
||||
return $_COOKIE[self::COOKIE_NAME] ?? null;
|
||||
}
|
||||
|
||||
public static function setToken(string $token) {
|
||||
setcookie_safe(self::COOKIE_NAME, $token);
|
||||
}
|
||||
|
||||
public static function resetToken() {
|
||||
if (!headers_sent())
|
||||
unsetcookie(self::COOKIE_NAME);
|
||||
}
|
||||
|
||||
public static function id(bool $do_check = true): int {
|
||||
if ($do_check)
|
||||
self::check();
|
||||
|
||||
if (!self::$authorizedUser)
|
||||
return 0;
|
||||
|
||||
return self::$authorizedUser->id;
|
||||
}
|
||||
|
||||
public static function check(?string $pwhash = null): bool {
|
||||
if (self::$authorizedUser !== null)
|
||||
return true;
|
||||
|
||||
// get auth token
|
||||
if (!$pwhash)
|
||||
$pwhash = self::getToken();
|
||||
|
||||
if (!is_string($pwhash))
|
||||
return false;
|
||||
|
||||
// find session by given token
|
||||
$user = users::getUserByPwhash($pwhash);
|
||||
if (is_null($user)) {
|
||||
self::resetToken();
|
||||
return false;
|
||||
}
|
||||
|
||||
self::$authorizedUser = $user;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function logout() {
|
||||
self::resetToken();
|
||||
self::$authorizedUser = null;
|
||||
}
|
||||
|
||||
}
|
13
localwebsite/classes/config.php
Normal file
13
localwebsite/classes/config.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
class config {
|
||||
|
||||
const TEMPHUMD_NO_TEMP = 1 << 0;
|
||||
const TEMPHUMD_NO_HUM = 1 << 1;
|
||||
|
||||
public static function get(string $key) {
|
||||
global $config;
|
||||
return is_callable($config[$key]) ? $config[$key]() : $config[$key];
|
||||
}
|
||||
|
||||
}
|
39
localwebsite/classes/users.php
Normal file
39
localwebsite/classes/users.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
class users {
|
||||
|
||||
public static function add(string $username, string $password): int {
|
||||
$db = getDB();
|
||||
$db->insert('users', [
|
||||
'username' => $username,
|
||||
'password' => pwhash($password)
|
||||
]);
|
||||
return $db->insertId();
|
||||
}
|
||||
|
||||
public static function exists(string $username): bool {
|
||||
$db = getDB();
|
||||
$count = (int)$db->querySingle("SELECT COUNT(*) FROM users WHERE username=?", $username);
|
||||
return $count > 0;
|
||||
}
|
||||
|
||||
public static function validatePassword(string $username, string $password): bool {
|
||||
$db = getDB();
|
||||
$row = $db->querySingleRow("SELECT * FROM users WHERE username=?", $username);
|
||||
if (!$row)
|
||||
return false;
|
||||
|
||||
return $row['password'] == pwhash($password);
|
||||
}
|
||||
|
||||
public static function getUserByPwhash(string $pwhash): ?User {
|
||||
$db = getDB();
|
||||
$data = $db->querySingleRow("SELECT * FROM users WHERE password=?", $pwhash);
|
||||
return $data ? new User($data) : null;
|
||||
}
|
||||
|
||||
public static function setPassword(int $id, string $new_password) {
|
||||
getDB()->exec("UPDATE users SET password=? WHERE id=?", pwhash($new_password), $id);
|
||||
}
|
||||
|
||||
}
|
16
localwebsite/composer.json
Normal file
16
localwebsite/composer.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "ch1p/localwebsite.homekit",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"twig/twig": "^3.3",
|
||||
"ext-mbstring": "*",
|
||||
"ext-sockets": "*",
|
||||
"ext-simplexml": "*",
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-gmp": "*",
|
||||
"ext-sqlite3": "*",
|
||||
"giggsey/libphonenumber-for-php": "^8.12"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
341
localwebsite/composer.lock
generated
Normal file
341
localwebsite/composer.lock
generated
Normal file
@ -0,0 +1,341 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "aad58d1c2f9900517de6f62599845b12",
|
||||
"packages": [
|
||||
{
|
||||
"name": "giggsey/libphonenumber-for-php",
|
||||
"version": "8.12.51",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/giggsey/libphonenumber-for-php.git",
|
||||
"reference": "a42d89a46797083a95aa48393485fdac22fcac94"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/giggsey/libphonenumber-for-php/zipball/a42d89a46797083a95aa48393485fdac22fcac94",
|
||||
"reference": "a42d89a46797083a95aa48393485fdac22fcac94",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"giggsey/locale": "^1.7|^2.0",
|
||||
"php": ">=5.3.2",
|
||||
"symfony/polyfill-mbstring": "^1.17"
|
||||
},
|
||||
"require-dev": {
|
||||
"pear/pear-core-minimal": "^1.9",
|
||||
"pear/pear_exception": "^1.0",
|
||||
"pear/versioncontrol_git": "^0.5",
|
||||
"phing/phing": "^2.7",
|
||||
"php-coveralls/php-coveralls": "^1.0|^2.0",
|
||||
"symfony/console": "^2.8|^3.0|^v4.4|^v5.2",
|
||||
"symfony/phpunit-bridge": "^4.2 || ^5"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "8.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"libphonenumber\\": "src/"
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/src/data/",
|
||||
"/src/carrier/data/",
|
||||
"/src/geocoding/data/",
|
||||
"/src/timezone/data/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Joshua Gigg",
|
||||
"email": "giggsey@gmail.com",
|
||||
"homepage": "https://giggsey.com/"
|
||||
}
|
||||
],
|
||||
"description": "PHP Port of Google's libphonenumber",
|
||||
"homepage": "https://github.com/giggsey/libphonenumber-for-php",
|
||||
"keywords": [
|
||||
"geocoding",
|
||||
"geolocation",
|
||||
"libphonenumber",
|
||||
"mobile",
|
||||
"phonenumber",
|
||||
"validation"
|
||||
],
|
||||
"support": {
|
||||
"irc": "irc://irc.appliedirc.com/lobby",
|
||||
"issues": "https://github.com/giggsey/libphonenumber-for-php/issues",
|
||||
"source": "https://github.com/giggsey/libphonenumber-for-php"
|
||||
},
|
||||
"time": "2022-07-11T08:12:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "giggsey/locale",
|
||||
"version": "2.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/giggsey/Locale.git",
|
||||
"reference": "9c1dca769253f6a3e81f9a5c167f53b6a54ab635"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/giggsey/Locale/zipball/9c1dca769253f6a3e81f9a5c167f53b6a54ab635",
|
||||
"reference": "9c1dca769253f6a3e81f9a5c167f53b6a54ab635",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-json": "*",
|
||||
"pear/pear-core-minimal": "^1.9",
|
||||
"pear/pear_exception": "^1.0",
|
||||
"pear/versioncontrol_git": "^0.5",
|
||||
"phing/phing": "^2.7",
|
||||
"php-coveralls/php-coveralls": "^2.0",
|
||||
"phpunit/phpunit": "^8.5|^9.5",
|
||||
"symfony/console": "^5.0",
|
||||
"symfony/filesystem": "^5.0",
|
||||
"symfony/finder": "^5.0",
|
||||
"symfony/process": "^5.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Giggsey\\Locale\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Joshua Gigg",
|
||||
"email": "giggsey@gmail.com",
|
||||
"homepage": "https://giggsey.com/"
|
||||
}
|
||||
],
|
||||
"description": "Locale functions required by libphonenumber-for-php",
|
||||
"support": {
|
||||
"issues": "https://github.com/giggsey/Locale/issues",
|
||||
"source": "https://github.com/giggsey/Locale/tree/2.2"
|
||||
},
|
||||
"time": "2022-04-06T07:33:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-ctype",
|
||||
"version": "v1.23.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-ctype.git",
|
||||
"reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/46cd95797e9df938fdd2b03693b5fca5e64b01ce",
|
||||
"reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-ctype": "For best performance"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "1.23-dev"
|
||||
},
|
||||
"thanks": {
|
||||
"name": "symfony/polyfill",
|
||||
"url": "https://github.com/symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Ctype\\": ""
|
||||
},
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Gert de Pagter",
|
||||
"email": "BackEndTea@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill for ctype functions",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"ctype",
|
||||
"polyfill",
|
||||
"portable"
|
||||
],
|
||||
"time": "2021-02-19T12:13:01+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-mbstring",
|
||||
"version": "v1.23.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-mbstring.git",
|
||||
"reference": "2df51500adbaebdc4c38dea4c89a2e131c45c8a1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/2df51500adbaebdc4c38dea4c89a2e131c45c8a1",
|
||||
"reference": "2df51500adbaebdc4c38dea4c89a2e131c45c8a1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-mbstring": "For best performance"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "1.23-dev"
|
||||
},
|
||||
"thanks": {
|
||||
"name": "symfony/polyfill",
|
||||
"url": "https://github.com/symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Mbstring\\": ""
|
||||
},
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill for the Mbstring extension",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"mbstring",
|
||||
"polyfill",
|
||||
"portable",
|
||||
"shim"
|
||||
],
|
||||
"time": "2021-05-27T09:27:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "twig/twig",
|
||||
"version": "v3.3.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/twigphp/Twig.git",
|
||||
"reference": "21578f00e83d4a82ecfa3d50752b609f13de6790"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/twigphp/Twig/zipball/21578f00e83d4a82ecfa3d50752b609f13de6790",
|
||||
"reference": "21578f00e83d4a82ecfa3d50752b609f13de6790",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2.5",
|
||||
"symfony/polyfill-ctype": "^1.8",
|
||||
"symfony/polyfill-mbstring": "^1.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"psr/container": "^1.0",
|
||||
"symfony/phpunit-bridge": "^4.4.9|^5.0.9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.3-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Twig\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com",
|
||||
"homepage": "http://fabien.potencier.org",
|
||||
"role": "Lead Developer"
|
||||
},
|
||||
{
|
||||
"name": "Twig Team",
|
||||
"role": "Contributors"
|
||||
},
|
||||
{
|
||||
"name": "Armin Ronacher",
|
||||
"email": "armin.ronacher@active-4.com",
|
||||
"role": "Project Founder"
|
||||
}
|
||||
],
|
||||
"description": "Twig, the flexible, fast, and secure template language for PHP",
|
||||
"homepage": "https://twig.symfony.com",
|
||||
"keywords": [
|
||||
"templating"
|
||||
],
|
||||
"time": "2021-05-16T12:14:13+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"ext-mbstring": "*",
|
||||
"ext-sockets": "*",
|
||||
"ext-simplexml": "*",
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-gmp": "*",
|
||||
"ext-sqlite3": "*"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.0.0"
|
||||
}
|
95
localwebsite/config.php
Normal file
95
localwebsite/config.php
Normal file
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'group' => 'www-data',
|
||||
'files_mode' => 0664,
|
||||
'dirs_mode' => 0775,
|
||||
'is_dev' => true,
|
||||
'static_public_path' => '/assets',
|
||||
|
||||
'openwrt_ip' => '192.168.1.1',
|
||||
|
||||
'inverterd_host' => '192.168.1.2',
|
||||
'inverterd_port' => 8305,
|
||||
|
||||
'pump_host' => '192.168.1.2',
|
||||
'pump_port' => 8307,
|
||||
|
||||
'temphumd_servers' => [
|
||||
// fill here, example:
|
||||
'hall' => ['192.168.1.3', 8306, 'Big Hall'/*, optional: config::TEMPHUMD_NO_HUM */],
|
||||
],
|
||||
|
||||
// modem names (array keys) must match ipset names and
|
||||
// routing table names on the openwrt router
|
||||
//
|
||||
// the order of the keys in the array must be the same
|
||||
// as the order in which fwmark iptables rules are applied
|
||||
'modems' => [
|
||||
'modem-example' => [
|
||||
'ip' => '1.2.3.4',
|
||||
'label' => 'Modem Name',
|
||||
'short_label' => 'Mname',
|
||||
'legacy_token_auth' => false,
|
||||
],
|
||||
],
|
||||
|
||||
// 'routing_smallhome_ip' => 'fill_me',
|
||||
// 'routing_default' => 'fill_me',
|
||||
|
||||
'debug_backtrace' => true,
|
||||
'debug_file' => '.debug.log',
|
||||
|
||||
'twig_cache' => true,
|
||||
'templates' => [
|
||||
'web' => [
|
||||
'root' => 'templates-web',
|
||||
'cache' => 'cache/templates-web',
|
||||
],
|
||||
],
|
||||
|
||||
'static' => [
|
||||
'app.css' => 12,
|
||||
'app.js' => 7,
|
||||
'polyfills.js' => 1,
|
||||
'modem.js' => 2,
|
||||
'inverter.js' => 2,
|
||||
'h265webjs-dist/h265webjs-v20221106.js' => 3,
|
||||
'h265webjs-dist/h265webjs-v20221106-reminified.js' => 1,
|
||||
'h265webjs-dist/missile.js' => 1,
|
||||
],
|
||||
|
||||
'cam_hls_access_key' => '',
|
||||
'cam_hls_proto' => 'http', // bool|callable
|
||||
'cam_hls_host' => '192.168.1.1', // bool|callable
|
||||
'cam_list' => [
|
||||
'low' => [
|
||||
// fill me with names
|
||||
],
|
||||
'high' => [
|
||||
// fill me with names
|
||||
],
|
||||
'labels' => [
|
||||
// assoc array
|
||||
],
|
||||
],
|
||||
|
||||
'vk_sms_checker' => [
|
||||
'telegram_token' => '',
|
||||
'telegram_chat_id' => '',
|
||||
'modem_name' => '', // reference to the 'modems' array
|
||||
],
|
||||
|
||||
'database_path' => getenv('HOME').'/.config/homekit.localwebsite.sqlite3',
|
||||
|
||||
'auth_cookie_host' => '',
|
||||
'auth_need' => false, // bool|callable
|
||||
'auth_pw_salt' => '',
|
||||
|
||||
'grafana_sensors_url' => '',
|
||||
'grafana_inverter_url' => '',
|
||||
|
||||
'ipcam_server_api_addr' => '',
|
||||
|
||||
'dhcp_hostname_overrides' => [],
|
||||
];
|
44
localwebsite/cron/check-vk-sms.php
Executable file
44
localwebsite/cron/check-vk-sms.php
Executable file
@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
// this scripts pulls recent inbox from e3372 modem,
|
||||
// looks for new messages from vk and re-sends them
|
||||
// to the telegram group
|
||||
|
||||
require_once __DIR__.'/../init.php';
|
||||
global $config;
|
||||
|
||||
$cfg = $config['modems'][$config['vk_sms_checker']['modem_name']];
|
||||
$e3372 = new E3372($cfg['ip'], $cfg['legacy_token_auth']);
|
||||
|
||||
$db = getDB();
|
||||
|
||||
$last_processed = $db->querySingle("SELECT last_message_time FROM vk_processed");
|
||||
$new_last_processed = 0;
|
||||
|
||||
$messages = $e3372->getSMSList();
|
||||
$messages = array_reverse($messages);
|
||||
|
||||
$results = [];
|
||||
if (!empty($messages)) {
|
||||
foreach ($messages as $m) {
|
||||
if ($m['timestamp'] <= $last_processed)
|
||||
continue;
|
||||
|
||||
$new_last_processed = $m['timestamp'];
|
||||
if (preg_match('/^vk/i', $m['phone']) || preg_match('/vk/i', $m['content']))
|
||||
$results[] = $m;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($results)) {
|
||||
$t = new TelegramBotClient($config['vk_sms_checker']['telegram_token']);
|
||||
foreach ($results as $m) {
|
||||
$text = '<b>'.htmlescape($m['phone']).'</b> ('.$m['date'].')';
|
||||
$text .= "\n".htmlescape($m['content']);
|
||||
$t->sendMessage($config['vk_sms_checker']['telegram_chat_id'], $text);
|
||||
}
|
||||
}
|
||||
|
||||
if ($new_last_processed != 0)
|
||||
$db->exec("UPDATE vk_processed SET last_message_time=?", $new_last_processed);
|
131
localwebsite/engine/database.php
Normal file
131
localwebsite/engine/database.php
Normal file
@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
class database {
|
||||
|
||||
const SCHEMA_VERSION = 2;
|
||||
|
||||
protected SQLite3 $link;
|
||||
|
||||
public function __construct(string $db_path) {
|
||||
$will_create = !file_exists($db_path);
|
||||
$this->link = new SQLite3($db_path);
|
||||
if ($will_create)
|
||||
setperm($db_path);
|
||||
$this->link->enableExceptions(true);
|
||||
$this->upgradeSchema();
|
||||
}
|
||||
|
||||
protected function upgradeSchema() {
|
||||
$cur = $this->getSchemaVersion();
|
||||
if ($cur == self::SCHEMA_VERSION)
|
||||
return;
|
||||
|
||||
if ($cur < 1) {
|
||||
$this->link->exec("CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT,
|
||||
password TEXT
|
||||
)");
|
||||
}
|
||||
if ($cur < 2) {
|
||||
$this->link->exec("CREATE TABLE vk_processed (
|
||||
last_message_time INTEGER
|
||||
)");
|
||||
$this->link->exec("INSERT INTO vk_processed (last_message_time) VALUES (0)");
|
||||
}
|
||||
$this->syncSchemaVersion();
|
||||
}
|
||||
|
||||
protected function getSchemaVersion() {
|
||||
return $this->link->query("PRAGMA user_version")->fetchArray()[0];
|
||||
}
|
||||
|
||||
protected function syncSchemaVersion() {
|
||||
$this->link->exec("PRAGMA user_version=".self::SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
protected function prepareQuery(string $sql): string {
|
||||
if (func_num_args() > 1) {
|
||||
$mark_count = substr_count($sql, '?');
|
||||
$positions = array();
|
||||
$last_pos = -1;
|
||||
for ($i = 0; $i < $mark_count; $i++) {
|
||||
$last_pos = strpos($sql, '?', $last_pos + 1);
|
||||
$positions[] = $last_pos;
|
||||
}
|
||||
for ($i = $mark_count - 1; $i >= 0; $i--) {
|
||||
$arg_val = func_get_arg($i + 1);
|
||||
if (is_null($arg_val)) {
|
||||
$v = 'NULL';
|
||||
} else {
|
||||
$v = '\''.$this->link->escapeString($arg_val) . '\'';
|
||||
}
|
||||
$sql = substr_replace($sql, $v, $positions[$i], 1);
|
||||
}
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
public function query(string $sql, ...$params): SQLite3Result {
|
||||
return $this->link->query($this->prepareQuery($sql, ...$params));
|
||||
}
|
||||
|
||||
public function exec(string $sql, ...$params) {
|
||||
return $this->link->exec($this->prepareQuery($sql, ...$params));
|
||||
}
|
||||
|
||||
public function querySingle(string $sql, ...$params) {
|
||||
return $this->link->querySingle($this->prepareQuery($sql, ...$params));
|
||||
}
|
||||
|
||||
public function querySingleRow(string $sql, ...$params) {
|
||||
return $this->link->querySingle($this->prepareQuery($sql, ...$params), true);
|
||||
}
|
||||
|
||||
protected function performInsert(string $command, string $table, array $fields): SQLite3Result {
|
||||
$names = [];
|
||||
$values = [];
|
||||
$count = 0;
|
||||
foreach ($fields as $k => $v) {
|
||||
$names[] = $k;
|
||||
$values[] = $v;
|
||||
$count++;
|
||||
}
|
||||
|
||||
$sql = "{$command} INTO `{$table}` (`" . implode('`, `', $names) . "`) VALUES (" . implode(', ', array_fill(0, $count, '?')) . ")";
|
||||
array_unshift($values, $sql);
|
||||
|
||||
return call_user_func_array([$this, 'query'], $values);
|
||||
}
|
||||
|
||||
public function insert(string $table, array $fields): SQLite3Result {
|
||||
return $this->performInsert('INSERT', $table, $fields);
|
||||
}
|
||||
|
||||
public function replace(string $table, array $fields): SQLite3Result {
|
||||
return $this->performInsert('REPLACE', $table, $fields);
|
||||
}
|
||||
|
||||
public function insertId(): int {
|
||||
return $this->link->lastInsertRowID();
|
||||
}
|
||||
|
||||
public function update($table, $rows, ...$cond): SQLite3Result {
|
||||
$fields = [];
|
||||
$args = [];
|
||||
foreach ($rows as $row_name => $row_value) {
|
||||
$fields[] = "`{$row_name}`=?";
|
||||
$args[] = $row_value;
|
||||
}
|
||||
$sql = "UPDATE `$table` SET " . implode(', ', $fields);
|
||||
if (!empty($cond)) {
|
||||
$sql .= " WHERE " . $cond[0];
|
||||
if (count($cond) > 1)
|
||||
$args = array_merge($args, array_slice($cond, 1));
|
||||
}
|
||||
return $this->query($sql, ...$args);
|
||||
}
|
||||
|
||||
|
||||
}
|
355
localwebsite/engine/debug.php
Normal file
355
localwebsite/engine/debug.php
Normal file
@ -0,0 +1,355 @@
|
||||
<?php
|
||||
|
||||
// require_once 'engine/mysql.php';
|
||||
|
||||
class debug {
|
||||
|
||||
protected static $Types = [
|
||||
1 => 'E_ERROR',
|
||||
2 => 'E_WARNING',
|
||||
4 => 'E_PARSE',
|
||||
8 => 'E_NOTICE',
|
||||
16 => 'E_CORE_ERROR',
|
||||
32 => 'E_CORE_WARNING',
|
||||
64 => 'E_COMPILE_ERROR',
|
||||
128 => 'E_COMPILE_WARNING',
|
||||
256 => 'E_USER_ERROR',
|
||||
512 => 'E_USER_WARNING',
|
||||
1024 => 'E_USER_NOTICE',
|
||||
2048 => 'E_STRICT',
|
||||
4096 => 'E_RECOVERABLE_ERROR',
|
||||
8192 => 'E_DEPRECATED',
|
||||
16384 => 'E_USER_DEPRECATED',
|
||||
32767 => 'E_ALL'
|
||||
];
|
||||
|
||||
const STORE_NONE = -1;
|
||||
const STORE_MYSQL = 0;
|
||||
const STORE_FILE = 1;
|
||||
const STORE_BOTH = 2;
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
protected $enabled = false;
|
||||
protected $errCounter = 0;
|
||||
protected $logCounter = 0;
|
||||
protected $messagesStoreType = self::STORE_NONE;
|
||||
protected $errorsStoreType = self::STORE_NONE;
|
||||
protected $filter;
|
||||
protected $reportRecursionLevel = 0;
|
||||
protected $overridenDebugFile = null;
|
||||
protected $silent = false;
|
||||
protected $prefix;
|
||||
|
||||
private function __construct($filter) {
|
||||
$this->filter = $filter;
|
||||
}
|
||||
|
||||
public static function getInstance($filter = null) {
|
||||
if (is_null(self::$instance)) {
|
||||
self::$instance = new self($filter);
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function enable() {
|
||||
$self = $this;
|
||||
|
||||
set_error_handler(function($no, $str, $file, $line) use ($self) {
|
||||
if ($self->silent || !$self->enabled) {
|
||||
return;
|
||||
}
|
||||
if ((is_callable($this->filter) && !($this->filter)($no, $file, $line, $str)) || !$self->canReport()) {
|
||||
return;
|
||||
}
|
||||
$self->report(true, $str, $no, $file, $line);
|
||||
});
|
||||
|
||||
append_shutdown_function(function() use ($self) {
|
||||
if (!$self->enabled || !($error = error_get_last())) {
|
||||
return;
|
||||
}
|
||||
if (is_callable($this->filter)
|
||||
&& !($this->filter)($error['type'], $error['file'], $error['line'], $error['message'])) {
|
||||
return;
|
||||
}
|
||||
if (!$self->canReport()) {
|
||||
return;
|
||||
}
|
||||
$self->report(true, $error['message'], $error['type'], $error['file'], $error['line']);
|
||||
});
|
||||
|
||||
$this->enabled = true;
|
||||
}
|
||||
|
||||
public function disable() {
|
||||
restore_error_handler();
|
||||
$this->enabled = false;
|
||||
}
|
||||
|
||||
public function report($is_error, $text, $errno = 0, $errfile = '', $errline = '') {
|
||||
global $config;
|
||||
|
||||
$this->reportRecursionLevel++;
|
||||
|
||||
$logstarted = $this->errCounter > 0 || $this->logCounter > 0;
|
||||
$num = $is_error ? $this->errCounter++ : $this->logCounter++;
|
||||
$custom = $is_error && !$errno;
|
||||
$ts = time();
|
||||
$exectime = exectime();
|
||||
$bt = backtrace(2);
|
||||
|
||||
$store_file = (!$is_error && $this->checkMessagesStoreType(self::STORE_FILE))
|
||||
|| ($is_error && $this->checkErrorsStoreType(self::STORE_FILE));
|
||||
|
||||
$store_mysql = (!$is_error && $this->checkMessagesStoreType(self::STORE_MYSQL))
|
||||
|| ($is_error && $this->checkErrorsStoreType(self::STORE_MYSQL));
|
||||
|
||||
if ($this->prefix)
|
||||
$text = $this->prefix.$text;
|
||||
|
||||
// if ($store_mysql) {
|
||||
// $db = getMySQL('local_logs', true);
|
||||
// $data = [
|
||||
// 'ts' => $ts,
|
||||
// 'num' => $num,
|
||||
// 'time' => $exectime,
|
||||
// 'custom' => intval($custom),
|
||||
// 'errno' => $errno,
|
||||
// 'file' => $errfile,
|
||||
// 'line' => $errline,
|
||||
// 'text' => $text,
|
||||
// 'stacktrace' => $bt,
|
||||
// 'is_cli' => PHP_SAPI == 'cli' ? 1 : 0,
|
||||
// ];
|
||||
// if (PHP_SAPI == 'cli') {
|
||||
// $data += [
|
||||
// 'ip' => '',
|
||||
// 'ua' => '',
|
||||
// 'url' => '',
|
||||
// ];
|
||||
// } else {
|
||||
// $data += [
|
||||
// 'ip' => ip2ulong($_SERVER['REMOTE_ADDR']),
|
||||
// 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
// 'url' => $_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI']
|
||||
// ];
|
||||
// }
|
||||
// $db->insert('backend_errors', $data);
|
||||
// }
|
||||
|
||||
if ($store_file) {
|
||||
$title = PHP_SAPI == 'cli' ? 'cli' : $_SERVER['REQUEST_URI'];
|
||||
$date = date('d/m/y H:i:s', $ts);
|
||||
$exectime = (string)$exectime;
|
||||
if (strlen($exectime) < 6)
|
||||
$exectime .= str_repeat('0', 6 - strlen($exectime));
|
||||
|
||||
$buf = "";
|
||||
if (!$logstarted) {
|
||||
$buf .= "\n<e fg=white bg=magenta style=fgbright,bold> {$title} </e><e fg=white bg=blue style=fgbright> {$date} </e>\n";
|
||||
}
|
||||
$buf .= "<e fg=".($is_error ? 'red' : 'white').">".($is_error ? 'E' : 'I')."=<e style=bold>${num}</e> <e fg=cyan>{$exectime}</e> ";
|
||||
if ($is_error && !$custom) {
|
||||
$buf .= "<e fg=green>{$errfile}<e fg=white>:<e fg=green style=fgbright>{$errline}</e> (".self::errname($errno).") ";
|
||||
}
|
||||
$buf = stransi($buf);
|
||||
|
||||
$buf .= $text;
|
||||
$buf .= "\n";
|
||||
if ($is_error && $config['debug_backtrace']) {
|
||||
$buf .= $bt."\n";
|
||||
}
|
||||
|
||||
$debug_file = $this->getDebugFile();
|
||||
|
||||
$logdir = dirname($debug_file);
|
||||
if (!file_exists($logdir)) {
|
||||
mkdir($logdir);
|
||||
setperm($logdir);
|
||||
}
|
||||
|
||||
$f = fopen($debug_file, 'a');
|
||||
if ($f) {
|
||||
fwrite($f, $buf);
|
||||
fclose($f);
|
||||
}
|
||||
}
|
||||
|
||||
$this->reportRecursionLevel--;
|
||||
}
|
||||
|
||||
public function canReport() {
|
||||
return $this->reportRecursionLevel < 2;
|
||||
}
|
||||
|
||||
public function setErrorsStoreType($errorsStoreType) {
|
||||
$this->errorsStoreType = $errorsStoreType;
|
||||
}
|
||||
|
||||
public function setMessagesStoreType($messagesStoreType) {
|
||||
$this->messagesStoreType = $messagesStoreType;
|
||||
}
|
||||
|
||||
public function checkMessagesStoreType($store_type) {
|
||||
return $this->messagesStoreType == $store_type || $this->messagesStoreType == self::STORE_BOTH;
|
||||
}
|
||||
|
||||
public function checkErrorsStoreType($store_type) {
|
||||
return $this->errorsStoreType == $store_type || $this->errorsStoreType == self::STORE_BOTH;
|
||||
}
|
||||
|
||||
public function overrideDebugFile($file) {
|
||||
$this->overridenDebugFile = $file;
|
||||
}
|
||||
|
||||
protected function getDebugFile() {
|
||||
global $config;
|
||||
return is_null($this->overridenDebugFile) ? ROOT.'/'.$config['debug_file'] : $this->overridenDebugFile;
|
||||
}
|
||||
|
||||
public function setSilence($silent) {
|
||||
$this->silent = $silent;
|
||||
}
|
||||
|
||||
public function setPrefix($prefix) {
|
||||
$this->prefix = $prefix;
|
||||
}
|
||||
|
||||
public static function errname($errno) {
|
||||
static $errors = null;
|
||||
if (is_null($errors)) {
|
||||
$errors = array_flip(array_slice(get_defined_constants(true)['Core'], 0, 15, true));
|
||||
}
|
||||
return $errors[$errno];
|
||||
}
|
||||
|
||||
public static function getTypes() {
|
||||
return self::$Types;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class debug_measure {
|
||||
|
||||
private $name;
|
||||
private $time;
|
||||
private $started = false;
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @return $this
|
||||
*/
|
||||
public function start($name = null) {
|
||||
if (is_null($name)) {
|
||||
$name = strgen(3);
|
||||
}
|
||||
$this->name = $name;
|
||||
$this->time = microtime(true);
|
||||
$this->started = true;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return float|string|null
|
||||
*/
|
||||
public function finish() {
|
||||
if (!$this->started) {
|
||||
debugLog("debug_measure::finish(): not started, name=".$this->name);
|
||||
return null;
|
||||
}
|
||||
|
||||
$time = (microtime(true) - $this->time);
|
||||
debugLog("MEASURE".($this->name != '' ? ' '.$this->name : '').": ".$time);
|
||||
|
||||
$this->started = false;
|
||||
return $time;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $var
|
||||
* @return string
|
||||
*/
|
||||
function str_print_r($var) {
|
||||
ob_start();
|
||||
print_r($var);
|
||||
return trim(ob_get_clean());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $var
|
||||
* @return string
|
||||
*/
|
||||
function str_var_dump($var) {
|
||||
ob_start();
|
||||
var_dump($var);
|
||||
return trim(ob_get_clean());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $args
|
||||
* @param bool $all_dump
|
||||
* @return string
|
||||
*/
|
||||
function str_vars($args, $all_dump = false) {
|
||||
return implode(' ', array_map(function($a) use ($all_dump) {
|
||||
if ($all_dump) {
|
||||
return str_var_dump($a);
|
||||
}
|
||||
$type = gettype($a);
|
||||
if ($type == 'string' || $type == 'integer' || $type == 'double') {
|
||||
return $a;
|
||||
} else if ($type == 'array' || $type == 'object') {
|
||||
return str_print_r($a);
|
||||
} else {
|
||||
return str_var_dump($a);
|
||||
}
|
||||
}, $args));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $shift
|
||||
* @return string
|
||||
*/
|
||||
function backtrace($shift = 0) {
|
||||
$bt = debug_backtrace();
|
||||
$lines = [];
|
||||
foreach ($bt as $i => $t) {
|
||||
if ($i < $shift) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($t['file'])) {
|
||||
$lines[] = 'from ?';
|
||||
} else {
|
||||
$lines[] = 'from '.$t['file'].':'.$t['line'];
|
||||
}
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed ...$args
|
||||
*/
|
||||
function debugLog(...$args) {
|
||||
global $config;
|
||||
if (!$config['is_dev'])
|
||||
return;
|
||||
|
||||
debug::getInstance()->report(false, str_vars($args));
|
||||
}
|
||||
|
||||
function debugLogOnProd(...$args) {
|
||||
debug::getInstance()->report(false, str_vars($args));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed ...$args
|
||||
*/
|
||||
function debugError(...$args) {
|
||||
$debug = debug::getInstance();
|
||||
if ($debug->canReport()) {
|
||||
$debug->report(true, str_vars($args));
|
||||
}
|
||||
}
|
243
localwebsite/engine/model.php
Normal file
243
localwebsite/engine/model.php
Normal file
@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
abstract class model {
|
||||
|
||||
const DB_TABLE = null;
|
||||
const DB_KEY = 'id';
|
||||
|
||||
const STRING = 0;
|
||||
const INTEGER = 1;
|
||||
const FLOAT = 2;
|
||||
const ARRAY = 3;
|
||||
const BOOLEAN = 4;
|
||||
const JSON = 5;
|
||||
const SERIALIZED = 6;
|
||||
|
||||
protected static array $SpecCache = [];
|
||||
|
||||
public static function create_instance(...$args) {
|
||||
$cl = get_called_class();
|
||||
return new $cl(...$args);
|
||||
}
|
||||
|
||||
public function __construct(array $raw) {
|
||||
if (!isset(self::$SpecCache[static::class])) {
|
||||
list($fields, $model_name_map, $db_name_map) = static::get_spec();
|
||||
self::$SpecCache[static::class] = [
|
||||
'fields' => $fields,
|
||||
'model_name_map' => $model_name_map,
|
||||
'db_name_map' => $db_name_map
|
||||
];
|
||||
}
|
||||
|
||||
foreach (self::$SpecCache[static::class]['fields'] as $field)
|
||||
$this->{$field['model_name']} = self::cast_to_type($field['type'], $raw[$field['db_name']]);
|
||||
|
||||
if (is_null(static::DB_TABLE))
|
||||
trigger_error('class '.get_class($this).' doesn\'t have DB_TABLE defined');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $fields
|
||||
*
|
||||
* TODO: support adding or subtracting (SET value=value+1)
|
||||
*/
|
||||
public function edit($fields) {
|
||||
$db = getDB();
|
||||
|
||||
$model_upd = [];
|
||||
$db_upd = [];
|
||||
|
||||
foreach ($fields as $name => $value) {
|
||||
$index = self::$SpecCache[static::class]['db_name_map'][$name] ?? null;
|
||||
if (is_null($index)) {
|
||||
debugError(__METHOD__.': field `'.$name.'` not found in '.static::class);
|
||||
continue;
|
||||
}
|
||||
|
||||
$field = self::$SpecCache[static::class]['fields'][$index];
|
||||
switch ($field['type']) {
|
||||
case self::ARRAY:
|
||||
if (is_array($value)) {
|
||||
$db_upd[$name] = implode(',', $value);
|
||||
$model_upd[$field['model_name']] = $value;
|
||||
} else {
|
||||
debugError(__METHOD__.': field `'.$name.'` is expected to be array. skipping.');
|
||||
}
|
||||
break;
|
||||
|
||||
case self::INTEGER:
|
||||
$value = (int)$value;
|
||||
$db_upd[$name] = $value;
|
||||
$model_upd[$field['model_name']] = $value;
|
||||
break;
|
||||
|
||||
case self::FLOAT:
|
||||
$value = (float)$value;
|
||||
$db_upd[$name] = $value;
|
||||
$model_upd[$field['model_name']] = $value;
|
||||
break;
|
||||
|
||||
case self::BOOLEAN:
|
||||
$db_upd[$name] = $value ? 1 : 0;
|
||||
$model_upd[$field['model_name']] = $value;
|
||||
break;
|
||||
|
||||
case self::JSON:
|
||||
$db_upd[$name] = jsonEncode($value);
|
||||
$model_upd[$field['model_name']] = $value;
|
||||
break;
|
||||
|
||||
case self::SERIALIZED:
|
||||
$db_upd[$name] = serialize($value);
|
||||
$model_upd[$field['model_name']] = $value;
|
||||
break;
|
||||
|
||||
default:
|
||||
$value = (string)$value;
|
||||
$db_upd[$name] = $value;
|
||||
$model_upd[$field['model_name']] = $value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($db_upd) && !$db->update(static::DB_TABLE, $db_upd, static::DB_KEY."=?", $this->get_id())) {
|
||||
debugError(__METHOD__.': failed to update database');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!empty($model_upd)) {
|
||||
foreach ($model_upd as $name => $value)
|
||||
$this->{$name} = $value;
|
||||
}
|
||||
}
|
||||
|
||||
public function get_id() {
|
||||
return $this->{to_camel_case(static::DB_KEY)};
|
||||
}
|
||||
|
||||
public function as_array(array $fields = [], array $custom_getters = []): array {
|
||||
if (empty($fields))
|
||||
$fields = array_keys(static::$SpecCache[static::class]['db_name_map']);
|
||||
|
||||
$array = [];
|
||||
foreach ($fields as $field) {
|
||||
if (isset($custom_getters[$field]) && is_callable($custom_getters[$field])) {
|
||||
$array[$field] = $custom_getters[$field]();
|
||||
} else {
|
||||
$array[$field] = $this->{to_camel_case($field)};
|
||||
}
|
||||
}
|
||||
|
||||
return $array;
|
||||
}
|
||||
|
||||
protected static function cast_to_type(int $type, $value) {
|
||||
switch ($type) {
|
||||
case self::BOOLEAN:
|
||||
return (bool)$value;
|
||||
|
||||
case self::INTEGER:
|
||||
return (int)$value;
|
||||
|
||||
case self::FLOAT:
|
||||
return (float)$value;
|
||||
|
||||
case self::ARRAY:
|
||||
return array_filter(explode(',', $value));
|
||||
|
||||
case self::JSON:
|
||||
$val = jsonDecode($value);
|
||||
if (!$val)
|
||||
$val = null;
|
||||
return $val;
|
||||
|
||||
case self::SERIALIZED:
|
||||
$val = unserialize($value);
|
||||
if ($val === false)
|
||||
$val = null;
|
||||
return $val;
|
||||
|
||||
default:
|
||||
return (string)$value;
|
||||
}
|
||||
}
|
||||
|
||||
protected static function get_spec(): array {
|
||||
$rc = new ReflectionClass(static::class);
|
||||
$props = $rc->getProperties(ReflectionProperty::IS_PUBLIC);
|
||||
|
||||
$list = [];
|
||||
$index = 0;
|
||||
|
||||
$model_name_map = [];
|
||||
$db_name_map = [];
|
||||
|
||||
foreach ($props as $prop) {
|
||||
if ($prop->isStatic())
|
||||
continue;
|
||||
|
||||
$name = $prop->getName();
|
||||
if (startsWith($name, '_'))
|
||||
continue;
|
||||
|
||||
$type = $prop->getType();
|
||||
$phpdoc = $prop->getDocComment();
|
||||
|
||||
$mytype = null;
|
||||
if (!$prop->hasType() && !$phpdoc)
|
||||
$mytype = self::STRING;
|
||||
else {
|
||||
$typename = $type->getName();
|
||||
switch ($typename) {
|
||||
case 'string':
|
||||
$mytype = self::STRING;
|
||||
break;
|
||||
case 'int':
|
||||
$mytype = self::INTEGER;
|
||||
break;
|
||||
case 'float':
|
||||
$mytype = self::FLOAT;
|
||||
break;
|
||||
case 'array':
|
||||
$mytype = self::ARRAY;
|
||||
break;
|
||||
case 'bool':
|
||||
$mytype = self::BOOLEAN;
|
||||
break;
|
||||
}
|
||||
|
||||
if ($phpdoc != '') {
|
||||
$pos = strpos($phpdoc, '@');
|
||||
if ($pos === false)
|
||||
continue;
|
||||
|
||||
if (substr($phpdoc, $pos+1, 4) == 'json')
|
||||
$mytype = self::JSON;
|
||||
else if (substr($phpdoc, $pos+1, 5) == 'array')
|
||||
$mytype = self::ARRAY;
|
||||
else if (substr($phpdoc, $pos+1, 10) == 'serialized')
|
||||
$mytype = self::SERIALIZED;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_null($mytype))
|
||||
debugError(__METHOD__.": ".$name." is still null in ".static::class);
|
||||
|
||||
$dbname = from_camel_case($name);
|
||||
$list[] = [
|
||||
'type' => $mytype,
|
||||
'model_name' => $name,
|
||||
'db_name' => $dbname
|
||||
];
|
||||
|
||||
$model_name_map[$name] = $index;
|
||||
$db_name_map[$dbname] = $index;
|
||||
|
||||
$index++;
|
||||
}
|
||||
|
||||
return [$list, $model_name_map, $db_name_map];
|
||||
}
|
||||
|
||||
}
|
142
localwebsite/engine/request_handler.php
Normal file
142
localwebsite/engine/request_handler.php
Normal file
@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
abstract class request_handler {
|
||||
|
||||
const GET = 'GET';
|
||||
const POST = 'POST';
|
||||
|
||||
private static array $AllowedInputTypes = ['i', 'f', 'b', 'e' /* enum */];
|
||||
|
||||
public function dispatch(string $act) {
|
||||
$method = $_SERVER['REQUEST_METHOD'] == 'POST' ? 'POST' : 'GET';
|
||||
return $this->call_act($method, $act);
|
||||
}
|
||||
|
||||
protected function before_dispatch(string $method, string $act)/*: ?array*/ {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function call_act(string $method, string $act, array $input = []) {
|
||||
global $RouterInput;
|
||||
|
||||
$notfound = !method_exists($this, $method.'_'.$act) || !((new ReflectionMethod($this, $method.'_'.$act))->isPublic());
|
||||
if ($notfound)
|
||||
$this->method_not_found($method, $act);
|
||||
|
||||
if (!empty($input)) {
|
||||
foreach ($input as $k => $v)
|
||||
$RouterInput[$k] = $v;
|
||||
}
|
||||
|
||||
$args = $this->before_dispatch($method, $act);
|
||||
return call_user_func_array([$this, $method.'_'.$act], is_array($args) ? [$args] : []);
|
||||
}
|
||||
|
||||
abstract protected function method_not_found(string $method, string $act);
|
||||
|
||||
protected function input(string $input, bool $as_assoc = false): array {
|
||||
$input = preg_split('/,\s+?/', $input, null, PREG_SPLIT_NO_EMPTY);
|
||||
|
||||
$ret = [];
|
||||
foreach ($input as $var) {
|
||||
list($type, $name, $enum_values, $enum_default) = self::parse_input_var($var);
|
||||
|
||||
$value = param($name);
|
||||
|
||||
switch ($type) {
|
||||
case 'i':
|
||||
if (is_null($value) && !is_null($enum_default)) {
|
||||
$value = (int)$enum_default;
|
||||
} else {
|
||||
$value = (int)$value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'f':
|
||||
if (is_null($value) && !is_null($enum_default)) {
|
||||
$value = (float)$enum_default;
|
||||
} else {
|
||||
$value = (float)$value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'b':
|
||||
if (is_null($value) && !is_null($enum_default)) {
|
||||
$value = (bool)$enum_default;
|
||||
} else {
|
||||
$value = (bool)$value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'e':
|
||||
if (!in_array($value, $enum_values)) {
|
||||
$value = !is_null($enum_default) ? $enum_default : '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (!$as_assoc) {
|
||||
$ret[] = $value;
|
||||
} else {
|
||||
$ret[$name] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $ret;
|
||||
}
|
||||
protected static function parse_input_var(string $var): array {
|
||||
$type = null;
|
||||
$name = null;
|
||||
$enum_values = null;
|
||||
$enum_default = null;
|
||||
|
||||
$pos = strpos($var, ':');
|
||||
if ($pos !== false) {
|
||||
$type = substr($var, 0, $pos);
|
||||
$rest = substr($var, $pos+1);
|
||||
|
||||
if (!in_array($type, self::$AllowedInputTypes)) {
|
||||
trigger_error('request_handler::parse_input_var('.$var.'): unknown type '.$type);
|
||||
$type = null;
|
||||
}
|
||||
|
||||
switch ($type) {
|
||||
case 'e':
|
||||
$br_from = strpos($rest, '(');
|
||||
$br_to = strpos($rest, ')');
|
||||
|
||||
if ($br_from === false || $br_to === false) {
|
||||
trigger_error('request_handler::parse_input_var('.$var.'): failed to parse enum values');
|
||||
$type = null;
|
||||
$name = $rest;
|
||||
break;
|
||||
}
|
||||
|
||||
$enum_values = array_map('trim', explode('|', trim(substr($rest, $br_from+1, $br_to-$br_from-1))));
|
||||
$name = trim(substr($rest, 0, $br_from));
|
||||
|
||||
if (!empty($enum_values)) foreach ($enum_values as $key => $val) {
|
||||
if (substr($val, 0, 1) == '=') {
|
||||
$enum_values[$key] = substr($val, 1);
|
||||
$enum_default = $enum_values[$key];
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
if (($eq_pos = strpos($rest, '=')) !== false) {
|
||||
$enum_default = substr($rest, $eq_pos+1);
|
||||
$rest = substr($rest, 0, $eq_pos);
|
||||
}
|
||||
$name = trim($rest);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
$type = 's';
|
||||
$name = $var;
|
||||
}
|
||||
|
||||
return [$type, $name, $enum_values, $enum_default];
|
||||
}
|
||||
|
||||
}
|
199
localwebsite/engine/router.php
Normal file
199
localwebsite/engine/router.php
Normal file
@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
class router {
|
||||
|
||||
protected array $routes = [
|
||||
'children' => [],
|
||||
're_children' => []
|
||||
];
|
||||
|
||||
public function add($template, $value) {
|
||||
if ($template == '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// expand {enum,erat,ions}
|
||||
$templates = [[$template, $value]];
|
||||
if (preg_match_all('/\{([\w\d_\-,]+)\}/', $template, $matches)) {
|
||||
foreach ($matches[1] as $match_index => $variants) {
|
||||
$variants = explode(',', $variants);
|
||||
$variants = array_map('trim', $variants);
|
||||
$variants = array_filter($variants, function($s) { return $s != ''; });
|
||||
|
||||
for ($i = 0; $i < count($templates); ) {
|
||||
list($template, $value) = $templates[$i];
|
||||
$new_templates = [];
|
||||
foreach ($variants as $variant_index => $variant) {
|
||||
$new_templates[] = [
|
||||
str_replace_once($matches[0][$match_index], $variant, $template),
|
||||
str_replace('${'.($match_index+1).'}', $variant, $value)
|
||||
];
|
||||
}
|
||||
array_splice($templates, $i, 1, $new_templates);
|
||||
$i += count($new_templates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// process all generated routes
|
||||
foreach ($templates as $template) {
|
||||
list($template, $value) = $template;
|
||||
|
||||
$start_pos = 0;
|
||||
$parent = &$this->routes;
|
||||
$template_len = strlen($template);
|
||||
|
||||
while ($start_pos < $template_len) {
|
||||
$slash_pos = strpos($template, '/', $start_pos);
|
||||
if ($slash_pos !== false) {
|
||||
$part = substr($template, $start_pos, $slash_pos-$start_pos+1);
|
||||
$start_pos = $slash_pos+1;
|
||||
} else {
|
||||
$part = substr($template, $start_pos);
|
||||
$start_pos = $template_len;
|
||||
}
|
||||
|
||||
$parent = &$this->_addRoute($parent, $part,
|
||||
$start_pos < $template_len ? null : $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function &_addRoute(&$parent, $part, $value = null) {
|
||||
$par_pos = strpos($part, '(');
|
||||
$is_regex = $par_pos !== false && ($par_pos == 0 || $part[$par_pos-1] != '\\');
|
||||
|
||||
$children_key = !$is_regex ? 'children' : 're_children';
|
||||
|
||||
if (isset($parent[$children_key][$part])) {
|
||||
if (is_null($value)) {
|
||||
$parent = &$parent[$children_key][$part];
|
||||
} else {
|
||||
if (!isset($parent[$children_key][$part]['value'])) {
|
||||
$parent[$children_key][$part]['value'] = $value;
|
||||
} else {
|
||||
trigger_error(__METHOD__.': route is already defined');
|
||||
}
|
||||
}
|
||||
return $parent;
|
||||
}
|
||||
|
||||
$child = [
|
||||
'children' => [],
|
||||
're_children' => []
|
||||
];
|
||||
if (!is_null($value)) {
|
||||
$child['value'] = $value;
|
||||
}
|
||||
|
||||
$parent[$children_key][$part] = $child;
|
||||
return $parent[$children_key][$part];
|
||||
}
|
||||
|
||||
public function find($uri) {
|
||||
if ($uri != '/' && $uri[0] == '/') {
|
||||
$uri = substr($uri, 1);
|
||||
}
|
||||
$start_pos = 0;
|
||||
$parent = &$this->routes;
|
||||
$uri_len = strlen($uri);
|
||||
$matches = [];
|
||||
|
||||
while ($start_pos < $uri_len) {
|
||||
$slash_pos = strpos($uri, '/', $start_pos);
|
||||
if ($slash_pos !== false) {
|
||||
$part = substr($uri, $start_pos, $slash_pos-$start_pos+1);
|
||||
$start_pos = $slash_pos+1;
|
||||
} else {
|
||||
$part = substr($uri, $start_pos);
|
||||
$start_pos = $uri_len;
|
||||
}
|
||||
|
||||
$found = false;
|
||||
if (isset($parent['children'][$part])) {
|
||||
$parent = &$parent['children'][$part];
|
||||
$found = true;
|
||||
} else if (!empty($parent['re_children'])) {
|
||||
foreach ($parent['re_children'] as $re => &$child) {
|
||||
$exp = '#^'.$re.'$#';
|
||||
$re_result = preg_match($exp, $part, $match);
|
||||
if ($re_result === false) {
|
||||
debugError(__METHOD__.": regex $exp failed");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($re_result) {
|
||||
if (count($match) > 1) {
|
||||
$matches = array_merge($matches, array_slice($match, 1));
|
||||
}
|
||||
$parent = &$child;
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$found) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($parent['value'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$value = $parent['value'];
|
||||
if (!empty($matches)) {
|
||||
foreach ($matches as $i => $match) {
|
||||
$needle = '$('.($i+1).')';
|
||||
$pos = strpos($value, $needle);
|
||||
if ($pos !== false) {
|
||||
$value = substr_replace($value, $match, $pos, strlen($needle));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function load($routes) {
|
||||
$this->routes = $routes;
|
||||
}
|
||||
|
||||
public function dump() {
|
||||
return $this->routes;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function routerFind(router $router) {
|
||||
$document_uri = $_SERVER['REQUEST_URI'];
|
||||
if (($pos = strpos($document_uri, '?')) !== false)
|
||||
$document_uri = substr($document_uri, 0, $pos);
|
||||
$document_uri = urldecode($document_uri);
|
||||
|
||||
$fixed_document_uri = preg_replace('#/+#', '/', $document_uri);
|
||||
if ($fixed_document_uri != $document_uri && !is_xhr_request()) {
|
||||
redirect($fixed_document_uri);
|
||||
} else {
|
||||
$document_uri = $fixed_document_uri;
|
||||
}
|
||||
|
||||
$route = $router->find($document_uri);
|
||||
if ($route === false)
|
||||
return false;
|
||||
|
||||
$route = preg_split('/ +/', $route);
|
||||
$handler = $route[0];
|
||||
$act = $route[1];
|
||||
$input = [];
|
||||
if (count($route) > 2) {
|
||||
for ($i = 2; $i < count($route); $i++) {
|
||||
$var = $route[$i];
|
||||
list($k, $v) = explode('=', $var);
|
||||
$input[trim($k)] = trim($v);
|
||||
}
|
||||
}
|
||||
|
||||
return [$handler, $act, $input];
|
||||
}
|
520
localwebsite/engine/tpl.php
Normal file
520
localwebsite/engine/tpl.php
Normal file
@ -0,0 +1,520 @@
|
||||
<?php
|
||||
|
||||
abstract class base_tpl {
|
||||
|
||||
public $twig;
|
||||
protected $vars = [];
|
||||
protected $global_vars = [];
|
||||
protected $title = '';
|
||||
protected $title_modifiers = [];
|
||||
protected $keywords = '';
|
||||
protected $description = '';
|
||||
protected $js = [];
|
||||
protected $lang_keys = [];
|
||||
protected $static = [];
|
||||
protected $external_static = [];
|
||||
protected $head = [];
|
||||
protected $globals_applied = false;
|
||||
protected $static_time;
|
||||
|
||||
public function __construct($templates_dir, $cache_dir) {
|
||||
global $config;
|
||||
|
||||
// $cl = get_called_class();
|
||||
|
||||
$this->twig = self::twig_instance($templates_dir, $cache_dir, $config['is_dev']);
|
||||
$this->static_time = time();
|
||||
}
|
||||
|
||||
public static function twig_instance($templates_dir, $cache_dir, $auto_reload) {
|
||||
// must specify a second argument ($rootPath) here
|
||||
// otherwise it will be getcwd() and it's www-prod/htdocs/ for apache and www-prod/ for cli code
|
||||
// this is bad for templates rebuilding
|
||||
$twig_loader = new \Twig\Loader\FilesystemLoader($templates_dir, ROOT);
|
||||
|
||||
$env_options = [];
|
||||
if (!is_null($cache_dir)) {
|
||||
$env_options += [
|
||||
'cache' => $cache_dir,
|
||||
'auto_reload' => $auto_reload
|
||||
];
|
||||
}
|
||||
|
||||
$twig = new \Twig\Environment($twig_loader, $env_options);
|
||||
$twig->addExtension(new Twig_MyExtension);
|
||||
|
||||
return $twig;
|
||||
}
|
||||
|
||||
public function render($template, array $vars = []) {
|
||||
$this->apply_globals();
|
||||
return $this->do_render($template, array_merge($this->vars, $vars));
|
||||
}
|
||||
|
||||
protected function do_render($template, $vars) {
|
||||
global $config;
|
||||
$s = '';
|
||||
try {
|
||||
$s = $this->twig->render($template, $vars);
|
||||
} catch (\Twig\Error\Error $e) {
|
||||
$error = get_class($e).": failed to render";
|
||||
$source_ctx = $e->getSourceContext();
|
||||
if ($source_ctx) {
|
||||
$path = $source_ctx->getPath();
|
||||
if (startsWith($path, ROOT))
|
||||
$path = substr($path, strlen(ROOT)+1);
|
||||
$error .= " ".$source_ctx->getName()." (".$path.") at line ".$e->getTemplateLine();
|
||||
}
|
||||
$error .= ": ";
|
||||
$error .= $e->getMessage();
|
||||
debugError($error);
|
||||
if ($config['is_dev'])
|
||||
$s = $error."\n";
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
|
||||
public function set($arg1, $arg2 = null) {
|
||||
if (is_array($arg1)) {
|
||||
foreach ($arg1 as $key => $value) {
|
||||
$this->vars[$key] = $value;
|
||||
}
|
||||
} elseif ($arg2 !== null) {
|
||||
$this->vars[$arg1] = $arg2;
|
||||
}
|
||||
}
|
||||
|
||||
public function is_set($key): bool {
|
||||
return isset($this->vars[$key]);
|
||||
}
|
||||
|
||||
public function set_global($arg1, $arg2 = null) {
|
||||
if (is_array($arg1)) {
|
||||
foreach ($arg1 as $key => $value) {
|
||||
$this->global_vars[$key] = $value;
|
||||
}
|
||||
} elseif ($arg2 !== null) {
|
||||
$this->global_vars[$arg1] = $arg2;
|
||||
}
|
||||
}
|
||||
|
||||
public function is_global_set($key): bool {
|
||||
return isset($this->global_vars[$key]);
|
||||
}
|
||||
|
||||
public function get_global($key) {
|
||||
return $this->is_global_set($key) ? $this->global_vars[$key] : null;
|
||||
}
|
||||
|
||||
public function apply_globals() {
|
||||
if (!empty($this->global_vars) && !$this->globals_applied) {
|
||||
foreach ($this->global_vars as $key => $value)
|
||||
$this->twig->addGlobal($key, $value);
|
||||
$this->globals_applied = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $title
|
||||
*/
|
||||
public function set_title($title) {
|
||||
$this->title = $title;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function get_title() {
|
||||
$title = $this->title != '' ? $this->title : 'Домашний сайт';
|
||||
if (!empty($this->title_modifiers)) {
|
||||
foreach ($this->title_modifiers as $modifier) {
|
||||
$title = $modifier($title);
|
||||
}
|
||||
}
|
||||
return $title;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable $callable
|
||||
*/
|
||||
public function add_page_title_modifier(callable $callable) {
|
||||
if (!is_callable($callable)) {
|
||||
trigger_error(__METHOD__.': argument is not callable');
|
||||
} else {
|
||||
$this->title_modifiers[] = $callable;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $css_name
|
||||
* @param null $extra
|
||||
*/
|
||||
public function add_static(string $name, $extra = null) {
|
||||
global $config;
|
||||
// $is_css = endsWith($name, '.css');
|
||||
$this->static[] = [$name, $extra];
|
||||
}
|
||||
|
||||
public function add_external_static($type, $url) {
|
||||
$this->external_static[] = ['type' => $type, 'url' => $url];
|
||||
}
|
||||
|
||||
public function add_js($js) {
|
||||
$this->js[] = $js;
|
||||
}
|
||||
|
||||
public function add_lang_keys(array $keys) {
|
||||
$this->lang_keys = array_merge($this->lang_keys, $keys);
|
||||
}
|
||||
|
||||
public function add_head($html) {
|
||||
$this->head[] = $html;
|
||||
}
|
||||
|
||||
public function get_head_html() {
|
||||
global $config;
|
||||
$lines = [];
|
||||
$public_path = $config['static_public_path'];
|
||||
foreach ($this->static as $val) {
|
||||
list($name, $extra) = $val;
|
||||
if (endsWith($name, '.js'))
|
||||
$lines[] = self::js_link($public_path.'/'.$name, $config['static'][$name] ?? 1);
|
||||
else
|
||||
$lines[] = self::css_link($public_path.'/'.$name, $config['static'][$name] ?? 1, $extra);
|
||||
}
|
||||
if (!empty($this->external_static)) {
|
||||
foreach ($this->external_static as $ext) {
|
||||
if ($ext['type'] == 'js')
|
||||
$lines[] = self::js_link($ext['url']);
|
||||
else if ($ext['type'] == 'css')
|
||||
$lines[] = self::css_link($ext['url']);
|
||||
}
|
||||
}
|
||||
if (!empty($this->head)) {
|
||||
$lines = array_merge($lines, $this->head);
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
public static function js_link($name, $version = null): string {
|
||||
if ($version !== null)
|
||||
$name .= '?'.$version;
|
||||
return '<script src="'.$name.'" type="text/javascript"></script>';
|
||||
}
|
||||
|
||||
public static function css_link($name, $version = null, $extra = null) {
|
||||
if ($version !== null)
|
||||
$name .= '?'.$version;
|
||||
$s = '<link';
|
||||
if (is_array($extra)) {
|
||||
if (!empty($extra['id']))
|
||||
$s .= ' id="'.$extra['id'].'"';
|
||||
}
|
||||
$s .= ' rel="stylesheet" type="text/css"';
|
||||
if (is_array($extra) && !empty($extra['media']))
|
||||
$s .= ' media="'.$extra['media'].'"';
|
||||
$s .= ' href="'.$name.'"';
|
||||
$s .= '>';
|
||||
return $s;
|
||||
}
|
||||
|
||||
public function get_lang_keys() {
|
||||
global $lang;
|
||||
$keys = [];
|
||||
if (!empty($this->lang_keys)) {
|
||||
foreach ($this->lang_keys as $key)
|
||||
$keys[$key] = $lang[$key];
|
||||
}
|
||||
return $keys;
|
||||
}
|
||||
|
||||
public function render_not_found() {
|
||||
http_response_code(404);
|
||||
if (!is_xhr_request()) {
|
||||
$this->render_page('404.twig');
|
||||
} else {
|
||||
ajax_error(['code' => 404]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|string $reason
|
||||
*/
|
||||
public function render_forbidden($reason = null) {
|
||||
http_response_code(403);
|
||||
if (!is_xhr_request()) {
|
||||
$this->set(['reason' => $reason]);
|
||||
$this->render_page('403.twig');
|
||||
} else {
|
||||
$data = ['code' => 403];
|
||||
if (!is_null($reason))
|
||||
$data['reason'] = $reason;
|
||||
ajax_error($data);
|
||||
}
|
||||
}
|
||||
|
||||
public function must_revalidate() {
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
}
|
||||
|
||||
abstract public function render_page($template);
|
||||
|
||||
}
|
||||
|
||||
class web_tpl extends base_tpl {
|
||||
|
||||
protected $alternate = false;
|
||||
|
||||
public function __construct() {
|
||||
global $config;
|
||||
$templates = $config['templates']['web'];
|
||||
parent::__construct(
|
||||
ROOT.'/'. $templates['root'],
|
||||
$config['twig_cache']
|
||||
? ROOT.'/'.$templates['cache']
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
public function set_alternate($alt) {
|
||||
$this->alternate = $alt;
|
||||
}
|
||||
|
||||
public function render_page($template) {
|
||||
echo $this->_render_header();
|
||||
echo $this->_render_body($template);
|
||||
echo $this->_render_footer();
|
||||
exit;
|
||||
}
|
||||
|
||||
public function _render_header() {
|
||||
global $config;
|
||||
$this->apply_globals();
|
||||
|
||||
$vars = [
|
||||
'title' => $this->get_title(),
|
||||
'keywords' => $this->keywords,
|
||||
'description' => $this->description,
|
||||
'alternate' => $this->alternate,
|
||||
'static' => $this->get_head_html(),
|
||||
];
|
||||
return $this->do_render('header.twig', $vars);
|
||||
}
|
||||
|
||||
public function _render_body($template) {
|
||||
return $this->do_render($template, $this->vars);
|
||||
}
|
||||
|
||||
public function _render_footer() {
|
||||
$exec_time = microtime(true) - START_TIME;
|
||||
$exec_time = round($exec_time, 4);
|
||||
|
||||
$footer_vars = [
|
||||
'exec_time' => $exec_time,
|
||||
'js' => !empty($this->js) ? implode("\n", $this->js) : '',
|
||||
];
|
||||
return $this->do_render('footer.twig', $footer_vars);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Twig_MyExtension extends \Twig\Extension\AbstractExtension {
|
||||
|
||||
public function getFilters() {
|
||||
global $lang;
|
||||
|
||||
return array(
|
||||
new \Twig\TwigFilter('lang', 'lang'),
|
||||
|
||||
new \Twig\TwigFilter('lang', function($key, array $args = []) use (&$lang) {
|
||||
array_walk($args, function(&$item, $key) {
|
||||
$item = htmlescape($item);
|
||||
});
|
||||
array_unshift($args, $key);
|
||||
return call_user_func_array([$lang, 'get'], $args);
|
||||
}, ['is_variadic' => true]),
|
||||
|
||||
new \Twig\TwigFilter('plural', function($text, array $args = []) use (&$lang) {
|
||||
array_unshift($args, $text);
|
||||
return call_user_func_array([$lang, 'num'], $args);
|
||||
}, ['is_variadic' => true]),
|
||||
|
||||
new \Twig\TwigFilter('format_number', function($number, array $args = []) {
|
||||
array_unshift($args, $number);
|
||||
return call_user_func_array('formatNumber', $args);
|
||||
}, ['is_variadic' => true]),
|
||||
|
||||
new \Twig\TwigFilter('short_number', function($number, array $args = []) {
|
||||
array_unshift($args, $number);
|
||||
return call_user_func_array('shortNumber', $args);
|
||||
}, ['is_variadic']),
|
||||
|
||||
new \Twig\TwigFilter('format_time', function($ts, array $args = []) {
|
||||
array_unshift($args, $ts);
|
||||
return call_user_func_array('formatTime', $args);
|
||||
}, ['is_variadic' => true]),
|
||||
|
||||
new \Twig\TwigFilter('format_duration', function($seconds, array $args = []) {
|
||||
array_unshift($args, $seconds);
|
||||
return call_user_func_array('formatDuration', $args);
|
||||
}, ['is_variadic' => true]),
|
||||
);
|
||||
}
|
||||
|
||||
public function getTokenParsers() {
|
||||
return [new JsTagTokenParser()];
|
||||
}
|
||||
|
||||
public function getName() {
|
||||
return 'lang';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Based on https://stackoverflow.com/questions/26170727/how-to-create-a-twig-custom-tag-that-executes-a-callback
|
||||
class JsTagTokenParser extends \Twig\TokenParser\AbstractTokenParser {
|
||||
|
||||
public function parse(\Twig\Token $token) {
|
||||
$lineno = $token->getLine();
|
||||
$stream = $this->parser->getStream();
|
||||
|
||||
// recovers all inline parameters close to your tag name
|
||||
$params = array_merge([], $this->getInlineParams($token));
|
||||
|
||||
$continue = true;
|
||||
while ($continue) {
|
||||
// create subtree until the decideJsTagFork() callback returns true
|
||||
$body = $this->parser->subparse(array ($this, 'decideJsTagFork'));
|
||||
|
||||
// I like to put a switch here, in case you need to add middle tags, such
|
||||
// as: {% js %}, {% nextjs %}, {% endjs %}.
|
||||
$tag = $stream->next()->getValue();
|
||||
switch ($tag) {
|
||||
case 'endjs':
|
||||
$continue = false;
|
||||
break;
|
||||
default:
|
||||
throw new \Twig\Error\SyntaxError(sprintf('Unexpected end of template. Twig was looking for the following tags "endjs" to close the "mytag" block started at line %d)', $lineno), -1);
|
||||
}
|
||||
|
||||
// you want $body at the beginning of your arguments
|
||||
array_unshift($params, $body);
|
||||
|
||||
// if your endjs can also contains params, you can uncomment this line:
|
||||
// $params = array_merge($params, $this->getInlineParams($token));
|
||||
// and comment this one:
|
||||
$stream->expect(\Twig\Token::BLOCK_END_TYPE);
|
||||
}
|
||||
|
||||
return new JsTagNode(new \Twig\Node\Node($params), $lineno, $this->getTag());
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovers all tag parameters until we find a BLOCK_END_TYPE ( %} )
|
||||
*
|
||||
* @param \Twig\Token $token
|
||||
* @return array
|
||||
*/
|
||||
protected function getInlineParams(\Twig\Token $token) {
|
||||
$stream = $this->parser->getStream();
|
||||
$params = array ();
|
||||
while (!$stream->test(\Twig\Token::BLOCK_END_TYPE)) {
|
||||
$params[] = $this->parser->getExpressionParser()->parseExpression();
|
||||
}
|
||||
$stream->expect(\Twig\Token::BLOCK_END_TYPE);
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback called at each tag name when subparsing, must return
|
||||
* true when the expected end tag is reached.
|
||||
*
|
||||
* @param \Twig\Token $token
|
||||
* @return bool
|
||||
*/
|
||||
public function decideJsTagFork(\Twig\Token $token) {
|
||||
return $token->test(['endjs']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Your tag name: if the parsed tag match the one you put here, your parse()
|
||||
* method will be called.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getTag() {
|
||||
return 'js';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class JsTagNode extends \Twig\Node\Node {
|
||||
|
||||
public function __construct($params, $lineno = 0, $tag = null) {
|
||||
parent::__construct(['params' => $params], [], $lineno, $tag);
|
||||
}
|
||||
|
||||
public function compile(\Twig\Compiler $compiler) {
|
||||
$count = count($this->getNode('params'));
|
||||
|
||||
$compiler->addDebugInfo($this);
|
||||
$compiler
|
||||
->write('global $__tpl;')
|
||||
->raw(PHP_EOL);
|
||||
|
||||
for ($i = 0; ($i < $count); $i++) {
|
||||
// argument is not an expression (such as, a \Twig\Node\Textbody)
|
||||
// we should trick with output buffering to get a valid argument to pass
|
||||
// to the functionToCall() function.
|
||||
if (!($this->getNode('params')->getNode($i) instanceof \Twig\Node\Expression\AbstractExpression)) {
|
||||
$compiler
|
||||
->write('ob_start();')
|
||||
->raw(PHP_EOL);
|
||||
|
||||
$compiler
|
||||
->subcompile($this->getNode('params')->getNode($i));
|
||||
|
||||
$compiler
|
||||
->write('$js = ob_get_clean();')
|
||||
->raw(PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
$compiler
|
||||
->write('$__tpl->add_js($js);')
|
||||
->raw(PHP_EOL)
|
||||
->write('unset($js);')
|
||||
->raw(PHP_EOL);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param $data
|
||||
*/
|
||||
function ajax_ok($data) {
|
||||
ajax_response(['response' => $data]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $error
|
||||
* @param int $code
|
||||
*/
|
||||
function ajax_error($error, $code = 200) {
|
||||
ajax_response(['error' => $error], $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $data
|
||||
* @param int $code
|
||||
*/
|
||||
function ajax_response($data, $code = 200) {
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
http_response_code($code);
|
||||
echo jsonEncode($data);
|
||||
exit;
|
||||
}
|
300
localwebsite/functions.php
Normal file
300
localwebsite/functions.php
Normal file
@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
function param($key) {
|
||||
global $RouterInput;
|
||||
|
||||
$val = null;
|
||||
|
||||
if (isset($RouterInput[$key])) {
|
||||
$val = $RouterInput[$key];
|
||||
} else if (isset($_POST[$key])) {
|
||||
$val = $_POST[$key];
|
||||
} else if (isset($_GET[$key])) {
|
||||
$val = $_GET[$key];
|
||||
}
|
||||
|
||||
if (is_array($val)) {
|
||||
$val = implode($val);
|
||||
}
|
||||
return $val;
|
||||
}
|
||||
|
||||
function str_replace_once(string $needle, string $replace, string $haystack): string {
|
||||
$pos = strpos($haystack, $needle);
|
||||
if ($pos !== false) {
|
||||
$haystack = substr_replace($haystack, $replace, $pos, strlen($needle));
|
||||
}
|
||||
return $haystack;
|
||||
}
|
||||
|
||||
function htmlescape($s) {
|
||||
if (is_array($s)) {
|
||||
foreach ($s as $k => $v) {
|
||||
$s[$k] = htmlescape($v);
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function jsonEncode($obj) {
|
||||
return json_encode($obj, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
function jsonDecode($json) {
|
||||
return json_decode($json, true);
|
||||
}
|
||||
|
||||
function startsWith(string $haystack, string $needle): bool {
|
||||
return $needle === "" || strpos($haystack, $needle) === 0;
|
||||
}
|
||||
|
||||
function endsWith(string $haystack, string $needle): bool {
|
||||
return $needle === "" || substr($haystack, -strlen($needle)) === $needle;
|
||||
}
|
||||
|
||||
function exectime($format = null) {
|
||||
$time = round(microtime(true) - START_TIME, 4);
|
||||
if (!is_null($format)) {
|
||||
$time = sprintf($format, $time);
|
||||
}
|
||||
return $time;
|
||||
}
|
||||
|
||||
function stransi($s) {
|
||||
static $colors = [
|
||||
'black' => 0,
|
||||
'red' => 1,
|
||||
'green' => 2,
|
||||
'yellow' => 3,
|
||||
'blue' => 4,
|
||||
'magenta' => 5,
|
||||
'cyan' => 6,
|
||||
'white' => 7
|
||||
];
|
||||
static $valid_styles = ['bold', 'fgbright', 'bgbright'];
|
||||
|
||||
$s = preg_replace_callback('/<(?:e ([a-z, =]+)|\/e)>/', function($match) use ($colors, $valid_styles) {
|
||||
if (empty($match[1])) {
|
||||
return "\033[0m";
|
||||
} else {
|
||||
$codes = [];
|
||||
$args = preg_split('/ +/', $match[1]);
|
||||
$fg = null;
|
||||
$bg = null;
|
||||
$styles = [];
|
||||
foreach ($args as $arg) {
|
||||
list($argname, $argvalue) = explode('=', $arg);
|
||||
$err = false;
|
||||
if ($argname == 'fg' || $argname == 'bg') {
|
||||
if (isset($colors[$argvalue])) {
|
||||
$$argname = $colors[$argvalue];
|
||||
} else {
|
||||
$err = true;
|
||||
}
|
||||
} else if ($argname == 'style') {
|
||||
$argstyles = array_filter(explode(',', $argvalue));
|
||||
foreach ($argstyles as $style) {
|
||||
if (!in_array($style, $valid_styles)) {
|
||||
$err = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$err) {
|
||||
foreach ($argstyles as $style) {
|
||||
$styles[$style] = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$err = true;
|
||||
}
|
||||
|
||||
if ($err) {
|
||||
trigger_error(__FUNCTION__.": unrecognized argument {$arg}", E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_null($fg)) {
|
||||
$codes[] = $fg + (isset($styles['fgbright']) ? 90 : 30);
|
||||
}
|
||||
if (!is_null($bg)) {
|
||||
$codes[] = $bg + (isset($styles['bgbright']) ? 100 : 40);
|
||||
}
|
||||
if (isset($styles['bold'])) {
|
||||
$codes[] = 1;
|
||||
}
|
||||
|
||||
return !empty($codes) ? "\033[".implode(';', $codes)."m" : '';
|
||||
}
|
||||
}, $s);
|
||||
return $s;
|
||||
}
|
||||
|
||||
function strgen($len = 10): string {
|
||||
$buf = '';
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$j = mt_rand(0, 61);
|
||||
if ($j >= 36) {
|
||||
$j += 13;
|
||||
} else if ($j >= 10) {
|
||||
$j += 7;
|
||||
}
|
||||
$buf .= chr(48 + $j);
|
||||
}
|
||||
return $buf;
|
||||
}
|
||||
|
||||
function setperm($file, $is_dir = null) {
|
||||
global $config;
|
||||
|
||||
// chgrp
|
||||
$gid = filegroup($file);
|
||||
$gname = posix_getgrgid($gid);
|
||||
if (!is_array($gname)) {
|
||||
debugError(__FUNCTION__.": posix_getgrgid() failed on $gid", $gname);
|
||||
} else {
|
||||
$gname = $gname['name'];
|
||||
}
|
||||
if ($gname != $config['group']) {
|
||||
if (!chgrp($file, $config['group'])) {
|
||||
debugError(__FUNCTION__.": chgrp() failed on $file");
|
||||
}
|
||||
}
|
||||
|
||||
// chmod
|
||||
$perms = fileperms($file);
|
||||
$need_perms = is_dir($file) ? $config['dirs_mode'] : $config['files_mode'];
|
||||
if (($perms & $need_perms) !== $need_perms) {
|
||||
if (!chmod($file, $need_perms)) {
|
||||
debugError(__FUNCTION__.": chmod() failed on $file");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function redirect($url, $preserve_utm = true, $no_ajax = false) {
|
||||
if (PHP_SAPI != 'cli' && $_SERVER['REQUEST_METHOD'] == 'GET' && $preserve_utm) {
|
||||
$proxy_params = ['utm_source', 'utm_medium', 'utm_content', 'utm_campaign'];
|
||||
$params = [];
|
||||
foreach ($proxy_params as $p) {
|
||||
if (!empty($_GET[$p])) {
|
||||
$params[$p] = (string)$_GET[$p];
|
||||
}
|
||||
}
|
||||
if (!empty($params)) {
|
||||
if (($anchor_pos = strpos($url, '#')) !== false) {
|
||||
$anchor = substr($url, $anchor_pos+1);
|
||||
$url = substr($url, 0, $anchor_pos);
|
||||
}
|
||||
$url .= (strpos($url, '?') === false ? '?' : '&').http_build_query($params);
|
||||
if ($anchor_pos !== false) {
|
||||
$url .= '#'.$anchor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header('Location: ' . $url);
|
||||
exit;
|
||||
}
|
||||
|
||||
function is_xhr_request(): bool {
|
||||
return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest';
|
||||
}
|
||||
|
||||
function secondsToTime(int $n): string {
|
||||
$parts = [];
|
||||
|
||||
if ($n >= 86400) {
|
||||
$days = floor($n / 86400);
|
||||
$n %= 86400;
|
||||
$parts[] = "{$days}д";
|
||||
}
|
||||
|
||||
if ($n >= 3600) {
|
||||
$hours = floor($n / 3600);
|
||||
$n %= 3600;
|
||||
$parts[] = "{$hours}ч";
|
||||
}
|
||||
|
||||
if ($n >= 60) {
|
||||
$minutes = floor($n / 60);
|
||||
$n %= 60;
|
||||
$parts[] = "{$minutes}мин";
|
||||
}
|
||||
|
||||
if ($n)
|
||||
$parts[] = "{$n}сек";
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
function bytesToUnitsLabel(GMP $b): string {
|
||||
$ks = array('B', 'Kb', 'Mb', 'Gb', 'Tb');
|
||||
foreach ($ks as $i => $k) {
|
||||
if (gmp_cmp($b, gmp_pow(1024, $i + 1)) < 0) {
|
||||
if ($i == 0)
|
||||
return gmp_strval($b) . ' ' . $k;
|
||||
|
||||
$n = gmp_intval(gmp_div_q($b, gmp_pow(1024, $i)));
|
||||
return round($n, 2).' '.$k;
|
||||
}
|
||||
}
|
||||
|
||||
return gmp_strval($b);
|
||||
}
|
||||
|
||||
function pwhash(string $s): string {
|
||||
return hash('sha256', config::get('auth_pw_salt').'|'.$s);
|
||||
}
|
||||
|
||||
$ShutdownFunctions = [];
|
||||
|
||||
function append_shutdown_function(callable $f) {
|
||||
global $ShutdownFunctions;
|
||||
$ShutdownFunctions[] = $f;
|
||||
}
|
||||
|
||||
function prepend_shutdown_function(callable $f) {
|
||||
global $ShutdownFunctions;
|
||||
array_unshift($ShutdownFunctions, $f);
|
||||
}
|
||||
|
||||
function getDB(): database {
|
||||
static $link = null;
|
||||
|
||||
if (is_null($link))
|
||||
$link = new database(config::get('database_path'));
|
||||
|
||||
return $link;
|
||||
}
|
||||
|
||||
function to_camel_case(string $input, string $separator = '_'): string {
|
||||
return lcfirst(str_replace($separator, '', ucwords($input, $separator)));
|
||||
}
|
||||
|
||||
function from_camel_case(string $s): string {
|
||||
$buf = '';
|
||||
$len = strlen($s);
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
if (!ctype_upper($s[$i])) {
|
||||
$buf .= $s[$i];
|
||||
} else {
|
||||
$buf .= '_'.strtolower($s[$i]);
|
||||
}
|
||||
}
|
||||
return $buf;
|
||||
}
|
||||
|
||||
function unsetcookie(string $name) {
|
||||
global $config;
|
||||
setcookie($name, null, -1, '/', $config['auth_cookie_host']);
|
||||
}
|
||||
|
||||
function setcookie_safe(...$args) {
|
||||
global $config;
|
||||
if (!headers_sent()) {
|
||||
if (count($args) == 2)
|
||||
setcookie($args[0], $args[1], time()+86400*365, '/', $config['auth_cookie_host']);
|
||||
else
|
||||
setcookie(...$args);
|
||||
}
|
||||
}
|
36
localwebsite/handlers/AuthHandler.php
Normal file
36
localwebsite/handlers/AuthHandler.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
class AuthHandler extends RequestHandler {
|
||||
|
||||
protected function before_dispatch(string $method, string $act) {
|
||||
return null;
|
||||
}
|
||||
|
||||
public function GET_auth() {
|
||||
list($error) = $this->input('error');
|
||||
$this->tpl->set(['error' => $error]);
|
||||
$this->tpl->set_title('Авторизация');
|
||||
$this->tpl->render_page('auth.twig');
|
||||
}
|
||||
|
||||
public function POST_auth() {
|
||||
list($username, $password) = $this->input('username, password');
|
||||
|
||||
$result = users::validatePassword($username, $password);
|
||||
if (!$result) {
|
||||
debugError('invalid login attempt: '.$_SERVER['REMOTE_ADDR'].', '.$_SERVER['HTTP_USER_AGENT'].", username=$username, password=$password");
|
||||
redirect('/auth/?error='.urlencode('неверный логин или пароль'));
|
||||
}
|
||||
|
||||
auth::setToken(pwhash($password));
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
public function GET_deauth() {
|
||||
if (auth::id())
|
||||
auth::logout();
|
||||
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
}
|
20
localwebsite/handlers/FakeRequestHandler.php
Normal file
20
localwebsite/handlers/FakeRequestHandler.php
Normal file
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
class FakeRequestHandler extends RequestHandler {
|
||||
|
||||
public function apacheNotFound() {
|
||||
http_response_code(404);
|
||||
$uri = htmlspecialchars($_SERVER['REQUEST_URI']);
|
||||
echo <<<EOF
|
||||
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
|
||||
<html><head>
|
||||
<title>404 Not Found</title>
|
||||
</head><body>
|
||||
<h1>Not Found</h1>
|
||||
<p>The requested URL {$uri} was not found on this server.</p>
|
||||
</body></html>
|
||||
EOF;
|
||||
exit;
|
||||
}
|
||||
|
||||
}
|
102
localwebsite/handlers/InverterHandler.php
Normal file
102
localwebsite/handlers/InverterHandler.php
Normal file
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
class InverterHandler extends RequestHandler
|
||||
{
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct();
|
||||
$this->tpl->add_static('inverter.js');
|
||||
}
|
||||
|
||||
public function GET_status_page() {
|
||||
$inv = $this->getClient();
|
||||
|
||||
$status = jsonDecode($inv->exec('get-status'))['data'];
|
||||
$rated = jsonDecode($inv->exec('get-rated'))['data'];
|
||||
|
||||
$this->tpl->set([
|
||||
'status' => $status,
|
||||
'rated' => $rated,
|
||||
'html' => $this->renderStatusHtml($status, $rated)
|
||||
]);
|
||||
$this->tpl->set_title('Инвертор');
|
||||
$this->tpl->render_page('inverter_page.twig');
|
||||
}
|
||||
|
||||
public function GET_set_osp() {
|
||||
list($osp) = $this->input('e:value(=sub|sbu)');
|
||||
$inv = $this->getClient();
|
||||
try {
|
||||
$inv->exec('set-output-source-priority', [strtoupper($osp)]);
|
||||
} catch (Exception $e) {
|
||||
die('Ошибка: '.jsonDecode($e->getMessage())['message']);
|
||||
}
|
||||
redirect('/inverter/');
|
||||
}
|
||||
|
||||
public function GET_status_ajax() {
|
||||
$inv = $this->getClient();
|
||||
$status = jsonDecode($inv->exec('get-status'))['data'];
|
||||
$rated = jsonDecode($inv->exec('get-rated'))['data'];
|
||||
ajax_ok(['html' => $this->renderStatusHtml($status, $rated)]);
|
||||
}
|
||||
|
||||
protected function renderStatusHtml(array $status, array $rated) {
|
||||
$power_direction = strtolower($status['battery_power_direction']);
|
||||
$power_direction = preg_replace('/ge$/', 'ging', $power_direction);
|
||||
|
||||
$charging_rate = '';
|
||||
if ($power_direction == 'charging')
|
||||
$charging_rate = sprintf(' @ %s %s',
|
||||
$status['battery_charge_current']['value'],
|
||||
$status['battery_charge_current']['unit']);
|
||||
else if ($power_direction == 'discharging')
|
||||
$charging_rate = sprintf(' @ %s %s',
|
||||
$status['battery_discharge_current']['value'],
|
||||
$status['battery_discharge_current']['unit']);
|
||||
|
||||
$html = sprintf('<b>Battery:</b> %s %s',
|
||||
$status['battery_voltage']['value'],
|
||||
$status['battery_voltage']['unit']);
|
||||
$html .= sprintf(' (%s%s, ',
|
||||
$status['battery_capacity']['value'],
|
||||
$status['battery_capacity']['unit']);
|
||||
$html .= sprintf('%s%s)',
|
||||
$power_direction,
|
||||
$charging_rate);
|
||||
|
||||
$html .= "\n".sprintf('<b>Load:</b> %s %s',
|
||||
$status['ac_output_active_power']['value'],
|
||||
$status['ac_output_active_power']['unit']);
|
||||
$html .= sprintf(' (%s%%)',
|
||||
$status['output_load_percent']['value']);
|
||||
|
||||
if ($status['pv1_input_power']['value'] > 0)
|
||||
$html .= "\n".sprintf('<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".sprintf('<b>AC input:</b> %s %s',
|
||||
$status['grid_voltage']['value'],
|
||||
$status['grid_voltage']['unit']);
|
||||
$html .= sprintf(', %s %s',
|
||||
$status['grid_freq']['value'],
|
||||
$status['grid_freq']['unit']);
|
||||
}
|
||||
|
||||
$html .= "\n".sprintf('<b>Priority:</b> %s',
|
||||
$rated['output_source_priority']);
|
||||
|
||||
return nl2br($html);
|
||||
}
|
||||
|
||||
protected function getClient(): InverterdClient {
|
||||
global $config;
|
||||
$inv = new InverterdClient($config['inverterd_host'], $config['inverterd_port']);
|
||||
$inv->setFormat('json');
|
||||
return $inv;
|
||||
}
|
||||
|
||||
|
||||
}
|
168
localwebsite/handlers/MiscHandler.php
Normal file
168
localwebsite/handlers/MiscHandler.php
Normal file
@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
class MiscHandler extends RequestHandler
|
||||
{
|
||||
|
||||
public function GET_main() {
|
||||
global $config;
|
||||
$this->tpl->set_title('Главная');
|
||||
$this->tpl->set([
|
||||
'grafana_sensors_url' => $config['grafana_sensors_url'],
|
||||
'grafana_inverter_url' => $config['grafana_inverter_url'],
|
||||
'cameras' => $config['cam_list']['labels']
|
||||
]);
|
||||
$this->tpl->render_page('index.twig');
|
||||
}
|
||||
|
||||
public function GET_sensors_page() {
|
||||
global $config;
|
||||
|
||||
$clients = [];
|
||||
foreach ($config['temphumd_servers'] as $key => $params) {
|
||||
$cl = new TemphumdClient(...$params);
|
||||
$clients[$key] = $cl;
|
||||
|
||||
$cl->readSensor();
|
||||
}
|
||||
|
||||
$this->tpl->set(['sensors' => $clients]);
|
||||
$this->tpl->set_title('Датчики');
|
||||
$this->tpl->render_page('sensors.twig');
|
||||
}
|
||||
|
||||
public function GET_pump_page() {
|
||||
global $config;
|
||||
|
||||
list($set) = $this->input('set');
|
||||
$client = new GPIORelaydClient($config['pump_host'], $config['pump_port']);
|
||||
|
||||
if ($set == GPIORelaydClient::STATUS_ON || $set == GPIORelaydClient::STATUS_OFF) {
|
||||
$client->setStatus($set);
|
||||
redirect('/pump/');
|
||||
}
|
||||
|
||||
$status = $client->getStatus();
|
||||
|
||||
$this->tpl->set([
|
||||
'status' => $status
|
||||
]);
|
||||
$this->tpl->set_title('Насос');
|
||||
$this->tpl->render_page('pump.twig');
|
||||
}
|
||||
|
||||
public function GET_cams() {
|
||||
global $config;
|
||||
|
||||
list($hls_debug, $video_events, $high, $camera_ids) = $this->input('b:hls_debug, b:video_events, b:high, id');
|
||||
if ($camera_ids != '') {
|
||||
$camera_param = $camera_ids;
|
||||
$camera_ids = explode(',', $camera_ids);
|
||||
$camera_ids = array_filter($camera_ids);
|
||||
$camera_ids = array_map('trim', $camera_ids);
|
||||
$camera_ids = array_map('intval', $camera_ids);
|
||||
} else {
|
||||
$camera_ids = array_keys($config['cam_list']['labels']);
|
||||
$camera_param = '';
|
||||
}
|
||||
|
||||
$tab = $high ? 'high' : 'low';
|
||||
|
||||
// h264
|
||||
$js_hls_config = [
|
||||
'opts' => [
|
||||
'startPosition' => -1,
|
||||
|
||||
// // https://github.com/video-dev/hls.js/issues/3884#issuecomment-842380784
|
||||
'liveSyncDuration' => 2,
|
||||
'liveMaxLatencyDuration' => 3,
|
||||
'maxLiveSyncPlaybackRate' => 2,
|
||||
'liveDurationInfinity' => true,
|
||||
],
|
||||
'debugVideoEvents' => !!$video_events,
|
||||
];
|
||||
if ($hls_debug)
|
||||
$js_hls_config['debug'] = true;
|
||||
|
||||
// h265
|
||||
$js_h265webjs_config = [
|
||||
// https://github.com/numberwolf/h265web.js/blob/master/README_EN.MD#freetoken
|
||||
'token' => 'base64:QXV0aG9yOmNoYW5neWFubG9uZ3xudW1iZXJ3b2xmLEdpdGh1YjpodHRwczovL2dpdGh1Yi5jb20vbnVtYmVyd29sZixFbWFpbDpwb3JzY2hlZ3QyM0Bmb3htYWlsLmNvbSxRUTo1MzEzNjU4NzIsSG9tZVBhZ2U6aHR0cDovL3h2aWRlby52aWRlbyxEaXNjb3JkOm51bWJlcndvbGYjODY5NCx3ZWNoYXI6bnVtYmVyd29sZjExLEJlaWppbmcsV29ya0luOkJhaWR1',
|
||||
];
|
||||
|
||||
$js_config = [
|
||||
'isLow' => $tab == 'low',
|
||||
'proto' => config::get('cam_hls_proto'),
|
||||
'host' => config::get('cam_hls_host'),
|
||||
'camIds' => $camera_ids,
|
||||
'camLabels' => array_map(fn($id) => $config['cam_list']['labels'][$id], $camera_ids)
|
||||
];
|
||||
|
||||
$cams_by_type = [];
|
||||
$include_h264 = false;
|
||||
$include_h265 = false;
|
||||
foreach ($camera_ids as $camera_id) {
|
||||
$var_name = 'include_'.$config['cam_list']['full'][$camera_id]['type'];
|
||||
$cams_by_type[$camera_id] = $config['cam_list']['full'][$camera_id]['type'];
|
||||
$$var_name = true;
|
||||
}
|
||||
if ($include_h264) {
|
||||
$js_config['hlsConfig'] = $js_hls_config;
|
||||
$this->tpl->add_static('hls.js');
|
||||
}
|
||||
if ($include_h265) {
|
||||
$js_config['h265webjsConfig'] = $js_h265webjs_config;
|
||||
$this->tpl->add_static('h265webjs-dist/missile.js');
|
||||
$this->tpl->add_static('h265webjs-dist/h265webjs-v20221106-reminified.js');
|
||||
}
|
||||
|
||||
$js_config['camsByType'] = $cams_by_type;
|
||||
|
||||
$hls_key = config::get('cam_hls_access_key');
|
||||
if ($hls_key)
|
||||
setcookie_safe('hls_key', $hls_key);
|
||||
|
||||
// $cam_filter = function($id) use ($config, $camera_ids) {
|
||||
// return in_array($id, $camera_ids);
|
||||
// };
|
||||
|
||||
$this->tpl->set([
|
||||
'js_config' => $js_config,
|
||||
|
||||
// 'hls_access_key' => $config['cam_hls_access_key'],
|
||||
|
||||
'camera_param' => $camera_param,
|
||||
// 'cams' => array_values(array_filter($config['cam_list'][$tab], $cam_filter)),
|
||||
'tab' => $tab,
|
||||
'video_events' => $video_events
|
||||
]);
|
||||
$this->tpl->set_title('Камеры');
|
||||
$this->tpl->render_page('cams.twig');
|
||||
}
|
||||
|
||||
public function GET_cams_stat() {
|
||||
global $config;
|
||||
list($ip, $port) = explode(':', $config['ipcam_server_api_addr']);
|
||||
$body = jsonDecode(file_get_contents('http://'.$ip.':'.$port.'/api/timestamp/all'));
|
||||
|
||||
header('Content-Type: text/plain');
|
||||
$date_fmt = 'd.m.Y H:i:s';
|
||||
|
||||
foreach ($body['response'] as $cam => $data) {
|
||||
$fix = date($date_fmt, $data['fix']);
|
||||
$start = date($date_fmt, $data['motion_start']);
|
||||
$motion = date($date_fmt, $data['motion']);
|
||||
echo "$cam:\n motion: $motion\n";
|
||||
echo " motion_start: $start\n";
|
||||
echo " fix: $fix\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
public function GET_debug() {
|
||||
print_r($_SERVER);
|
||||
}
|
||||
|
||||
public function GET_phpinfo() {
|
||||
phpinfo();
|
||||
}
|
||||
|
||||
}
|
297
localwebsite/handlers/ModemHandler.php
Normal file
297
localwebsite/handlers/ModemHandler.php
Normal file
@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
use libphonenumber\NumberParseException;
|
||||
use libphonenumber\PhoneNumberFormat;
|
||||
use libphonenumber\PhoneNumberUtil;
|
||||
|
||||
class ModemHandler extends RequestHandler
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->tpl->add_static('modem.js');
|
||||
}
|
||||
|
||||
public function GET_status_page() {
|
||||
global $config;
|
||||
|
||||
$this->tpl->set([
|
||||
'modems' => $config['modems'],
|
||||
'js_modems' => array_keys($config['modems']),
|
||||
]);
|
||||
|
||||
$this->tpl->set_title('Состояние модемов');
|
||||
$this->tpl->render_page('modem_status_page.twig');
|
||||
}
|
||||
|
||||
public function GET_status_get_ajax() {
|
||||
global $config;
|
||||
list($id) = $this->input('id');
|
||||
if (!isset($config['modems'][$id]))
|
||||
ajax_error('invalid modem id: '.$id);
|
||||
|
||||
$modem_data = self::getModemData(
|
||||
$config['modems'][$id]['ip'],
|
||||
$config['modems'][$id]['legacy_token_auth']);
|
||||
|
||||
ajax_ok([
|
||||
'html' => $this->tpl->render('modem_data.twig', [
|
||||
'loading' => false,
|
||||
'modem' => $id,
|
||||
'modem_data' => $modem_data
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
public function GET_verbose_page() {
|
||||
global $config;
|
||||
|
||||
list($modem) = $this->input('modem');
|
||||
if (!$modem)
|
||||
$modem = array_key_first($config['modems']);
|
||||
|
||||
list($signal, $status, $traffic, $device, $dialup_conn) = self::getModemData(
|
||||
$config['modems'][$modem]['ip'],
|
||||
$config['modems'][$modem]['legacy_token_auth'],
|
||||
true);
|
||||
|
||||
$data = [
|
||||
['Signal', $signal],
|
||||
['Connection', $status],
|
||||
['Traffic', $traffic],
|
||||
['Device info', $device],
|
||||
['Dialup connection', $dialup_conn]
|
||||
];
|
||||
$this->tpl->set([
|
||||
'data' => $data,
|
||||
'modem_name' => $config['modems'][$modem]['label'],
|
||||
]);
|
||||
$this->tpl->set_title('Подробная информация о модеме '.$modem);
|
||||
$this->tpl->render_page('modem_verbose_page.twig');
|
||||
}
|
||||
|
||||
|
||||
public function GET_routing_smallhome_page() {
|
||||
global $config;
|
||||
|
||||
list($error) = $this->input('error');
|
||||
$upstream = self::getCurrentSmallHomeUpstream();
|
||||
|
||||
$current_upstream = [
|
||||
'key' => $upstream,
|
||||
'label' => $config['modems'][$upstream]['label']
|
||||
];
|
||||
|
||||
$this->tpl->set([
|
||||
'error' => $error,
|
||||
'current' => $current_upstream,
|
||||
'modems' => $config['modems'],
|
||||
]);
|
||||
$this->tpl->set_title('Маршрутизация');
|
||||
$this->tpl->render_page('routing_page.twig');
|
||||
}
|
||||
|
||||
public function GET_routing_smallhome_switch() {
|
||||
global $config;
|
||||
list($new_upstream) = $this->input('upstream');
|
||||
if (!isset($config['modems'][$new_upstream]))
|
||||
redirect('/routing/?error='.urlencode('invalid upstream'));
|
||||
|
||||
$current_upstream = self::getCurrentSmallHomeUpstream();
|
||||
if ($current_upstream != $new_upstream) {
|
||||
if ($current_upstream != $config['routing_default'])
|
||||
MyOpenWrtUtils::ipsetDel($current_upstream, $config['routing_smallhome_ip']);
|
||||
if ($new_upstream != $config['routing_default'])
|
||||
MyOpenWrtUtils::ipsetAdd($new_upstream, $config['routing_smallhome_ip']);
|
||||
}
|
||||
|
||||
redirect('/routing/');
|
||||
}
|
||||
|
||||
public function GET_routing_ipsets_page() {
|
||||
list($error) = $this->input('error');
|
||||
|
||||
$ip_sets = MyOpenWrtUtils::ipsetListAll();
|
||||
$this->tpl->set([
|
||||
'sets' => $ip_sets,
|
||||
'error' => $error
|
||||
]);
|
||||
$this->tpl->set_title('Маршрутизация: IP sets');
|
||||
$this->tpl->render_page('routing_ipsets_page.twig');
|
||||
}
|
||||
|
||||
public function GET_routing_ipsets_del() {
|
||||
list($set, $ip) = $this->input('set, ip');
|
||||
self::validateIpsetsInput($set, $ip);
|
||||
|
||||
$output = MyOpenWrtUtils::ipsetDel($set, $ip);
|
||||
|
||||
$url = '/routing/ipsets/';
|
||||
if ($output != '')
|
||||
$url .= '?error='.urlencode($output);
|
||||
redirect($url);
|
||||
}
|
||||
|
||||
public function POST_routing_ipsets_add() {
|
||||
list($set, $ip) = $this->input('set, ip');
|
||||
self::validateIpsetsInput($set, $ip);
|
||||
|
||||
$output = MyOpenWrtUtils::ipsetAdd($set, $ip);
|
||||
|
||||
$url = '/routing/ipsets/';
|
||||
if ($output != '')
|
||||
$url .= '?error='.urlencode($output);
|
||||
redirect($url);
|
||||
}
|
||||
|
||||
public function GET_routing_dhcp_page() {
|
||||
$overrides = config::get('dhcp_hostname_overrides');
|
||||
$leases = MyOpenWrtUtils::getDHCPLeases();
|
||||
foreach ($leases as &$lease) {
|
||||
if ($lease['hostname'] == '?' && array_key_exists($lease['mac'], $overrides))
|
||||
$lease['hostname'] = $overrides[$lease['mac']];
|
||||
}
|
||||
$this->tpl->set([
|
||||
'leases' => $leases
|
||||
]);
|
||||
$this->tpl->set_title('Маршрутизация: DHCP');
|
||||
$this->tpl->render_page('routing_dhcp_page.twig');
|
||||
}
|
||||
|
||||
public function GET_sms() {
|
||||
global $config;
|
||||
|
||||
list($selected, $is_outbox, $error, $sent) = $this->input('modem, b:outbox, error, b:sent');
|
||||
if (!$selected)
|
||||
$selected = array_key_first($config['modems']);
|
||||
|
||||
$cfg = $config['modems'][$selected];
|
||||
$e3372 = new E3372($cfg['ip'], $cfg['legacy_token_auth']);
|
||||
$messages = $e3372->getSMSList(1, 20, $is_outbox);
|
||||
|
||||
$this->tpl->set([
|
||||
'modems_list' => array_keys($config['modems']),
|
||||
'modems' => $config['modems'],
|
||||
'selected_modem' => $selected,
|
||||
'messages' => $messages,
|
||||
'is_outbox' => $is_outbox,
|
||||
'error' => $error,
|
||||
'is_sent' => $sent
|
||||
]);
|
||||
|
||||
$direction = $is_outbox ? 'исходящие' : 'входящие';
|
||||
$this->tpl->set_title('SMS-сообщения ('.$direction.', '.$selected.')');
|
||||
$this->tpl->render_page('sms_page.twig');
|
||||
}
|
||||
|
||||
public function POST_sms() {
|
||||
global $config;
|
||||
|
||||
list($selected, $is_outbox, $phone, $text) = $this->input('modem, b:outbox, phone, text');
|
||||
if (!$selected)
|
||||
$selected = array_key_first($config['modems']);
|
||||
|
||||
$return_url = '/sms/?modem='.$selected;
|
||||
if ($is_outbox)
|
||||
$return_url .= '&outbox=1';
|
||||
|
||||
$go_back = function(?string $error = null) use ($return_url) {
|
||||
if (!is_null($error))
|
||||
$return_url .= '&error='.urlencode($error);
|
||||
else
|
||||
$return_url .= '&sent=1';
|
||||
redirect($return_url);
|
||||
};
|
||||
|
||||
$phone = preg_replace('/\s+/', '', $phone);
|
||||
|
||||
// при отправке смс на короткие номера не надо использовать libphonenumber и вот это вот всё
|
||||
if (strlen($phone) > 4) {
|
||||
$country = null;
|
||||
if (!startsWith($phone, '+'))
|
||||
$country = 'RU';
|
||||
|
||||
$phoneUtil = PhoneNumberUtil::getInstance();
|
||||
try {
|
||||
$number = $phoneUtil->parse($phone, $country);
|
||||
} catch (NumberParseException $e) {
|
||||
debugError(__METHOD__.': failed to parse number '.$phone.': '.$e->getMessage());
|
||||
$go_back('Неверный номер ('.$e->getMessage().')');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$phoneUtil->isValidNumber($number)) {
|
||||
$go_back('Неверный номер');
|
||||
return;
|
||||
}
|
||||
|
||||
$phone = $phoneUtil->format($number, PhoneNumberFormat::E164);
|
||||
}
|
||||
|
||||
$cfg = $config['modems'][$selected];
|
||||
$e3372 = new E3372($cfg['ip'], $cfg['legacy_token_auth']);
|
||||
|
||||
$result = $e3372->sendSMS($phone, $text);
|
||||
debugLog($result);
|
||||
|
||||
$go_back();
|
||||
}
|
||||
|
||||
protected static function getModemData(string $ip,
|
||||
bool $need_auth = true,
|
||||
bool $get_raw_data = false): array {
|
||||
$modem = new E3372($ip, $need_auth);
|
||||
|
||||
$signal = $modem->getDeviceSignal();
|
||||
$status = $modem->getMonitoringStatus();
|
||||
$traffic = $modem->getTrafficStats();
|
||||
|
||||
if ($get_raw_data) {
|
||||
$device_info = $modem->getDeviceInformation();
|
||||
$dialup_conn = $modem->getDialupConnection();
|
||||
return [$signal, $status, $traffic, $device_info, $dialup_conn];
|
||||
} else {
|
||||
return [
|
||||
'type' => e3372::getNetworkTypeLabel($status['CurrentNetworkType']),
|
||||
'level' => $status['SignalIcon'] ?? 0,
|
||||
'rssi' => $signal['rssi'],
|
||||
'sinr' => $signal['sinr'],
|
||||
'connected_time' => secondsToTime($traffic['CurrentConnectTime']),
|
||||
'downloaded' => bytesToUnitsLabel(gmp_init($traffic['CurrentDownload'])),
|
||||
'uploaded' => bytesToUnitsLabel(gmp_init($traffic['CurrentUpload'])),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
protected static function getCurrentSmallHomeUpstream() {
|
||||
global $config;
|
||||
|
||||
$upstream = null;
|
||||
$ip_sets = MyOpenWrtUtils::ipsetListAll();
|
||||
foreach ($ip_sets as $set => $ips) {
|
||||
if (in_array($config['routing_smallhome_ip'], $ips)) {
|
||||
$upstream = $set;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (is_null($upstream))
|
||||
$upstream = $config['routing_default'];
|
||||
|
||||
return $upstream;
|
||||
}
|
||||
|
||||
protected static function validateIpsetsInput($set, $ip) {
|
||||
global $config;
|
||||
|
||||
if (!isset($config['modems'][$set]))
|
||||
redirect('/routing/ipsets/?error='.urlencode('invalid set: '.$set));
|
||||
|
||||
if (($slashpos = strpos($ip, '/')) !== false)
|
||||
$ip = substr($ip, 0, $slashpos);
|
||||
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP))
|
||||
redirect('/routing/ipsets/?error='.urlencode('invalid ip/network: '.$ip));
|
||||
}
|
||||
|
||||
}
|
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