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 aiohttp.web import FileResponse, StreamResponse, Request, Response
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,17 +62,21 @@ class Addr:
self.host = host
self.port = port
@staticmethod
def fromstring(addr: str) -> Addr:
colons = addr.count(':')
if colons != 1:
raise ValueError('invalid host:port format')
@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')
if not colons:
host = addr
port = None
if not colons:
host = addr
port = None
else:
host, port = addr.split(':')
else:
host, port = addr.split(':')
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())
@ -252,4 +291,10 @@ def next_tick_gen(freq):
t = time.time()
while True:
t += freq
yield max(t - time.time(), 0)
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
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

@ -316,4 +316,53 @@ window.Cameras = {
return video.canPlayType('application/vnd.apple.mpegurl');
},
};
})();
})();
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>
@ -14,4 +13,5 @@
<span class="text-danger"><b>выключен</b></span>.<br><br>
<button type="submit" class="btn btn-primary">Включить</button>
{% endif %}
</form>
</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 %}
{% endfor %}
{% endblock %}