Merge branch 'website-python-rewrite'
This commit is contained in:
commit
b7f1d55c9b
@ -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
199
bin/ipcam_ntp_util.py
Executable 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()
|
@ -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
354
bin/web_kbn.py
Normal 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()
|
@ -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),)
|
||||
|
@ -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'
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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'
|
||||
|
@ -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),
|
||||
}
|
2
include/py/homekit/modem/__init__.py
Normal file
2
include/py/homekit/modem/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .config import ModemsConfig
|
||||
from .e3372 import E3372, MacroNetWorkType
|
29
include/py/homekit/modem/config.py
Normal file
29
include/py/homekit/modem/config.py
Normal 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']
|
253
include/py/homekit/modem/e3372.py
Normal file
253
include/py/homekit/modem/e3372.py
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
)
|
||||
|
@ -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 '?';
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
@ -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
|
||||
});
|
||||
},
|
||||
});
|
@ -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');
|
||||
|
@ -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 %}
|
@ -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 %}
|
@ -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 %}
|
@ -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 %}
|
@ -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>
|
@ -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
2
tasks/df_h.sh
Normal file
@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
df -h
|
9
test/test_modems.py
Executable file
9
test/test_modems.py
Executable 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'])
|
@ -14,7 +14,7 @@
|
||||
}
|
||||
|
||||
|
||||
/** spinner.twig **/
|
||||
/** spinner.j2 **/
|
||||
|
||||
.sk-fading-circle {
|
||||
margin-top: 10px;
|
@ -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
44
web/kbn_templates/base.j2
Normal 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>
|
39
web/kbn_templates/index.j2
Normal file
39
web/kbn_templates/index.j2
Normal 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 %}
|
20
web/kbn_templates/inverter.j2
Normal file
20
web/kbn_templates/inverter.j2
Normal 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&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 %}
|
14
web/kbn_templates/loading.j2
Normal file
14
web/kbn_templates/loading.j2
Normal 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>
|
13
web/kbn_templates/modem_data.j2
Normal file
13
web/kbn_templates/modem_data.j2
Normal 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>
|
18
web/kbn_templates/modem_verbose.j2
Normal file
18
web/kbn_templates/modem_verbose.j2
Normal 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 %}
|
16
web/kbn_templates/modems.j2
Normal file
16
web/kbn_templates/modems.j2
Normal 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 %}
|
@ -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 %}
|
@ -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>
|
@ -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 }}&outbox=1">исходящие</a>
|
||||
<b>входящие</b> <span class="text-black-50">|</span> <a href="/sms.cgi?id={{ selected_modem }}&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 %}
|
Loading…
x
Reference in New Issue
Block a user