Merge branch 'website-python-rewrite'

This commit is contained in:
Evgeny Zinoviev 2024-02-17 02:48:57 +03:00
commit b7f1d55c9b
68 changed files with 1284 additions and 964 deletions

View File

@ -48,7 +48,8 @@ async def run_ffmpeg(cam: int, channel: int):
else:
debug_args = ['-nostats', '-loglevel', 'error']
protocol = 'tcp' if ipcam_config.should_use_tcp_for_rtsp(cam) else 'udp'
# 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)

199
bin/ipcam_ntp_util.py Executable file
View File

@ -0,0 +1,199 @@
#!/usr/bin/env python3
import __py_include
import requests
import hashlib
import xml.etree.ElementTree as ET
from time import time
from argparse import ArgumentParser, ArgumentError
from homekit.util import validate_ipv4, validate_ipv4_or_hostname
from homekit.camera import IpcamConfig
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()
class ResponseError(RuntimeError):
pass
class AuthError(ResponseError):
pass
class HikvisionISAPIClient:
def __init__(self, host):
self.host = host
self.cookies = {}
def auth(self, username: str, password: str):
r = requests.get(self.isapi_uri('Security/sessionLogin/capabilities'),
{'username': username},
headers={
'X-Requested-With': 'XMLHttpRequest',
})
r.raise_for_status()
caps = xml_to_dict(r.text)['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>'
r = requests.post(self.isapi_uri(f'Security/sessionLogin?timeStamp={int(time())}'), data=data, headers={
'Accept-Encoding': 'gzip, deflate',
'If-Modified-Since': '0',
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
})
r.raise_for_status()
resp = xml_to_dict(r.text)['SessionLogin']
status_value = int(resp['statusValue'][0])
status_string = resp['statusString'][0]
if status_value != 200:
raise AuthError(f'{status_value}: {status_string}')
self.cookies = r.cookies.get_dict()
def get_ntp_server(self) -> str:
r = requests.get(self.isapi_uri('System/time/ntpServers/capabilities'), cookies=self.cookies)
r.raise_for_status()
ntp_server = xml_to_dict(r.text)['NTPServerList']['NTPServer'][0]
if ntp_server['addressingFormatType'][0]['#text'] == '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>'
r = requests.put(self.isapi_uri('System/time'), cookies=self.cookies, data=data, headers={
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
})
self.isapi_check_put_response(r)
def set_ntp_server(self, ntp_host: str, ntp_port: int = 123):
format = 'ipaddress' if validate_ipv4(ntp_host) else 'hostname'
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>'
r = requests.put(self.isapi_uri('System/time/ntpServers/1'),
data=data,
cookies=self.cookies,
headers={
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
})
self.isapi_check_put_response(r)
def isapi_uri(self, path: str) -> str:
return f'http://{self.host}/ISAPI/{path}'
def isapi_check_put_response(self, r):
r.raise_for_status()
resp = xml_to_dict(r.text)['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')
def main():
parser = ArgumentParser()
parser.add_argument('--host', type=str, required=True)
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 not args.get_ntp_server and not args.set_ntp_server:
raise ArgumentError(None, 'either --get-ntp-server or --set-ntp-server is required')
ipcam_config = IpcamConfig()
login = args.username if args.username else ipcam_config['web_creds']['login']
password = args.password if args.password else ipcam_config['web_creds']['password']
client = HikvisionISAPIClient(args.host)
client.auth(args.username, args.password)
if args.get_ntp_server:
print(client.get_ntp_server())
return
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')
client.set_ntp_server(args.set_ntp_server)
if __name__ == '__main__':
main()

View File

@ -48,7 +48,6 @@ if __name__ == '__main__':
help='mqtt modules to include')
parser.add_argument('--switch-relay', choices=[0, 1], type=int,
help='send relay state')
parser.add_argument('--legacy-relay', action='store_true')
parser.add_argument('--push-ota', type=str, metavar='OTA_FILENAME',
help='push OTA, receives path to firmware.bin (not .elf!)')
parser.add_argument('--no-wait', action='store_true',
@ -80,8 +79,10 @@ if __name__ == '__main__':
if arg.modules:
for m in arg.modules:
kwargs = {}
if m == 'relay' and arg.legacy_relay:
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

354
bin/web_kbn.py Normal file
View File

@ -0,0 +1,354 @@
#!/usr/bin/env python3
import asyncio
import jinja2
import aiohttp_jinja2
import json
import re
import inverterd
import phonenumbers
import __py_include
from io import StringIO
from aiohttp.web import HTTPFound
from typing import Optional, Union
from homekit.config import config, AppConfigUnit
from homekit.util import homekit_path, filesize_fmt, seconds_to_human_readable_string
from homekit.modem import E3372, ModemsConfig, MacroNetWorkType
from homekit.inverter.config import InverterdConfig
from homekit.relay.sunxi_h3_client import RelayClient
from homekit import 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),
'inverter_grafana_url': {'type': 'string'},
'sensors_grafana_url': {'type': 'string'},
}
STATIC_FILES = [
'bootstrap.min.css',
'bootstrap.min.js',
'polyfills.js',
'app.js',
'app.css'
]
def get_js_link(file, version) -> str:
if version:
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 version:
file += f'?version={version}'
return f'<link rel="stylesheet" type="text/css" href="{config.app_config["assets_public_path"]}/{file}">'
def get_head_static() -> str:
buf = StringIO()
for file in STATIC_FILES:
v = 2
try:
q_ind = file.index('?')
v = file[q_ind+1:]
file = file[:file.index('?')]
except ValueError:
pass
if file.endswith('.js'):
buf.write(get_js_link(file, v))
else:
buf.write(get_css_link(file, v))
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
class WebSite(http.HTTPServer):
_modems_config: ModemsConfig
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._modems_config = ModemsConfig()
aiohttp_jinja2.setup(
self.app,
loader=jinja2.FileSystemLoader(homekit_path('web', 'kbn_templates')),
autoescape=jinja2.select_autoescape(['html', 'xml']),
)
env = aiohttp_jinja2.get_env(self.app)
env.filters['tojson'] = lambda obj: json.dumps(obj, separators=(',', ':'))
self.app.router.add_static('/assets/', path=homekit_path('web', 'kbn_assets'))
self.get('/main.cgi', self.index)
self.get('/modems.cgi', self.modems)
self.get('/modems/info.ajx', self.modems_ajx)
self.get('/modems/verbose.cgi', self.modems_verbose)
self.get('/inverter.cgi', self.inverter)
self.get('/inverter.ajx', self.inverter_ajx)
self.get('/pump.cgi', self.pump)
self.get('/sms.cgi', self.sms)
self.post('/sms.cgi', self.sms_post)
async def render_page(self,
req: http.Request,
template_name: str,
title: Optional[str] = None,
context: Optional[dict] = None):
if context is None:
context = {}
context = {
**context,
'head_static': get_head_static()
}
if title is not None:
context['title'] = title
response = aiohttp_jinja2.render_template(template_name+'.j2', req, context=context)
return response
async def index(self, req: http.Request):
ctx = {}
for k in 'inverter', 'sensors':
ctx[f'{k}_grafana_url'] = config.app_config[f'{k}_grafana_url']
return await self.render_page(req, 'index',
title="Home web site",
context=ctx)
async def modems(self, req: http.Request):
return await self.render_page(req, 'modems',
title='Состояние модемов',
context=dict(modems=self._modems_config))
async def modems_ajx(self, req: http.Request):
modem = req.query.get('id', None)
if modem not in self._modems_config.keys():
raise ValueError('invalid modem id')
modem_cfg = self._modems_config.get(modem)
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 self.ok({'html': html})
async def modems_verbose(self, req: http.Request):
modem = req.query.get('id', None)
if modem not in self._modems_config.keys():
raise ValueError('invalid modem id')
modem_cfg = self._modems_config.get(modem)
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 = self._modems_config.getfullname(modem)
return await self.render_page(req, 'modem_verbose',
title=f'Подробная информация о модеме "{modem_name}"',
context=dict(data=data, modem_name=modem_name))
async def sms(self, req: http.Request):
modem = req.query.get('id', list(self._modems_config.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
cl = get_modem_client(self._modems_config[modem])
messages = cl.sms_list(1, 20, is_outbox)
return await self.render_page(req, 'sms',
title=f"SMS-сообщения ({'исходящие' if is_outbox else 'входящие'}, {modem})",
context=dict(
modems=self._modems_config,
selected_modem=modem,
is_outbox=is_outbox,
error=error,
is_sent=sent,
messages=messages
))
async def sms_post(self, req: http.Request):
modem = req.query.get('id', list(self._modems_config.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('\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 HTTPFound(f'{return_url}&error=Неверный+номер')
phone = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
cl = get_modem_client(self._modems_config[modem])
cl.sms_send(phone, text)
raise HTTPFound(return_url)
async def inverter(self, req: http.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 HTTPFound('/inverter.cgi')
status, rated, html = await asyncio.get_event_loop().run_in_executor(None, get_inverter_data)
return await self.render_page(req, 'inverter',
title='Инвертор',
context=dict(status=status, rated=rated, html=html))
async def inverter_ajx(self, req: http.Request):
status, rated, html = await asyncio.get_event_loop().run_in_executor(None, get_inverter_data)
return self.ok({'html': html})
async def pump(self, req: http.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 HTTPFound('/pump.cgi')
status = cl.status()
return await self.render_page(req, 'pump',
title='Насос',
context=dict(status=status))
if __name__ == '__main__':
config.load_app(WebKbnConfig)
server = WebSite(config.app_config['listen_addr'])
server.run()

View File

@ -23,17 +23,13 @@ class IpcamConfig(ConfigUnit):
@classmethod
def schema(cls) -> Optional[dict]:
return {
'cams': {
'cameras': {
'type': 'dict',
'keysrules': {'type': ['string', 'integer']},
'valuesrules': {
'type': 'dict',
'schema': {
'type': {'type': 'string', 'allowed': [t.value for t in CameraType], 'required': True},
'codec': {'type': 'string', 'allowed': [t.value for t in VideoCodecType], 'required': True},
'container': {'type': 'string', 'allowed': [t.value for t in VideoContainerType], 'required': True},
'server': {'type': 'string', 'allowed': list(_lbc.get().keys()), 'required': True},
'disk': {'type': 'integer', 'required': True},
'motion': {
'type': 'dict',
'schema': {
@ -44,10 +40,18 @@ class IpcamConfig(ConfigUnit):
}
}
},
'rtsp_tcp': {'type': 'boolean'}
}
}
},
'areas': {
'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},
@ -69,6 +73,15 @@ class IpcamConfig(ConfigUnit):
'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},
}
}
}
@ -94,6 +107,7 @@ class IpcamConfig(ConfigUnit):
}
}
# FIXME
def get_all_cam_names(self,
filter_by_server: Optional[str] = None,
filter_by_disk: Optional[int] = None) -> list[int]:
@ -106,25 +120,22 @@ class IpcamConfig(ConfigUnit):
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_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['cams'][cam]['server'], self['cams'][cam]['disk']
# def get_cam_server_and_disk(self, cam: int) -> tuple[str, int]:
# return self['cams'][cam]['server'], self['cams'][cam]['disk']
def get_camera_container(self, cam: int) -> VideoContainerType:
return VideoContainerType(self['cams'][cam]['container'])
def get_camera_container(self, camera: int) -> VideoContainerType:
return self.get_camera_type(camera).get_container()
def get_camera_type(self, cam: int) -> CameraType:
return CameraType(self['cams'][cam]['type'])
def get_camera_type(self, camera: int) -> CameraType:
return CameraType(self['cams'][camera]['type'])
def get_rtsp_creds(self) -> tuple[str, str]:
return self['rtsp_creds']['login'], self['rtsp_creds']['password']
def should_use_tcp_for_rtsp(self, cam: int) -> bool:
return 'rtsp_tcp' in self['cams'][cam] and self['cams'][cam]['rtsp_tcp']
def get_camera_ip(self, camera: int) -> str:
return f'192.168.5.{camera}'
return self['camera_ip_template'] % (str(camera),)

View File

@ -1,25 +1,6 @@
from enum import Enum
class CameraType(Enum):
ESP32 = 'esp32'
ALIEXPRESS_NONAME = 'ali'
HIKVISION = 'hik'
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.value == CameraType.HIKVISION:
return '/Streaming/Channels/2'
elif self.value == CameraType.ALIEXPRESS_NONAME:
return '/?stream=1.sdp'
else:
raise ValueError(f'unsupported camera type {self.value}')
class VideoContainerType(Enum):
MP4 = 'mp4'
MOV = 'mov'
@ -30,6 +11,37 @@ class VideoCodecType(Enum):
H265 = 'h265'
class CameraType(Enum):
ESP32 = 'esp32'
ALIEXPRESS_NONAME = 'ali'
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.value in (CameraType.HIKVISION_264, CameraType.HIKVISION_265):
return '/Streaming/Channels/2'
elif self.value == CameraType.ALIEXPRESS_NONAME:
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.value == CameraType.HIKVISION_264 else VideoCodecType.H265
elif channel == 2:
return VideoCodecType.H265 if self.value == CameraType.ALIEXPRESS_NONAME 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
class TimeFilterType(Enum):
FIX = 'fix'
MOTION = 'motion'

View File

@ -26,6 +26,7 @@ class LinuxBoardsConfig(ConfigUnit):
'schema': {
'mdns': {'type': 'string', 'required': True},
'board': {'type': 'string', 'required': True},
'location': {'type': 'string', 'required': True},
'network': {
'type': 'list',
'required': True,

View File

@ -41,6 +41,9 @@ class BaseConfigUnit(ABC):
self._data = {}
self._logger = logging.getLogger(self.__class__.__name__)
def __iter__(self):
return iter(self._data)
def __getitem__(self, key):
return self._data[key]
@ -75,6 +78,15 @@ class BaseConfigUnit(ABC):
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'
@ -123,10 +135,10 @@ class ConfigUnit(BaseConfigUnit):
return None
@classmethod
def _addr_schema(cls, required=False, **kwargs):
def _addr_schema(cls, required=False, only_ip=False, **kwargs):
return {
'type': 'addr',
'coerce': Addr.fromstring,
'coerce': Addr.fromstring if not only_ip else Addr.fromipstring,
'required': required,
**kwargs
}
@ -158,6 +170,7 @@ class ConfigUnit(BaseConfigUnit):
pass
v = MyValidator()
need_document = False
if rst == RootSchemaType.DICT:
normalized = v.validated({'document': self._data},
@ -165,16 +178,21 @@ class ConfigUnit(BaseConfigUnit):
'type': 'dict',
'keysrules': {'type': 'string'},
'valuesrules': schema
}})['document']
}})
need_document = True
elif rst == RootSchemaType.LIST:
v = MyValidator()
normalized = v.validated({'document': self._data}, {'document': schema})['document']
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:
@ -235,6 +253,8 @@ class TranslationUnit(BaseConfigUnit):
class Translation:
LANGUAGES = ('en', 'ru')
DEFAULT_LANGUAGE = 'ru'
_langs: dict[str, TranslationUnit]
def __init__(self, name: str):
@ -278,9 +298,7 @@ class Config:
and not isinstance(name, bool) \
and issubclass(name, AppConfigUnit) or name == AppConfigUnit:
self.app_name = name.NAME
print(self.app_config)
self.app_config = name()
print(self.app_config)
app_config = self.app_config
else:
self.app_name = name if isinstance(name, str) else None

View File

@ -1,2 +1,2 @@
from .http import serve, ok, routes, HTTPServer
from .http import serve, ok, routes, HTTPServer, HTTPMethod
from aiohttp.web import FileResponse, StreamResponse, Request, Response

View File

@ -1,8 +1,9 @@
import logging
import asyncio
from enum import Enum
from aiohttp import web
from aiohttp.web import Response
from aiohttp.web import Response, HTTPFound
from aiohttp.web_exceptions import HTTPNotFound
from ..util import stringify, format_tb, Addr
@ -20,6 +21,9 @@ async def errors_handler_middleware(request, handler):
except HTTPNotFound:
return web.json_response({'error': 'not found'}, status=404)
except HTTPFound as exc:
raise exc
except Exception as exc:
_logger.exception(exc)
data = {
@ -104,3 +108,8 @@ class HTTPServer:
def plain(self, text: str):
return Response(text=text, content_type='text/plain')
class HTTPMethod(Enum):
GET = 'GET'
POST = 'POST'

View File

@ -8,6 +8,6 @@ class InverterdConfig(ConfigUnit):
@classmethod
def schema(cls) -> Optional[dict]:
return {
'remote_addr': {'type': 'string'},
'local_addr': {'type': 'string'},
'remote_addr': cls._addr_schema(required=True),
'local_addr': cls._addr_schema(required=True),
}

View File

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

View File

@ -0,0 +1,29 @@
from ..config import ConfigUnit, Translation
from typing import Optional
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}
}
}
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

