diff options
Diffstat (limited to 'bin/web_kbn.py')
-rw-r--r-- | bin/web_kbn.py | 580 |
1 files changed, 348 insertions, 232 deletions
diff --git a/bin/web_kbn.py b/bin/web_kbn.py index bf17046..a0f12a6 100644 --- a/bin/web_kbn.py +++ b/bin/web_kbn.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 +import __py_include import asyncio +import logging import jinja2 import aiohttp_jinja2 import json @@ -7,18 +9,18 @@ import re import inverterd import phonenumbers import time -import __py_include from io import StringIO -from aiohttp.web import HTTPFound, HTTPBadRequest +from aiohttp import web from typing import Optional, Union +from urllib.parse import quote_plus 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.util import homekit_path, filesize_fmt, seconds_to_human_readable_string, json_serial, validate_ipv4 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 +from homekit import openwrt, http class WebKbnConfig(AppConfigUnit): @@ -36,13 +38,16 @@ class WebKbnConfig(AppConfigUnit): } -STATIC_FILES = [ +common_static_files = [ 'bootstrap.min.css', 'bootstrap.min.js', 'polyfills.js', 'app.js', 'app.css' ] +routes = web.RouteTableDef() +webkbn_strings = Translation('web_kbn') +logger = logging.getLogger(__name__) def get_js_link(file, version) -> str: @@ -65,7 +70,7 @@ def get_head_static(files=None) -> str: buf = StringIO() if files is None: files = [] - for file in STATIC_FILES+files: + for file in common_static_files + files: v = 2 try: q_ind = file.index('?') @@ -179,245 +184,356 @@ def get_inverter_data() -> tuple: return status, rated, html -class WebSite(http.HTTPServer): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) +def get_current_upstream() -> str: + r = openwrt.get_default_route() + logger.info(f'default route: {r}') + mc = ModemsConfig() + for k, v in mc.items(): + if 'gateway_ip' in v and v['gateway_ip'] == r: + r = v['ip'] + break + upstream = None + for k, v in mc.items(): + if r == v['ip']: + upstream = k + if not upstream: + raise RuntimeError('failed to determine current upstream!') + return upstream + + +def lang(key: str): + return webkbn_strings.get()[key] + + +async def render(req: web.Request, + template_name: str, + title: Optional[str] = None, + context: Optional[dict] = None, + assets: Optional[list] = None): + if context is None: + context = {} + context = { + **context, + 'head_static': get_head_static(assets) + } + if title is not None: + context['title'] = title + response = aiohttp_jinja2.render_template(template_name+'.j2', req, context=context) + return response + + +@routes.get('/main.cgi') +async def index(req: web.Request): + 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 render(req, 'index', + title=lang('sitename'), + context=ctx) + + +@routes.get('/modems.cgi') +async def modems(req: web.Request): + return await render(req, 'modems', + title='Состояние модемов', + context=dict(modems=ModemsConfig())) + + +@routes.get('/modems/info.ajx') +async def modems_ajx(req: web.Request): + mc = ModemsConfig() + modem = req.query.get('id', None) + if modem not in mc.keys(): + raise ValueError('invalid modem id') + + modem_cfg = mc.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 http.ajax_ok({'html': html}) + + +@routes.get('/modems/verbose.cgi') +async def modems_verbose(req: web.Request): + modem = req.query.get('id', None) + if modem not in ModemsConfig().keys(): + raise ValueError('invalid modem id') + + 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 = [ + ['Signal', signal], + ['Connection', status], + ['Traffic', traffic], + ['Device info', device], + ['Dialup connection', dialup_conn] + ] + + modem_name = ModemsConfig().getfullname(modem) + return await render(req, 'modem_verbose', + title=f'Подробная информация о модеме "{modem_name}"', + context=dict(data=data, modem_name=modem_name)) + + +@routes.get('/sms.cgi') +async def sms(req: web.Request): + 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(ModemsConfig()[modem]) + messages = cl.sms_list(1, 20, is_outbox) + return await render(req, 'sms', + title=f"SMS-сообщения ({'исходящие' if is_outbox else 'входящие'}, {modem})", + context=dict( + modems=ModemsConfig(), + selected_modem=modem, + is_outbox=is_outbox, + error=error, + is_sent=sent, + messages=messages + )) + + +@routes.post('/sms.cgi') +async def sms_post(req: web.Request): + modem = req.query.get('id', list(ModemsConfig().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 web.HTTPFound(f'{return_url}&error=Неверный+номер') + phone = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164) + + cl = get_modem_client(ModemsConfig()[modem]) + cl.sms_send(phone, text) + raise web.HTTPFound(return_url) + + +@routes.get('/inverter.cgi') +async def inverter(req: web.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 web.HTTPFound('/inverter.cgi') + + status, rated, html = await asyncio.get_event_loop().run_in_executor(None, get_inverter_data) + return await render(req, 'inverter', + title=lang('inverter'), + context=dict(status=status, rated=rated, html=html)) + + +@routes.get('/inverter.ajx') +async def inverter_ajx(req: web.Request): + status, rated, html = await asyncio.get_event_loop().run_in_executor(None, get_inverter_data) + return http.ajax_ok({'html': html}) + + +@routes.get('/pump.cgi') +async def pump(req: web.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 web.HTTPFound('/pump.cgi') + + status = cl.status() + return await render(req, 'pump', + title=lang('pump'), + context=dict(status=status)) + + +@routes.get('/cams.cgi') +async def cams(req: web.Request): + cc = IpcamConfig() + + cam = req.query.get('id', None) + zone = req.query.get('zone', None) + debug_hls = bool(req.query.get('debug_hls', False)) + debug_video_events = bool(req.query.get('debug_video_events', False)) + + if cam is not None: + if not cc.has_camera(int(cam)): + raise ValueError('invalid camera id') + cams = [int(cam)] + mode = {'type': 'single', 'cam': cam} + + elif zone is not None: + if not cc.has_zone(zone): + raise ValueError('invalid zone') + cams = cc['zones'][zone] + mode = {'type': 'zone', 'zone': zone} - 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) + else: + raise web.HTTPBadRequest(text='no camera id or zone found') + + js_config = { + 'host': config.app_config['cam_hls_host'], + 'proto': 'http', + 'cams': cams, + 'hlsConfig': { + 'opts': { + 'startPosition': -1, + # https://github.com/video-dev/hls.js/issues/3884#issuecomment-842380784 + 'liveSyncDuration': 2, + 'liveMaxLatencyDuration': 3, + 'maxLiveSyncPlaybackRate': 2, + 'liveDurationInfinity': True + }, + 'debugVideoEvents': debug_video_events, + 'debug': debug_hls + } + } - def filter_lang(key, unit='web_kbn'): - strings = Translation(unit) - return strings.get()[key] + return await render(req, 'cams', + title=lang('cams'), + assets=['hls.js'], + context=dict( + mode=mode, + js_config=js_config, + )) - 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')) +@routes.get('/routing/main.cgi') +async def routing_main(req: web.Request): + upstream = get_current_upstream() - self.get('/main.cgi', self.index) + set_upstream_to = req.query.get('set-upstream-to', None) + mc = ModemsConfig() - self.get('/modems.cgi', self.modems) - self.get('/modems/info.ajx', self.modems_ajx) - self.get('/modems/verbose.cgi', self.modems_verbose) + if set_upstream_to and set_upstream_to in mc and set_upstream_to != upstream: + modem = mc[set_upstream_to] + new_upstream = str(modem['gateway_ip'] if 'gateway_ip' in modem else modem['ip']) + openwrt.set_upstream(new_upstream) + raise web.HTTPFound('/routing/main.cgi') - 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) + context = dict( + upstream=upstream, + selected_tab='main', + modems=mc.keys() + ) + return await render(req, 'routing_main', title=lang('routing'), context=context) - self.get('/cams.cgi', self.cams) - async def render_page(self, - req: http.Request, - template_name: str, - title: Optional[str] = None, - context: Optional[dict] = None, - assets: Optional[list] = None): - if context is None: - context = {} - context = { - **context, - 'head_static': get_head_static(assets) - } - 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'] - - 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) - - async def modems(self, req: http.Request): - return await self.render_page(req, 'modems', - title='Состояние модемов', - context=dict(modems=ModemsConfig())) - - async def modems_ajx(self, req: http.Request): - mc = ModemsConfig() - modem = req.query.get('id', None) - if modem not in mc.keys(): - raise ValueError('invalid modem id') - - modem_cfg = mc.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 ModemsConfig().keys(): - raise ValueError('invalid modem id') - - 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 = [ - ['Signal', signal], - ['Connection', status], - ['Traffic', traffic], - ['Device info', device], - ['Dialup connection', dialup_conn] - ] - - 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(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(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=ModemsConfig(), - 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(ModemsConfig().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(ModemsConfig()[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)) - - async def cams(self, req: http.Request): - cc = IpcamConfig() - - cam = req.query.get('id', None) - zone = req.query.get('zone', None) - debug_hls = bool(req.query.get('debug_hls', False)) - debug_video_events = bool(req.query.get('debug_video_events', False)) - - if cam is not None: - if not cc.has_camera(int(cam)): - raise ValueError('invalid camera id') - cams = [int(cam)] - mode = {'type': 'single', 'cam': cam} - - elif zone is not None: - if not cc.has_zone(zone): - raise ValueError('invalid zone') - cams = cc['zones'][zone] - mode = {'type': 'zone', 'zone': zone} +@routes.get('/routing/rules.cgi') +async def routing_rules(req: web.Request): + mc = ModemsConfig() + + action = req.query.get('action', None) + error = req.query.get('error', None) + set_name = req.query.get('set', None) + ip = req.query.get('ip', None) + + def validate_input(): + # validate set + if not set_name or set_name not in mc: + raise ValueError(f'invalid set \'{set_name}\'') + + # validate ip + if not isinstance(ip, str): + raise ValueError('invalid ip') + + slash_pos = None + try: + slash_pos = ip.index('/') + except ValueError: + pass + if slash_pos is not None: + ip_without_mask = ip[0:slash_pos] else: - raise HTTPBadRequest(text='no camera id or zone found') - - js_config = { - 'host': config.app_config['cam_hls_host'], - 'proto': 'http', - 'cams': cams, - 'hlsConfig': { - 'opts': { - 'startPosition': -1, - # https://github.com/video-dev/hls.js/issues/3884#issuecomment-842380784 - 'liveSyncDuration': 2, - 'liveMaxLatencyDuration': 3, - 'maxLiveSyncPlaybackRate': 2, - 'liveDurationInfinity': True - }, - 'debugVideoEvents': debug_video_events, - 'debug': debug_hls - } - } + ip_without_mask = ip + if not validate_ipv4(ip_without_mask): + raise ValueError(f'invalid ip \'{ip}\'') + + base_url = '/routing/rules.cgi' + if action in ('add', 'del'): + try: + validate_input() + except ValueError as e: + raise web.HTTPFound(f'{base_url}?error='+quote_plus(str(e))) + f = getattr(openwrt, f'ipset_{action}') + output = f(set_name, ip) + url = base_url + if output != '': + url += '?error='+quote_plus(output) + raise web.HTTPFound(url) + + ipsets = openwrt.ipset_list_all() + context = dict( + sets=ipsets, + selected_tab='rules', + error=error + ) + return await render(req, 'routing_rules', + title=lang('routing') + ' // ' + lang('routing_rules'), + context=context) + + +@routes.get('/routing/dhcp.cgi') +async def routing_dhcp(req: web.Request): + leases = openwrt.get_dhcp_leases() + return await render(req, 'routing_dhcp', + title=lang('routing') + ' // DHCP', + context=dict(leases=leases, selected_tab='dhcp')) + + +def init_web_app(app: web.Application): + aiohttp_jinja2.setup( + app, + loader=jinja2.FileSystemLoader(homekit_path('web', 'kbn_templates')), + autoescape=jinja2.select_autoescape(['html', 'xml']), + ) + env = aiohttp_jinja2.get_env(app) + + def filter_lang(key, unit='web_kbn'): + strings = Translation(unit) + if isinstance(key, str) and '.' in key: + return strings.get().get(key) + else: + return strings.get()[key] - return await self.render_page(req, 'cams', - title='Камеры', - assets=['hls.js'], - context=dict( - mode=mode, - js_config=js_config, - )) + env.filters['tojson'] = lambda obj: json.dumps(obj, separators=(',', ':'), default=json_serial) + env.filters['lang'] = filter_lang + + app.router.add_static('/assets/', path=homekit_path('web', 'kbn_assets')) if __name__ == '__main__': config.load_app(WebKbnConfig) - - server = WebSite(config.app_config['listen_addr']) - server.run() + http.serve(addr=config.app_config['listen_addr'], + routes=routes, + before_start=init_web_app) |