This commit is contained in:
Evgeny Zinoviev 2024-02-18 01:35:57 +03:00
parent 70b4a4f044
commit 4215537047
8 changed files with 176 additions and 53 deletions

View File

@ -6,13 +6,15 @@ import json
import re import re
import inverterd import inverterd
import phonenumbers import phonenumbers
import time
import __py_include import __py_include
from io import StringIO from io import StringIO
from aiohttp.web import HTTPFound from aiohttp.web import HTTPFound
from typing import Optional, Union from typing import Optional, Union
from homekit.config import config, AppConfigUnit from homekit.config import config, AppConfigUnit, is_development_mode, Translation
from homekit.util import homekit_path, filesize_fmt, seconds_to_human_readable_string from homekit.camera import IpcamConfig
from homekit.util import homekit_path, filesize_fmt, seconds_to_human_readable_string, json_serial
from homekit.modem import E3372, ModemsConfig, MacroNetWorkType from homekit.modem import E3372, ModemsConfig, MacroNetWorkType
from homekit.inverter.config import InverterdConfig from homekit.inverter.config import InverterdConfig
from homekit.relay.sunxi_h3_client import RelayClient from homekit.relay.sunxi_h3_client import RelayClient
@ -43,12 +45,16 @@ STATIC_FILES = [
def get_js_link(file, version) -> str: def get_js_link(file, version) -> str:
if is_development_mode():
version = int(time.time())
if version: if version:
file += f'?version={version}' file += f'?version={version}'
return f'<script src="{config.app_config["assets_public_path"]}/{file}" type="text/javascript"></script>' return f'<script src="{config.app_config["assets_public_path"]}/{file}" type="text/javascript"></script>'
def get_css_link(file, version) -> str: def get_css_link(file, version) -> str:
if is_development_mode():
version = int(time.time())
if version: if version:
file += f'?version={version}' file += f'?version={version}'
return f'<link rel="stylesheet" type="text/css" href="{config.app_config["assets_public_path"]}/{file}">' return f'<link rel="stylesheet" type="text/css" href="{config.app_config["assets_public_path"]}/{file}">'
@ -171,20 +177,22 @@ def get_inverter_data() -> tuple:
class WebSite(http.HTTPServer): class WebSite(http.HTTPServer):
_modems_config: ModemsConfig
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._modems_config = ModemsConfig()
aiohttp_jinja2.setup( aiohttp_jinja2.setup(
self.app, self.app,
loader=jinja2.FileSystemLoader(homekit_path('web', 'kbn_templates')), loader=jinja2.FileSystemLoader(homekit_path('web', 'kbn_templates')),
autoescape=jinja2.select_autoescape(['html', 'xml']), autoescape=jinja2.select_autoescape(['html', 'xml']),
) )
env = aiohttp_jinja2.get_env(self.app) env = aiohttp_jinja2.get_env(self.app)
env.filters['tojson'] = lambda obj: json.dumps(obj, separators=(',', ':'))
def filter_lang(key, unit):
strings = Translation(unit)
return strings.get()[key]
env.filters['tojson'] = lambda obj: json.dumps(obj, separators=(',', ':'), default=json_serial)
env.filters['lang'] = filter_lang
self.app.router.add_static('/assets/', path=homekit_path('web', 'kbn_assets')) self.app.router.add_static('/assets/', path=homekit_path('web', 'kbn_assets'))
@ -200,6 +208,8 @@ class WebSite(http.HTTPServer):
self.get('/sms.cgi', self.sms) self.get('/sms.cgi', self.sms)
self.post('/sms.cgi', self.sms_post) self.post('/sms.cgi', self.sms_post)
self.get('/cams.cgi', self.cams)
async def render_page(self, async def render_page(self,
req: http.Request, req: http.Request,
template_name: str, template_name: str,
@ -220,6 +230,11 @@ class WebSite(http.HTTPServer):
ctx = {} ctx = {}
for k in 'inverter', 'sensors': for k in 'inverter', 'sensors':
ctx[f'{k}_grafana_url'] = config.app_config[f'{k}_grafana_url'] ctx[f'{k}_grafana_url'] = config.app_config[f'{k}_grafana_url']
cc = IpcamConfig()
ctx['camzones'] = cc['zones'].keys()
ctx['allcams'] = cc.get_all_cam_names()
return await self.render_page(req, 'index', return await self.render_page(req, 'index',
title="Home web site", title="Home web site",
context=ctx) context=ctx)
@ -227,14 +242,15 @@ class WebSite(http.HTTPServer):
async def modems(self, req: http.Request): async def modems(self, req: http.Request):
return await self.render_page(req, 'modems', return await self.render_page(req, 'modems',
title='Состояние модемов', title='Состояние модемов',
context=dict(modems=self._modems_config)) context=dict(modems=ModemsConfig()))
async def modems_ajx(self, req: http.Request): async def modems_ajx(self, req: http.Request):
mc = ModemsConfig()
modem = req.query.get('id', None) modem = req.query.get('id', None)
if modem not in self._modems_config.keys(): if modem not in mc.keys():
raise ValueError('invalid modem id') raise ValueError('invalid modem id')
modem_cfg = self._modems_config.get(modem) modem_cfg = mc.get(modem)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
modem_data = await loop.run_in_executor(None, lambda: get_modem_data(modem_cfg)) modem_data = await loop.run_in_executor(None, lambda: get_modem_data(modem_cfg))
@ -247,10 +263,10 @@ class WebSite(http.HTTPServer):
async def modems_verbose(self, req: http.Request): async def modems_verbose(self, req: http.Request):
modem = req.query.get('id', None) modem = req.query.get('id', None)
if modem not in self._modems_config.keys(): if modem not in ModemsConfig().keys():
raise ValueError('invalid modem id') raise ValueError('invalid modem id')
modem_cfg = self._modems_config.get(modem) modem_cfg = ModemsConfig().get(modem)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
signal, status, traffic, device, dialup_conn = await loop.run_in_executor(None, lambda: get_modem_data(modem_cfg, True)) signal, status, traffic, device, dialup_conn = await loop.run_in_executor(None, lambda: get_modem_data(modem_cfg, True))
data = [ data = [
@ -261,23 +277,23 @@ class WebSite(http.HTTPServer):
['Dialup connection', dialup_conn] ['Dialup connection', dialup_conn]
] ]
modem_name = self._modems_config.getfullname(modem) modem_name = ModemsConfig().getfullname(modem)
return await self.render_page(req, 'modem_verbose', return await self.render_page(req, 'modem_verbose',
title=f'Подробная информация о модеме "{modem_name}"', title=f'Подробная информация о модеме "{modem_name}"',
context=dict(data=data, modem_name=modem_name)) context=dict(data=data, modem_name=modem_name))
async def sms(self, req: http.Request): async def sms(self, req: http.Request):
modem = req.query.get('id', list(self._modems_config.keys())[0]) modem = req.query.get('id', list(ModemsConfig().keys())[0])
is_outbox = int(req.query.get('outbox', 0)) == 1 is_outbox = int(req.query.get('outbox', 0)) == 1
error = req.query.get('error', None) error = req.query.get('error', None)
sent = int(req.query.get('sent', 0)) == 1 sent = int(req.query.get('sent', 0)) == 1
cl = get_modem_client(self._modems_config[modem]) cl = get_modem_client(ModemsConfig()[modem])
messages = cl.sms_list(1, 20, is_outbox) messages = cl.sms_list(1, 20, is_outbox)
return await self.render_page(req, 'sms', return await self.render_page(req, 'sms',
title=f"SMS-сообщения ({'исходящие' if is_outbox else 'входящие'}, {modem})", title=f"SMS-сообщения ({'исходящие' if is_outbox else 'входящие'}, {modem})",
context=dict( context=dict(
modems=self._modems_config, modems=ModemsConfig(),
selected_modem=modem, selected_modem=modem,
is_outbox=is_outbox, is_outbox=is_outbox,
error=error, error=error,
@ -286,7 +302,7 @@ class WebSite(http.HTTPServer):
)) ))
async def sms_post(self, req: http.Request): async def sms_post(self, req: http.Request):
modem = req.query.get('id', list(self._modems_config.keys())[0]) modem = req.query.get('id', list(ModemsConfig().keys())[0])
is_outbox = int(req.query.get('outbox', 0)) == 1 is_outbox = int(req.query.get('outbox', 0)) == 1
fd = await req.post() fd = await req.post()
@ -305,7 +321,7 @@ class WebSite(http.HTTPServer):
raise HTTPFound(f'{return_url}&error=Неверный+номер') raise HTTPFound(f'{return_url}&error=Неверный+номер')
phone = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164) phone = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
cl = get_modem_client(self._modems_config[modem]) cl = get_modem_client(ModemsConfig()[modem])
cl.sms_send(phone, text) cl.sms_send(phone, text)
raise HTTPFound(return_url) raise HTTPFound(return_url)
@ -346,6 +362,9 @@ class WebSite(http.HTTPServer):
title='Насос', title='Насос',
context=dict(status=status)) context=dict(status=status))
async def cams(self, req: http.Request):
pass
if __name__ == '__main__': if __name__ == '__main__':
config.load_app(WebKbnConfig) config.load_app(WebKbnConfig)