@ -0,0 +1,253 @@
import requests
import xml.etree.ElementTree as ElementTree
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}
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

@ -92,6 +92,7 @@ class MqttNodesConfig(ConfigUnit):
'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': {
@ -168,3 +169,15 @@ class MqttNodesConfig(ConfigUnit):
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

@ -10,8 +10,8 @@ MODULE_NAME = 'MqttTempHumModule'
DATA_TOPIC = 'temphum/data'
class MqttTemphumDataPayload(MqttPayload):
FORMAT = '=ddb'
class MqttTemphumLegacyDataPayload(MqttPayload):
FORMAT = '=dd'
UNPACKER = {
'temp': two_digits_precision,
'rh': two_digits_precision
@ -19,39 +19,26 @@ class MqttTemphumDataPayload(MqttPayload):
temp: float
rh: float
class MqttTemphumDataPayload(MqttTemphumLegacyDataPayload):
FORMAT = '=ddb'
error: int
# class MqttTempHumNodes(HashableEnum):
# KBN_SH_HALL = auto()
# KBN_SH_BATHROOM = auto()
# KBN_SH_LIVINGROOM = auto()
# KBN_SH_BEDROOM = auto()
#
# KBN_BH_2FL = auto()
# KBN_BH_2FL_STREET = auto()
# KBN_BH_1FL_LIVINGROOM = auto()
# KBN_BH_1FL_BEDROOM = auto()
# KBN_BH_1FL_BATHROOM = auto()
#
# KBN_NH_1FL_INV = auto()
# KBN_NH_1FL_CENTER = auto()
# KBN_NH_1LF_KT = auto()
# KBN_NH_1FL_DS = auto()
# KBN_NH_1FS_EZ = auto()
#
# SPB_FLAT120_CABINET = auto()
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)
@ -69,7 +56,7 @@ class MqttTempHumModule(MqttModule):
rh = self._sensor.humidity()
except:
error = 1
pld = MqttTemphumDataPayload(temp=temp, rh=rh, error=error)
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,
@ -77,6 +64,10 @@ class MqttTempHumModule(MqttModule):
topic: str,
payload: bytes) -> Optional[MqttPayload]:
if topic == DATA_TOPIC:
message = MqttTemphumDataPayload.unpack(payload)
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

