Compare commits

..

20 Commits

Author SHA1 Message Date
Evgeny Zinoviev
077495eba6 ipcam/hls: add -nostdin everywhere 2024-03-07 15:33:15 +03:00
Evgeny Zinoviev
b83f3e0eb7 ipcam_capture: nostdin and always tcp 2024-03-07 15:31:52 +03:00
Evgeny Zinoviev
3a8961837c another fix 2024-02-21 14:22:27 +03:00
Evgeny Zinoviev
40bcc7f5f4 ipcam_capture: test new fix 2024-02-21 14:15:38 +03:00
Evgeny Sorokin
c17410c073 fix 2024-02-21 01:20:38 +03:00
Evgeny Sorokin
cc67e1e2db one more fix 2024-02-21 01:01:07 +03:00
Evgeny Sorokin
f95472b413 ipcam_capture: trying to fix segment time stamps... 2024-02-21 00:59:39 +03:00
Evgeny Zinoviev
9ee4fc4fde fix tools/ipcam_capture_restart_all.sh 2024-02-15 14:02:46 +03:00
Evgeny Sorokin
4af565b27d fix 2024-01-25 16:35:00 +03:00
Evgeny Sorokin
75b2517c50 tools: add ipcam_capture_restart_all.sh 2024-01-25 16:31:02 +03:00
Evgeny Sorokin
b9de2f2ce5 chmod +x 2024-01-22 03:17:36 +03:00
Evgeny Sorokin
e505c57464 rtsp2hls rkmpp tools upd 2024-01-22 03:15:24 +03:00
Evgeny Sorokin
2ebc4c68ce ipcam_cleanup.sh chmod +x 2024-01-19 17:39:50 +03:00
Evgeny Sorokin
8d4045f6c3 tools: add ipcam_cleanup.sh script 2024-01-19 17:35:44 +03:00
Evgeny Zinoviev
72a45b8521 ipcam_capture: check for mountpoint 2024-01-11 17:52:53 +03:00
Evgeny Zinoviev
014f310353 ipcam capture upd 2024-01-11 17:40:10 +03:00
Evgeny Zinoviev
c712beb699 use corrent timestamps 2024-01-10 03:21:45 +03:00
evgeny
c857f58b40 ipcam_rtsp2hls_rkmpp fixes 2023-12-23 03:43:06 +03:00
evgeny
ae2787b3ae systemd: add rkmpp hls service unit 2023-10-05 04:45:30 +03:00
User
e26851a600 add script for rkmpp 2023-10-05 04:43:49 +03:00
425 changed files with 8203 additions and 7559 deletions

12
.gitignore vendored
View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

7
doc/localwebsite.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)

View File

@ -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 []

View File

@ -1 +0,0 @@
from .base import SensorType, BaseSensor

View File

@ -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')

View File

@ -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")

View File

@ -1 +0,0 @@
from .nwipcam import XMEyeCamera

View File

@ -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())

View File

@ -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)
)
])

View 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 '?';
}
}
}

View 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();
}
}

View 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));
}
}

View 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
];
}
}

View 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));
}
}

View 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;
}
}

View 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;
}
}

View File

@ -0,0 +1,11 @@
<?php
class User extends model {
const DB_TABLE = 'users';
public int $id;
public string $username;
public string $password;
}

View 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;
}
}

View 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];
}
}

View 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);
}
}

View 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
View 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
View 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' => [],
];

View 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);

View 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);
}
}

View 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));
}
}

View 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];
}
}

View 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];
}
}

View 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
View 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
View 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);
}
}

View 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('/');
}
}

View 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;
}
}

View 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;
}
}

View 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();
}
}

View 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