View File

@ -44,7 +44,7 @@ class IpcamConfig(ConfigUnit):
} }
} }
}, },
'areas': { 'zones': {
'type': 'dict', 'type': 'dict',
'keysrules': {'type': 'string'}, 'keysrules': {'type': 'string'},
'valuesrules': { 'valuesrules': {
@ -114,11 +114,14 @@ class IpcamConfig(ConfigUnit):
# FIXME # FIXME
def get_all_cam_names(self, def get_all_cam_names(self,
filter_by_server: Optional[str] = None, filter_by_server: Optional[str] = None,
filter_by_disk: Optional[int] = None) -> list[int]: filter_by_disk: Optional[int] = None,
only_enabled=True) -> list[int]:
cams = [] cams = []
if filter_by_server is not None and filter_by_server not in _lbc: if filter_by_server is not None and filter_by_server not in _lbc:
raise ValueError(f'invalid filter_by_server: {filter_by_server} not found in {_lbc.__class__.__name__}') raise ValueError(f'invalid filter_by_server: {filter_by_server} not found in {_lbc.__class__.__name__}')
for cam, params in self['cameras'].items(): for cam, params in self['cameras'].items():
if only_enabled and not self.is_camera_enabled(cam):
continue
if filter_by_server is None or params['server'] == filter_by_server: if filter_by_server is None or params['server'] == filter_by_server:
if filter_by_disk is None or params['disk'] == filter_by_disk: if filter_by_disk is None or params['disk'] == filter_by_disk:
cams.append(int(cam)) cams.append(int(cam))

View File

@ -266,14 +266,26 @@ class TranslationUnit(BaseConfigUnit):
pass pass
TranslationInstances = {}
class Translation: class Translation:
LANGUAGES = ('en', 'ru') LANGUAGES = ('en', 'ru')
DEFAULT_LANGUAGE = 'ru' DEFAULT_LANGUAGE = 'ru'
_langs: dict[str, TranslationUnit] _langs: dict[str, TranslationUnit]
# def __init_subclass__(cls, **kwargs):
# super().__init_subclass__(**kwargs)
# cls._instance = None
def __new__(cls, *args, **kwargs):
unit = args[0]
if unit not in TranslationInstances:
TranslationInstances[unit] = super(Translation, cls).__new__(cls)
return TranslationInstances[unit]
def __init__(self, name: str): def __init__(self, name: str):
super().__init__()
self._langs = {} self._langs = {}
for lang in self.LANGUAGES: for lang in self.LANGUAGES:
for dirname in CONFIG_DIRECTORIES: for dirname in CONFIG_DIRECTORIES:
@ -289,7 +301,7 @@ class Translation:
if len(diff) > 0: if len(diff) > 0:
raise RuntimeError(f'{name}: translation units have difference in keys: ' + ', '.join(diff)) raise RuntimeError(f'{name}: translation units have difference in keys: ' + ', '.join(diff))
def get(self, lang: str) -> TranslationUnit: def get(self, lang: str = DEFAULT_LANGUAGE) -> TranslationUnit:
return self._langs[lang] return self._langs[lang]

View File

@ -12,7 +12,7 @@ import re
import os import os
import ipaddress import ipaddress
from collections import namedtuple from collections.abc import KeysView
from enum import Enum from enum import Enum
from datetime import datetime from datetime import datetime
from typing import Optional, List from typing import Optional, List
@ -119,6 +119,8 @@ def json_serial(obj):
return obj.timestamp() return obj.timestamp()
if isinstance(obj, Enum): if isinstance(obj, Enum):
return obj.value return obj.value
if isinstance(obj, KeysView):
return list(obj)
raise TypeError("Type %s not serializable" % type(obj)) raise TypeError("Type %s not serializable" % type(obj))

View File

@ -173,3 +173,40 @@
.camfeeds.is_mobile > .video-container { .camfeeds.is_mobile > .video-container {
max-width: 100%; max-width: 100%;
} }
/* index page */
.camzones {
display: flex;
flex-wrap: wrap;
margin: 5px -5px 0;
}
.camzones::after {
content: "";
flex: 0 0 50%;
}
a.camzone {
display: block;
text-decoration: none;
color: var(--bs-dark);
flex: 0 0 calc(50% - 10px);
height: 100px;
box-sizing: border-box;
padding: 10px;
margin: 5px;
/*border: 1px solid #ccc;*/
background: #f0f2f4;
border-radius: 4px;
word-wrap: break-word;
text-overflow: ellipsis;
overflow: hidden;
position: relative;
}
.camzone_text {
position: absolute;
bottom: 5px;
left: 8px;
right: 8px;
text-overflow: ellipsis;
overflow: hidden;
}

View File

@ -1,7 +1,6 @@
(function() {
var RE_WHITESPACE = /[\t\r\n\f]/g var RE_WHITESPACE = /[\t\r\n\f]/g
window.ajax = { var ajax = {
get: function(url, data) { get: function(url, data) {
if (typeof data == 'object') { if (typeof data == 'object') {
var index = 0; var index = 0;
@ -38,35 +37,37 @@ window.ajax = {
} }
}; };
window.extend = function(a, b) { function extend(a, b) {
return Object.assign(a, b); return Object.assign(a, b);
} }
window.ge = function(id) { function ge(id) {
return document.getElementById(id); return document.getElementById(id);
} }
var ua = navigator.userAgent.toLowerCase(); (function() {
window.browserInfo = { var ua = navigator.userAgent.toLowerCase();
version: (ua.match(/.+(?:me|ox|on|rv|it|ra|ie)[\/: ]([\d.]+)/) || [0,'0'])[1], window.browserInfo = {
//opera: /opera/i.test(ua), version: (ua.match(/.+(?:me|ox|on|rv|it|ra|ie)[\/: ]([\d.]+)/) || [0,'0'])[1],
msie: (/msie/i.test(ua) && !/opera/i.test(ua)) || /trident/i.test(ua), //opera: /opera/i.test(ua),
mozilla: /firefox/i.test(ua), msie: (/msie/i.test(ua) && !/opera/i.test(ua)) || /trident/i.test(ua),
android: /android/i.test(ua), mozilla: /firefox/i.test(ua),
mac: /mac/i.test(ua), android: /android/i.test(ua),
samsungBrowser: /samsungbrowser/i.test(ua), mac: /mac/i.test(ua),
chrome: /chrome/i.test(ua), samsungBrowser: /samsungbrowser/i.test(ua),
safari: /safari/i.test(ua), chrome: /chrome/i.test(ua),
mobile: /iphone|ipod|ipad|opera mini|opera mobi|iemobile|android/i.test(ua), safari: /safari/i.test(ua),
operaMini: /opera mini/i.test(ua), mobile: /iphone|ipod|ipad|opera mini|opera mobi|iemobile|android/i.test(ua),
ios: /iphone|ipod|ipad|watchos/i.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1), operaMini: /opera mini/i.test(ua),
}; ios: /iphone|ipod|ipad|watchos/i.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1),
};
})();
window.isTouchDevice = function() { function isTouchDevice() {
return 'ontouchstart' in window || navigator.msMaxTouchPoints; return 'ontouchstart' in window || navigator.msMaxTouchPoints;
} }
window.hasClass = function(el, name) { function hasClass(el, name) {
if (!el) if (!el)
throw new Error('hasClass: invalid element') throw new Error('hasClass: invalid element')
@ -80,7 +81,7 @@ window.hasClass = function(el, name) {
} }
} }
window.addClass = function(el, name) { function addClass(el, name) {
if (!hasClass(el, name)) { if (!hasClass(el, name)) {
el.className = (el.className ? el.className + ' ' : '') + name; el.className = (el.className ? el.className + ' ' : '') + name;
return true return true
@ -88,6 +89,39 @@ window.addClass = function(el, name) {
return false return false
} }
function removeClass(el, name) {
if (!el)
throw new Error('removeClass: invalid element')
if (Array.isArray(name)) {
for (var i = 0; i < name.length; i++)
removeClass(el, name[i]);
return;
}
el.className = ((el.className || '').replace((new RegExp('(\\s|^)' + name + '(\\s|$)')), ' ')).trim()
}
function indexInit() {
var blocks = ['zones', 'all'];
for (var i = 0; i < blocks.length; i++) {
var button = ge('cam_'+blocks[i]+'_btn');
button.addEventListener('click', function(e) {
var selected = e.target.getAttribute('data-id');
for (var j = 0; j < blocks.length; j++) {
var button = ge('cam_'+blocks[j]+'_btn');
var content = ge('cam_'+blocks[j]);
if (blocks[j] === selected) {
addClass(button, 'active');
content.style.display = '';
} else {
removeClass(button, 'active');
content.style.display = 'none';
}
}
});
}
}
window.Cameras = { window.Cameras = {
hlsOptions: null, hlsOptions: null,
h265webjsOptions: null, h265webjsOptions: null,
@ -316,7 +350,6 @@ window.Cameras = {
return video.canPlayType('application/vnd.apple.mpegurl'); return video.canPlayType('application/vnd.apple.mpegurl');
}, },
}; };
})();
class ModemStatusUpdater { class ModemStatusUpdater {

View File

@ -28,12 +28,29 @@
<li class="list-group-item"><a href="/sensors.cgi">Датчики</a> (<a href="{{ sensors_grafana_url }}">Grafana</a>)</li> <li class="list-group-item"><a href="/sensors.cgi">Датчики</a> (<a href="{{ sensors_grafana_url }}">Grafana</a>)</li>
</ul> </ul>
<h6 class="mt-4"><a href="/cams/"><b>Все камеры</b></a> (<a href="/cams/?high=1">HQ</a>)</h6> <nav class="mt-4">
<ul class="list-group list-group-flush"> <div class="nav nav-tabs" id="nav-tab">
{% for id, name in cameras %} <button class="nav-link active" type="button" id="cam_zones_btn" data-id="zones">По зонам</button>
<li class="list-group-item"><a href="/cams/{{ id }}/">{{ name }}</a> (<a href="/cams/{{ id }}/?high=1">HQ</a>)</li> <button class="nav-link" type="button" id="cam_all_btn" data-id="all">Все камеры</button>
</div>
</nav>
<div class="camzones" id="cam_zones">
{% for zone in camzones %}
<a href="/cams.cgi?zone={{ zone }}" class="camzone">
<div class="camzone_text">{{ zone|lang('ipcam_zones') }}</div>
</a>
{% endfor %} {% endfor %}
<li class="list-group-item"><a href="/cams/stat/">Статистика</a></li> </div>
<ul class="list-group list-group-flush" id="cam_all" style="display: none">
{% for id in allcams %}
<li class="list-group-item"><a href="/cams.cgi?id={{ id }}">{{ id|lang('ipcam') }}</a></li>
{% endfor %}
{# <li class="list-group-item"><a href="/cams/stat/">Статистика</a></li>#}
</ul> </ul>
</div> </div>
{% endblock %} {% endblock %}
{% block js %}
indexInit();
{% endblock %}

View File

@ -12,5 +12,5 @@
{% endblock %} {% endblock %}
{% block js %} {% block js %}
ModemStatus.init({{ modems.getkeys()|tojson }}); ModemStatus.init({{ modems.keys()|tojson }});
{% endblock %} {% endblock %}