@ -9,6 +9,8 @@ import logging
import string
import random
import re
import os
import ipaddress
from enum import Enum
from datetime import datetime
@ -36,6 +38,14 @@ def validate_ipv4_or_hostname(address: str, raise_exception: bool = False) -> bo
return False
def validate_ipv4(address: str) -> bool:
try:
ipaddress.IPv6Address(address)
return True
except ipaddress.AddressValueError:
return False
def validate_mac_address(mac_address: str) -> bool:
mac_pattern = r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$'
if re.match(mac_pattern, mac_address):
@ -52,8 +62,9 @@ class Addr:
self.host = host
self.port = port
@staticmethod
def fromstring(addr: str) -> Addr:
@classmethod
def fromstring(cls, addr: str, port_required=True) -> Addr:
if port_required:
colons = addr.count(':')
if colons != 1:
raise ValueError('invalid host:port format')
@ -63,6 +74,9 @@ class Addr:
port = None
else:
host, port = addr.split(':')
else:
port = None
host = addr
validate_ipv4_or_hostname(host, raise_exception=True)
@ -73,12 +87,19 @@ class Addr:
return Addr(host, port)
@classmethod
def fromipstring(cls, addr: str) -> Addr:
return cls.fromstring(addr, port_required=False)
def __str__(self):
buf = self.host
if self.port is not None:
buf += ':'+str(self.port)
return buf
def __repr__(self):
return self.__str__()
def __iter__(self):
yield self.host
yield self.port
@ -243,6 +264,24 @@ def filesize_fmt(num, suffix="B") -> str:
return f"{num:.1f} Yi{suffix}"
def seconds_to_human_readable_string(seconds: int) -> str:
days, remainder = divmod(seconds, 86400)
hours, remainder = divmod(remainder, 3600)
minutes, seconds = divmod(remainder, 60)
parts = []
if days > 0:
parts.append(f"{int(days)} day{'s' if days > 1 else ''}")
if hours > 0:
parts.append(f"{int(hours)} hour{'s' if hours > 1 else ''}")
if minutes > 0:
parts.append(f"{int(minutes)} minute{'s' if minutes > 1 else ''}")
if seconds > 0:
parts.append(f"{int(seconds)} second{'s' if seconds > 1 else ''}")
return ' '.join(parts)
class HashableEnum(Enum):
def hash(self) -> int:
return adler32(self.name.encode())
@ -253,3 +292,9 @@ def next_tick_gen(freq):
while True:
t += freq
yield max(t - time.time(), 0)
def homekit_path(*args) -> str:
return os.path.realpath(
os.path.join(os.path.dirname(__file__), '..', '..', '..', *args)
)

