diff options
-rw-r--r-- | bin/web_kbn.py | 55 | ||||
-rw-r--r-- | include/py/homekit/camera/config.py | 7 | ||||
-rw-r--r-- | include/py/homekit/config/config.py | 16 | ||||
-rw-r--r-- | include/py/homekit/util.py | 4 | ||||
-rw-r--r-- | web/kbn_assets/app.css | 37 | ||||
-rw-r--r-- | web/kbn_assets/app.js | 79 | ||||
-rw-r--r-- | web/kbn_templates/index.j2 | 29 | ||||
-rw-r--r-- | web/kbn_templates/modems.j2 | 2 |
8 files changed, 176 insertions, 53 deletions
diff --git a/bin/web_kbn.py b/bin/web_kbn.py index c21269b..5b36d53 100644 --- a/bin/web_kbn.py +++ b/bin/web_kbn.py @@ -6,13 +6,15 @@ import json import re import inverterd import phonenumbers +import time 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.config import config, AppConfigUnit, is_development_mode, Translation +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.inverter.config import InverterdConfig from homekit.relay.sunxi_h3_client import RelayClient @@ -43,12 +45,16 @@ STATIC_FILES = [ def get_js_link(file, version) -> str: + if is_development_mode(): + version = int(time.time()) 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 is_development_mode(): + version = int(time.time()) if version: file += f'?version={version}' 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): - _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=(',', ':')) + + 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')) @@ -200,6 +208,8 @@ class WebSite(http.HTTPServer): self.get('/sms.cgi', self.sms) self.post('/sms.cgi', self.sms_post) + self.get('/cams.cgi', self.cams) + async def render_page(self, req: http.Request, template_name: str, @@ -220,6 +230,11 @@ class WebSite(http.HTTPServer): ctx = {} for k in 'inverter', 'sensors': ctx[f'{k}_grafana_url'] = config.app_config[f'{k}_grafana_url'] + + cc = IpcamConfig() + ctx['camzones'] = cc['zones'].keys() + ctx['allcams'] = cc.get_all_cam_names() + return await self.render_page(req, 'index', title="Home web site", context=ctx) @@ -227,14 +242,15 @@ class WebSite(http.HTTPServer): async def modems(self, req: http.Request): return await self.render_page(req, 'modems', title='Состояние модемов', - context=dict(modems=self._modems_config)) + context=dict(modems=ModemsConfig())) async def modems_ajx(self, req: http.Request): + mc = ModemsConfig() 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') - modem_cfg = self._modems_config.get(modem) + modem_cfg = mc.get(modem) loop = asyncio.get_event_loop() 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): 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') - modem_cfg = self._modems_config.get(modem) + modem_cfg = ModemsConfig().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 = [ @@ -261,23 +277,23 @@ class WebSite(http.HTTPServer): ['Dialup connection', dialup_conn] ] - modem_name = self._modems_config.getfullname(modem) + modem_name = ModemsConfig().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]) + modem = req.query.get('id', list(ModemsConfig().keys())[0]) is_outbox = int(req.query.get('outbox', 0)) == 1 error = req.query.get('error', None) sent = int(req.query.get('sent', 0)) == 1 - cl = get_modem_client(self._modems_config[modem]) + cl = get_modem_client(ModemsConfig()[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, + modems=ModemsConfig(), selected_modem=modem, is_outbox=is_outbox, error=error, @@ -286,7 +302,7 @@ class WebSite(http.HTTPServer): )) 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 fd = await req.post() @@ -305,7 +321,7 @@ class WebSite(http.HTTPServer): raise HTTPFound(f'{return_url}&error=Неверный+номер') 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) raise HTTPFound(return_url) @@ -346,6 +362,9 @@ class WebSite(http.HTTPServer): title='Насос', context=dict(status=status)) + async def cams(self, req: http.Request): + pass + if __name__ == '__main__': config.load_app(WebKbnConfig) diff --git a/include/py/homekit/camera/config.py b/include/py/homekit/camera/config.py index 9685cab..bcd5d07 100644 --- a/include/py/homekit/camera/config.py +++ b/include/py/homekit/camera/config.py @@ -44,7 +44,7 @@ class IpcamConfig(ConfigUnit): } } }, - 'areas': { + 'zones': { 'type': 'dict', 'keysrules': {'type': 'string'}, 'valuesrules': { @@ -114,11 +114,14 @@ class IpcamConfig(ConfigUnit): # FIXME def get_all_cam_names(self, 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 = [] if filter_by_server is not None and filter_by_server not in _lbc: raise ValueError(f'invalid filter_by_server: {filter_by_server} not found in {_lbc.__class__.__name__}') for cam, params in self['cameras'].items(): + if only_enabled and not self.is_camera_enabled(cam): + continue if filter_by_server is None or params['server'] == filter_by_server: if filter_by_disk is None or params['disk'] == filter_by_disk: cams.append(int(cam)) diff --git a/include/py/homekit/config/config.py b/include/py/homekit/config/config.py index 40ac211..3aa0e04 100644 --- a/include/py/homekit/config/config.py +++ b/include/py/homekit/config/config.py @@ -266,14 +266,26 @@ class TranslationUnit(BaseConfigUnit): pass +TranslationInstances = {} + + class Translation: LANGUAGES = ('en', 'ru') DEFAULT_LANGUAGE = 'ru' _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): - super().__init__() self._langs = {} for lang in self.LANGUAGES: for dirname in CONFIG_DIRECTORIES: @@ -289,7 +301,7 @@ class Translation: if len(diff) > 0: 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] diff --git a/include/py/homekit/util.py b/include/py/homekit/util.py index 7732d3b..c686f29 100644 --- a/include/py/homekit/util.py +++ b/include/py/homekit/util.py @@ -12,7 +12,7 @@ import re import os import ipaddress -from collections import namedtuple +from collections.abc import KeysView from enum import Enum from datetime import datetime from typing import Optional, List @@ -119,6 +119,8 @@ def json_serial(obj): return obj.timestamp() if isinstance(obj, Enum): return obj.value + if isinstance(obj, KeysView): + return list(obj) raise TypeError("Type %s not serializable" % type(obj)) diff --git a/web/kbn_assets/app.css b/web/kbn_assets/app.css index 1a4697a..ee0aa3e 100644 --- a/web/kbn_assets/app.css +++ b/web/kbn_assets/app.css @@ -173,3 +173,40 @@ .camfeeds.is_mobile > .video-container { 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; +}
\ No newline at end of file diff --git a/web/kbn_assets/app.js b/web/kbn_assets/app.js index d575a5a..6081681 100644 --- a/web/kbn_assets/app.js +++ b/web/kbn_assets/app.js @@ -1,7 +1,6 @@ -(function() { var RE_WHITESPACE = /[\t\r\n\f]/g -window.ajax = { +var ajax = { get: function(url, data) { if (typeof data == 'object') { var index = 0; @@ -38,35 +37,37 @@ window.ajax = { } }; -window.extend = function(a, b) { +function extend(a, b) { return Object.assign(a, b); } -window.ge = function(id) { +function ge(id) { return document.getElementById(id); } -var ua = navigator.userAgent.toLowerCase(); -window.browserInfo = { - version: (ua.match(/.+(?:me|ox|on|rv|it|ra|ie)[\/: ]([\d.]+)/) || [0,'0'])[1], - //opera: /opera/i.test(ua), - msie: (/msie/i.test(ua) && !/opera/i.test(ua)) || /trident/i.test(ua), - mozilla: /firefox/i.test(ua), - android: /android/i.test(ua), - mac: /mac/i.test(ua), - samsungBrowser: /samsungbrowser/i.test(ua), - chrome: /chrome/i.test(ua), - safari: /safari/i.test(ua), - mobile: /iphone|ipod|ipad|opera mini|opera mobi|iemobile|android/i.test(ua), - operaMini: /opera mini/i.test(ua), - ios: /iphone|ipod|ipad|watchos/i.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1), -}; +(function() { + var ua = navigator.userAgent.toLowerCase(); + window.browserInfo = { + version: (ua.match(/.+(?:me|ox|on|rv|it|ra|ie)[\/: ]([\d.]+)/) || [0,'0'])[1], + //opera: /opera/i.test(ua), + msie: (/msie/i.test(ua) && !/opera/i.test(ua)) || /trident/i.test(ua), + mozilla: /firefox/i.test(ua), + android: /android/i.test(ua), + mac: /mac/i.test(ua), + samsungBrowser: /samsungbrowser/i.test(ua), + chrome: /chrome/i.test(ua), + safari: /safari/i.test(ua), + mobile: /iphone|ipod|ipad|opera mini|opera mobi|iemobile|android/i.test(ua), + 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; } -window.hasClass = function(el, name) { +function hasClass(el, name) { if (!el) 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)) { el.className = (el.className ? el.className + ' ' : '') + name; return true @@ -88,6 +89,39 @@ window.addClass = function(el, name) { 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 = { hlsOptions: null, h265webjsOptions: null, @@ -316,7 +350,6 @@ window.Cameras = { return video.canPlayType('application/vnd.apple.mpegurl'); }, }; -})(); class ModemStatusUpdater { diff --git a/web/kbn_templates/index.j2 b/web/kbn_templates/index.j2 index c356326..cdf3026 100644 --- a/web/kbn_templates/index.j2 +++ b/web/kbn_templates/index.j2 @@ -28,12 +28,29 @@ <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> + <nav class="mt-4"> + <div class="nav nav-tabs" id="nav-tab"> + <button class="nav-link active" type="button" id="cam_zones_btn" data-id="zones">По зонам</button> + <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 %} - <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> </div> -{% endblock %}
\ No newline at end of file +{% endblock %} + +{% block js %} +indexInit(); +{% endblock %} diff --git a/web/kbn_templates/modems.j2 b/web/kbn_templates/modems.j2 index 06339f8..9defec3 100644 --- a/web/kbn_templates/modems.j2 +++ b/web/kbn_templates/modems.j2 @@ -12,5 +12,5 @@ {% endblock %} {% block js %} -ModemStatus.init({{ modems.getkeys()|tojson }}); +ModemStatus.init({{ modems.keys()|tojson }}); {% endblock %} |