View File

@ -1,310 +0,0 @@
<?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

@ -1,18 +0,0 @@
<?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

@ -1,69 +0,0 @@
<?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

@ -1,104 +0,0 @@
<?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;
if (isset($_GET['alt']) && $_GET['alt'] == 1)
$config['inverterd_host'] = '192.168.5.223';
$inv = new InverterdClient($config['inverterd_host'], $config['inverterd_port']);
$inv->setFormat('json');
return $inv;
}
}

View File

@ -3,17 +3,6 @@
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;
@ -30,29 +19,6 @@ class MiscHandler extends RequestHandler
$this->tpl->render_page('sensors.twig');
}
public function GET_pump_page() {
global $config;
if (isset($_GET['alt']) && $_GET['alt'] == 1)
$config['pump_host'] = '192.168.5.223';
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;
@ -160,12 +126,4 @@ class MiscHandler extends RequestHandler
}
}
public function GET_debug() {
print_r($_SERVER);
}
public function GET_phpinfo() {
phpinfo();
}
}

View File

@ -7,71 +7,6 @@ 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;
@ -160,111 +95,6 @@ class ModemHandler extends RequestHandler
$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 getCurrentUpstream() {
global $config;

View File

@ -1,15 +0,0 @@
var Inverter = {
poll: function () {
setInterval(this._tick, 1000);
},
_tick: function() {
ajax.get('/inverter/status.ajax')
.then(({response}) => {
if (response) {
var el = document.getElementById('inverter_status');
el.innerHTML = response.html;
}
});
}
};

View File

@ -1,29 +0,0 @@
var ModemStatus = {
_modems: [],
init: function(modems) {
for (var i = 0; i < modems.length; i++) {
var modem = modems[i];
this._modems.push(new ModemStatusUpdater(modem));
}
}
};
function ModemStatusUpdater(id) {
this.id = id;
this.elem = ge('modem_data_'+id);
this.fetch();
}
extend(ModemStatusUpdater.prototype, {
fetch: function() {
ajax.get('/modem/get.ajax', {
id: this.id
}).then(({response}) => {
var {html} = response;
this.elem.innerHTML = html;
// TODO enqueue rerender
});
},
});

View File

@ -4,11 +4,6 @@ require_once __DIR__.'/../init.php';
$router = new router;
// modem
$router->add('modem/', 'Modem status_page');
$router->add('modem/verbose/', 'Modem verbose_page');
$router->add('modem/get.ajax', 'Modem status_get_ajax');
$router->add('routing/', 'Modem routing_smallhome_page');
$router->add('routing/switch-small-home/', 'Modem routing_smallhome_switch');
$router->add('routing/{ipsets,dhcp}/', 'Modem routing_${1}_page');
@ -18,15 +13,11 @@ $router->add('sms/', 'Modem sms');
// $router->add('modem/set.ajax', 'Modem ctl_set_ajax');
// inverter
$router->add('inverter/', 'Inverter status_page');
$router->add('inverter/set-osp/', 'Inverter set_osp');
$router->add('inverter/status.ajax', 'Inverter status_ajax');
// misc
$router->add('/', 'Misc main');
$router->add('sensors/', 'Misc sensors_page');
$router->add('pump/', 'Misc pump_page');
$router->add('phpinfo/', 'Misc phpinfo');
$router->add('cams/', 'Misc cams');
$router->add('cams/([\d,]+)/', 'Misc cams id=$(1)');
$router->add('cams/stat/', 'Misc cams_stat');

View File

@ -1,20 +0,0 @@
{% include 'bc.twig' with {
history: [
{text: "Инвертор" }
]
} %}
<h6 class="text-primary">Статус</h6>
<div id="inverter_status">
{{ html|raw }}
</div>
<div class="pt-3">
<a href="/inverter/set-osp/?value={{ rated.output_source_priority == 'Solar-Battery-Utility' ? 'sub' : 'sbu' }}">
<button type="button" class="btn btn-primary">Переключить на <b>{{ rated.output_source_priority == 'Solar-Battery-Utility' ? 'Solar-Utility-Battery' : 'Solar-Battery-Utility' }}</b></button>
</a>
</div>
{% js %}
Inverter.poll();
{% endjs %}

View File

@ -1,14 +0,0 @@
{% if not loading %}
<span class="text-secondary">Сигнал:</span> {% include 'signal_level.twig' with {'level': modem_data.level} %}<br>
<span class="text-secondary">Тип сети:</span> <b>{{ modem_data.type }}</b><br>
<span class="text-secondary">RSSI:</span> {{ modem_data.rssi }}<br/>
{% if modem_data.sinr %}
<span class="text-secondary">SINR:</span> {{ modem_data.sinr }}<br/>
{% endif %}
<span class="text-secondary">Время соединения:</span> {{ modem_data.connected_time }}<br>
<span class="text-secondary">Принято/передано:</span> {{ modem_data.downloaded }} / {{ modem_data.uploaded }}
<br>
<a href="/modem/verbose/?modem={{ modem }}">Подробная информация</a>
{% else %}
{% include 'spinner.twig' %}
{% endif %}

View File

@ -1,19 +0,0 @@
{% include 'bc.twig' with {
history: [
{text: "Модемы" }
]
} %}
{% for modem_key, modem in modems %}
<h6 class="text-primary{% if not loop.first %} mt-4{% endif %}">{{ modem.label }}</h6>
<div id="modem_data_{{ modem_key }}">
{% include 'modem_data.twig' with {
loading: true,
modem: modem_key
} %}
</div>
{% endfor %}
{% js %}
ModemStatus.init({{ js_modems|json_encode|raw }});
{% endjs %}

View File

@ -1,15 +0,0 @@
{% include 'bc.twig' with {
history: [
{link: '/modem/', text: "Модемы" },
{text: modem_name}
]
} %}
{% for item in data %}
{% set item_name = item[0] %}
{% set item_data = item[1] %}
<h6 class="text-primary mt-4">{{ item_name }}</h6>
{% for k, v in item_data %}
{{ k }} = {{ v }}<br>
{% endfor %}
{% endfor %}

View File

@ -1,14 +0,0 @@
<div class="sk-fading-circle">
<div class="sk-circle1 sk-circle"></div>
<div class="sk-circle2 sk-circle"></div>
<div class="sk-circle3 sk-circle"></div>
<div class="sk-circle4 sk-circle"></div>
<div class="sk-circle5 sk-circle"></div>
<div class="sk-circle6 sk-circle"></div>
<div class="sk-circle7 sk-circle"></div>
<div class="sk-circle8 sk-circle"></div>
<div class="sk-circle9 sk-circle"></div>
<div class="sk-circle10 sk-circle"></div>
<div class="sk-circle11 sk-circle"></div>
<div class="sk-circle12 sk-circle"></div>
</div>

View File

@ -6,7 +6,7 @@ Werkzeug==2.3.6
uwsgi~=2.0.20
python-telegram-bot==20.3
requests==2.31.0
aiohttp~=3.8.1
aiohttp~=3.9.1
pytz==2023.3
PyYAML~=6.0
apscheduler==3.10.1
@ -14,7 +14,11 @@ psutil~=5.9.1
aioshutil~=1.1
scikit-image==0.21.0
cerberus~=1.3.4
phonenumbers~=8.13.28
# following can be installed from debian repositories
# matplotlib~=3.5.0
Pillow==9.5.0
jinja2~=3.1.2
aiohttp-jinja2~=1.5.1

2
tasks/df_h.sh Normal file
View File

@ -0,0 +1,2 @@
#!/bin/sh
df -h

9
test/test_modems.py Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env python3
import __py_include
from homekit.modem import E3372, ModemsConfig
if __name__ == '__main__':
mc = ModemsConfig()
modem = mc.get('mts-azov')
cl = E3372(modem['ip'], legacy_token_auth=modem['legacy_auth'])

View File

@ -14,7 +14,7 @@
}
/** spinner.twig **/
/** spinner.j2 **/
.sk-fading-circle {
margin-top: 10px;

View File

@ -317,3 +317,52 @@ window.Cameras = {
},
};
})();
class ModemStatusUpdater {
constructor(id) {
this.id = id;
this.elem = ge('modem_data_'+id);
this.fetch()
}
fetch() {
ajax.get('/modems/info.ajx', {
id: this.id
}).then(({response}) => {
const {html} = response;
this.elem.innerHTML = html;
// TODO enqueue rerender
});
}
}
var ModemStatus = {
_modems: [],
init: function(modems) {
for (var i = 0; i < modems.length; i++) {
var modem = modems[i];
this._modems.push(new ModemStatusUpdater(modem));
}
}
};
var Inverter = {
poll: function () {
setInterval(this._tick, 1000);
},
_tick: function() {
ajax.get('/inverter.ajx')
.then(({response}) => {
if (response) {
var el = document.getElementById('inverter_status');
el.innerHTML = response.html;
}
});
}
};

44
web/kbn_templates/base.j2 Normal file
View File

@ -0,0 +1,44 @@
{% macro breadcrumbs(history) %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="main.cgi">Главная</a></li>
{% for item in history %}
<li class="breadcrumb-item"{% if loop.last %} aria-current="page"{% endif %}>
{% if item.link %}<a href="{{ item.link }}">{% endif %}
{% if item.html %}
{% raw %}{{ item.html }}{% endraw %}
{% else %}
{{ item.text }}
{% endif %}
{% if item.link %}</a>{% endif %}
</li>
{% endfor %}
</ol>
</nav>
{% endmacro %}
<!doctype html>
<html>
<head>
<title>{{ title }}</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">
<script>
window.onerror = function(error) {
window.console && console.error(error);
}
</script>
{{ head_static | safe }}
</head>
<body>
<div class="container py-3">
{% block content %}{% endblock %}
<script>
{% block js %}{% endblock %}
</script>
</div>
</body>
</html>

View File

@ -0,0 +1,39 @@
{% extends "base.j2" %}
{% block content %}
<div class="container py-4">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item active" aria-current="page">Главная</li>
</ol>
</nav>
<!-- {% if auth_user %}-->
<!-- <div class="mb-4 alert alert-secondary">-->
<!-- Вы авторизованы как <b>{{ auth_user.username }}</b>. <a href="/deauth/">Выйти</a>-->
<!-- </div>-->
<!-- {% endif %}-->
<h6>Интернет</h6>
<ul class="list-group list-group-flush">
<li class="list-group-item"><a href="/modems.cgi">Модемы</a></li>
<li class="list-group-item"><a href="/routing.cgi">Маршрутизация</a></li>
<li class="list-group-item"><a href="/sms.cgi">SMS-сообщения</a></li>
</ul>
<h6 class="mt-4">Другое</h6>
<ul class="list-group list-group-flush">
<li class="list-group-item"><a href="/inverter.cgi">Инвертор</a> (<a href="{{ inverter_grafana_url }}">Grafana</a>)</li>
<li class="list-group-item"><a href="/pump.cgi">Насос</a></li>
<li class="list-group-item"><a href="/sensors.cgi">Датчики</a> (<a href="{{ sensors_grafana_url }}">Grafana</a>)</li>
</ul>
<h6 class="mt-4"><a href="/cams/"><b>Все камеры</b></a> (<a href="/cams/?high=1">HQ</a>)</h6>
<ul class="list-group list-group-flush">
{% for id, name in cameras %}
<li class="list-group-item"><a href="/cams/{{ id }}/">{{ name }}</a> (<a href="/cams/{{ id }}/?high=1">HQ</a>)</li>
{% endfor %}
<li class="list-group-item"><a href="/cams/stat/">Статистика</a></li>
</ul>
</div>
{% endblock %}

View File

@ -0,0 +1,20 @@
{% extends "base.j2" %}
{% block content %}
{{ breadcrumbs([{'text': 'Инвертор'}]) }}
<h6 class="text-primary">Статус</h6>
<div id="inverter_status">
{{ html|safe }}
</div>
<div class="pt-3">
<a href="/inverter.cgi?do=set-osp&amp;value={{ 'sub' if rated.output_source_priority == 'Solar-Battery-Utility' else 'sbu' }}">
<button type="button" class="btn btn-primary">Переключить на <b>{{ 'Solar-Utility-Battery' if rated.output_source_priority == 'Solar-Battery-Utility' else 'Solar-Battery-Utility' }}</b></button>
</a>
</div>
{% endblock %}
{% block js %}
Inverter.poll();
{% endblock %}

View File

@ -0,0 +1,14 @@
<div class="sk-fading-circle">
<div class="sk-circle1 sk-circle"></div>
<div class="sk-circle2 sk-circle"></div>
<div class="sk-circle3 sk-circle"></div>
<div class="sk-circle4 sk-circle"></div>
<div class="sk-circle5 sk-circle"></div>
<div class="sk-circle6 sk-circle"></div>
<div class="sk-circle7 sk-circle"></div>
<div class="sk-circle8 sk-circle"></div>
<div class="sk-circle9 sk-circle"></div>
<div class="sk-circle10 sk-circle"></div>
<div class="sk-circle11 sk-circle"></div>
<div class="sk-circle12 sk-circle"></div>
</div>

View File

@ -0,0 +1,13 @@
{% with level=modem_data.level %}
<span class="text-secondary">Сигнал:</span> {% include 'signal_level.j2' %}<br>
{% endwith %}
<span class="text-secondary">Тип сети:</span> <b>{{ modem_data.type }}</b><br>
<span class="text-secondary">RSSI:</span> {{ modem_data.rssi }}<br/>
{% if modem_data.sinr %}
<span class="text-secondary">SINR:</span> {{ modem_data.sinr }}<br/>
{% endif %}
<span class="text-secondary">Время соединения:</span> {{ modem_data.connected_time }}<br>
<span class="text-secondary">Принято/передано:</span> {{ modem_data.downloaded }} / {{ modem_data.uploaded }}
<br>
<a href="/modems/verbose.cgi?id={{ modem }}">Подробная информация</a>

View File

@ -0,0 +1,18 @@
{% extends "base.j2" %}
{% block content %}
{{ breadcrumbs([
{'link': '/modems.cgi', 'text': "Модемы"},
{'text': modem_name}
]) }}
{% for item in data %}
{% set item_name = item[0] %}
{% set item_data = item[1] %}
<h6 class="text-primary mt-4">{{ item_name }}</h6>
{% for k, v in item_data.items() %}
{{ k }} = {{ v }}<br>
{% endfor %}
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,16 @@
{% extends "base.j2" %}
{% block content %}
{{ breadcrumbs([{'text': 'Модемы'}]) }}
{% for modem in modems %}
<h6 class="text-primary{% if not loop.first %} mt-4{% endif %}">{{ modems.getfullname(modem) }}</h6>
<div id="modem_data_{{ modem }}">
{% include "loading.j2" %}
</div>
{% endfor %}
{% endblock %}
{% block js %}
ModemStatus.init({{ modems.getkeys()|tojson }});
{% endblock %}

View File

@ -1,11 +1,10 @@
{% include 'bc.twig' with {
history: [
{text: "Насос" }
]
} %}
{% extends "base.j2" %}
<form action="/pump/" method="get">
<input type="hidden" name="set" value="{{ status == 'on' ? 'off' : 'on' }}" />
{% block content %}
{{ breadcrumbs([{'text': 'Насос'}]) }}
<form action="/pump.cgi" method="get">
<input type="hidden" name="set" value="{{ 'off' if status == 'on' else 'on' }}" />
Сейчас насос
{% if status == 'on' %}
<span class="text-success"><b>включен</b></span>.<br><br>
@ -15,3 +14,4 @@
<button type="submit" class="btn btn-primary">Включить</button>
{% endif %}
</form>
{% endblock %}

View File

@ -1,5 +1,5 @@
<div class="signal_level">
{% for i in 0..4 %}
{% for i in range(5) %}
<div{% if i < level %} class="yes"{% endif %}></div>
{% endfor %}
</div>

View File

@ -1,14 +1,13 @@
{% include 'bc.twig' with {
history: [
{text: "SMS-сообщения" }
]
} %}
{% extends "base.j2" %}
{% block content %}
{{ breadcrumbs([{'text': 'SMS-сообщения'}]) }}
<nav>
<div class="nav nav-tabs" id="nav-tab">
{% for modem in modems_list %}
{% if selected_modem != modem %}<a href="/sms/?modem={{ modem }}" class="text-decoration-none">{% endif %}
<button class="nav-link{% if modem == selected_modem %} active{% endif %}" type="button">{{ modems[modem].short_label }}</button>
{% for modem in modems.keys() %}
{% if selected_modem != modem %}<a href="/sms.cgi?id={{ modem }}" class="text-decoration-none">{% endif %}
<button class="nav-link{% if modem == selected_modem %} active{% endif %}" type="button">{{ modems.getshortname(modem) }}</button>
{% if selected_modem != modem %}</a>{% endif %}
{% endfor %}
</div>
@ -20,14 +19,14 @@
<div class="alert alert-success" role="alert">
Сообщение отправлено.
</div>
{% elseif error %}
{% elif error %}
<div class="alert alert-danger" role="alert">
{{ error }}
</div>
{% endif %}
<div>
<form method="post" action="/sms/">
<form method="post" action="/sms.cgi">
<input type="hidden" name="modem" value="{{ selected_modem }}">
<div class="form-floating mb-3">
<input type="text" name="phone" class="form-control" id="inputPhone" placeholder="+7911xxxyyzz">
@ -46,17 +45,19 @@
<h6 class="text-primary mt-4">
Последние
{% if not is_outbox %}
<b>входящие</b> <span class="text-black-50">|</span> <a href="/sms/?modem={{ selected_modem }}&amp;outbox=1">исходящие</a>
<b>входящие</b> <span class="text-black-50">|</span> <a href="/sms.cgi?id={{ selected_modem }}&amp;outbox=1">исходящие</a>
{% else %}
<a href="/sms/?modem={{ selected_modem }}">входящие</a> <span class="text-black-50">|</span> <b>исходящие</b>
<a href="/sms.cgi?id={{ selected_modem }}">входящие</a> <span class="text-black-50">|</span> <b>исходящие</b>
{% endif %}
</h6>
{% for m in messages %}
<div class="mt-3">
<b>{{ m.phone }}</b> <span class="text-secondary">({{ m.date }})</span><br/>
{{ m.content }}
<b>{{ m.Phone }}</b> <span class="text-secondary">({{ m.Date }})</span><br/>
{{ m.Content }}
</div>
{% else %}
<span class="text-secondary">Сообщений нет.</span>
{% endfor %}
{% endblock %}