From 7126ced86520ca7de6fefcc6ccccb2caff156a08 Mon Sep 17 00:00:00 2001 From: Konstantin Koslowski Date: Sat, 29 Feb 2020 23:50:57 +0100 Subject: [PATCH] homecontrol-dash: use poetry, start supporting actors --- .gitignore | 2 + homecontrol-dash.py | 185 ---------- homecontrol_dash/__main__.py | 3 + .../__pycache__/hcdash.cpython-38.pyc | Bin 0 -> 9330 bytes homecontrol_dash/hcdash.py | 335 ++++++++++++++++++ pyproject.toml | 23 ++ 6 files changed, 363 insertions(+), 185 deletions(-) delete mode 100755 homecontrol-dash.py create mode 100644 homecontrol_dash/__main__.py create mode 100644 homecontrol_dash/__pycache__/hcdash.cpython-38.pyc create mode 100755 homecontrol_dash/hcdash.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore index 61c9711..ba64065 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ Pipfile.lock dash dcc +poetry.lock +homecontrol_dash.egg-info diff --git a/homecontrol-dash.py b/homecontrol-dash.py deleted file mode 100755 index a5e083e..0000000 --- a/homecontrol-dash.py +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env python3 -import dash -import dash_bootstrap_components as dbc -import dash_core_components as dcc -import dash_html_components as html -from dash.dependencies import Input, Output -import time -import requests -from math import log, log10 - -URL_BASE="http://innocence:5000" - -def get_sensors(): - ret = {} - try: - url = f"{URL_BASE}/sensor/get" - res = requests.get(url) - ret = res.json() - except Exception as ex: - print(f"Exception Type: {type(ex).__name__}, args:\n{ex.args}") - - return ret - - -def get_values(sensorId, min_ts, max_ts, limit): - ret = {} - if sensorId: - try: - url = f"{URL_BASE}/sensor/get_values/{sensorId}?min_ts={min_ts}&max_ts={max_ts}&limit={limit}" - # print(url) - res = requests.get(url) - ret = res.json()[sensorId] - except Exception as ex: - print(f"Exception Type: {type(ex).__name__}, args:\n{ex.args}") - return ret - -def pTime(value, fmt="%Y/%m/%d %H:%M"): - if value > 0: - return time.strftime(fmt, time.localtime(float(value))) - else: - return 0 - - -sensors = get_sensors() -sensor = next(iter(sensors), None) -tabs = [] -for s in sensors: - sensorType = sensors[s]["sensorType"] - tabs.append(dcc.Tab(label=sensorType, value=s)) - -external_stylesheets = [dbc.themes.BOOTSTRAP] -meta_tags=[{"name": "viewport", "content": "width=device-width, initial-scale=1"}] - -app = dash.Dash(__name__, external_stylesheets=external_stylesheets, meta_tags=meta_tags) -app.title = "dashboard.ykonni.de" - - -app.layout = html.Div(children=[ - html.H1(children='dashboard.ykonni.de'), - dbc.Row([ - dbc.Col(dcc.Tabs(id="tabs-select-sensor", value=sensor, children=tabs)) - ]), - dbc.Row([ - dbc.Col( - dcc.Graph( - id='graph-sensor-values', - figure={ - 'data': [ {'x': [0], 'y': [0], 'mode': 'line', 'name': 'None'} ], - 'layout': { 'title': 'initial values' } - } - ) - ) - ]), - dbc.Row([ - dbc.Col([ - html.Div(id='slider-min-txt', style={'marginLeft': '5em', 'marginRight': '3em'}), - html.Div([ - dcc.Slider(id='slider-min', min=0, max=round(time.time()), step=600, - value=0 ), - ], style={'marginLeft': '5em', 'marginRight': '3em'} - ), - ]), - dbc.Col([ - html.Div(id='slider-max-txt', style={'marginLeft': '3em', 'marginRight': - '3em'}), - html.Div([ - dcc.Slider(id='slider-max', min=0, max=round(time.time()), step=600, - value=round(time.time())), - ], style={'marginLeft': '3em', 'marginRight': '3em'} - ), - ]), - dbc.Col([ - html.Div(id='slider-limit-txt', style={'marginLeft': '3em', 'marginRight': '5em'}), - html.Div([ - dcc.Slider(id='slider-limit', min=0, max=1000, step=10, value=0 ), - ], style={'marginLeft': '3em', 'marginRight': '5em'} - ), - ]), - ]), -]) - -@app.callback( - [Output('slider-min', 'min'), Output('slider-min', 'value'), - Output('slider-min', 'max')], - [Input('tabs-select-sensor', 'value')]) -def update_slider_min(sensorId): - res = get_values(sensorId, 0, int(time.time()), 1) - min_ts = 0 - # default last 7 days - cur_ts = round(time.time() - (60*60*24*7)) - max_ts = int(time.time()) - sensorType = None - if "values" in res: - min_ts = int(res["values"][0]["ts"]) - sensorType = res["sensorType"] - - # print(f"min: [{min_ts} [{cur_ts}] {max_ts}]" - return min_ts, cur_ts, max_ts - - -@app.callback( - Output('slider-min-txt', 'children'), - [Input('slider-min', 'value')]) -def update_slider_min_txt(value): - return f"From: {pTime(value)}" - -@app.callback( - [Output('slider-max', 'min'), Output('slider-max', 'value'), - Output('slider-max', 'max')], - [Input('tabs-select-sensor', 'value')]) -def update_slider_max(sensorId): - res = get_values(sensorId, 0, int(time.time()), 1) - min_ts = 0 - max_ts = int(time.time()) - sensorType = None - if "values" in res: - min_ts = int(res["values"][0]["ts"]) - sensorType = res["sensorType"] - - # print(f"max: [{min_ts} [{max_ts}] {max_ts}]" - return min_ts, max_ts, max_ts - - -@app.callback( - Output('slider-max-txt', 'children'), - [Input('slider-max', 'value')]) -def update_slider_max_txt(value): - return f"To: {pTime(value)}" - - -@app.callback( - Output('slider-limit-txt', 'children'), - [Input('slider-limit', 'value')]) -def update_slider_limit_txt(value): - return f"Limit: {value if value > 0 else None}" - - -@app.callback( - Output('graph-sensor-values', 'figure'), - [Input('tabs-select-sensor', 'value'), Input('slider-min', 'value'), - Input('slider-max', 'value'), Input('slider-limit', 'value')]) -def update_graph_sensor_values(sensorId, min_ts, max_ts, limit): - res = {} - if min_ts > 0: - res = get_values(sensorId, min_ts, max_ts, limit) - if "values" in res: - v = res["values"] - s = res["sensorType"] - x = [pTime(v[i]["ts"], fmt="%m%d-%H%M") for i in range(len(v))] - if s == "luminance": - y = [log10(v[i]["value"]/5+1) for i in range(len(v))] - else: - y = [v[i]["value"] for i in range(len(v))] - else: - s = sensorId - x = [0] - y = [0] - return { - 'data': [ {'x': x, 'y': y, 'mode': 'line', 'name': f'{s}'} ], - 'layout': { 'title': f'Data for sensor: {s} ({len(x)} elements)' } - } - -if __name__ == '__main__': - - app.run_server(port=8081,debug=True) diff --git a/homecontrol_dash/__main__.py b/homecontrol_dash/__main__.py new file mode 100644 index 0000000..f5279ae --- /dev/null +++ b/homecontrol_dash/__main__.py @@ -0,0 +1,3 @@ +import hcdash + +hcdash.main() diff --git a/homecontrol_dash/__pycache__/hcdash.cpython-38.pyc b/homecontrol_dash/__pycache__/hcdash.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0428126af69e429d42074d8d80caea2e807ee236 GIT binary patch literal 9330 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 0: + return time.strftime(fmt, time.localtime(float(value))) + else: + return 0 + + + def main(self): + tab = "sensors" + tabs = [ + dcc.Tab(label="sensors", value="sensors"), + dcc.Tab(label="actors", value="actors") + ] + + external_stylesheets = [dbc.themes.BOOTSTRAP] + meta_tags=[{"name": "viewport", "content": "width=device-width, initial-scale=1"}] + + app = dash.Dash(__name__, external_stylesheets=external_stylesheets, meta_tags=meta_tags) + app.title = "dashboard.ykonni.de" + app.config.suppress_callback_exceptions = True + + app.layout = html.Div(children=[ + html.H1(children='dashboard.ykonni.de'), + dbc.Row([ + dbc.Col(dcc.Tabs(id="tabs-select-class", value=tab, children=tabs)) + ]), + html.Div(id="tabs-content") + ]) + + @app.callback( + Output("tabs-content", "children"), + [Input("tabs-select-class", "value")]) + def updatefoo(value): + if value == "sensors": + sensors = self.get_sensors() + sensor = next(iter(sensors), None) + sensor_tabs = [] + for s in sensors: + sensorType = sensors[s]["sensorType"] + sensor_tabs.append(dcc.Tab(label=sensorType, value=s)) + return html.Div([ + dbc.Row([ + dbc.Col(dcc.Tabs(id="tabs-select-sensor", value=sensor, + children=sensor_tabs)) + ]), + dbc.Row([ + dbc.Col( + dcc.Graph( + id='graph-sensor-values', + figure={ + 'data': [ {'x': [0], 'y': [0], 'mode': 'line', 'name': 'None'} ], + 'layout': { 'title': 'initial values' } + } + ) + ) + ]), + dbc.Row([ + dbc.Col([ + html.Div(id='slider-min-txt', style={'marginLeft': '5em', 'marginRight': '3em'}), + html.Div([ + dcc.Slider(id='slider-min', min=0, max=round(time.time()), step=600, + value=0 ), + ], style={'marginLeft': '5em', 'marginRight': '3em'} + ), + ]), + dbc.Col([ + html.Div(id='slider-max-txt', style={'marginLeft': '3em', 'marginRight': + '3em'}), + html.Div([ + dcc.Slider(id='slider-max', min=0, max=round(time.time()), step=600, + value=round(time.time())), + ], style={'marginLeft': '3em', 'marginRight': '3em'} + ), + ]), + dbc.Col([ + html.Div(id='slider-limit-txt', style={'marginLeft': '3em', 'marginRight': '5em'}), + html.Div([ + dcc.Slider(id='slider-limit', min=0, max=1000, step=10, value=0 ), + ], style={'marginLeft': '3em', 'marginRight': '5em'} + ), + ]), + ]), + ]) + elif value == "actors": + actors = self.get_actors() + actor = next(iter(actors), None) + actor_tabs = [] + for a in actors: + actorType = actors[a]["actorType"] + actor_tabs.append(dcc.Tab(label=actorType, value=a)) + return html.Div([ + dbc.Row([ + dbc.Col(dcc.Tabs(id="tabs-select-actor", value=actor, + children=actor_tabs)) + ]), + dbc.Row([ + daq.ColorPicker( + id="color-picker", + label="Color Picker", + size=400, + value=dict(hex="#268bd2") + ), + html.P(id="empty") + ]), + ]) + else: + return html.Div([ + html.H3("undefined") + ]) + + + ## sensor callbacks + @app.callback( + [Output('slider-min', 'min'), Output('slider-min', 'value'), + Output('slider-min', 'max')], + [Input('tabs-select-sensor', 'value')]) + def update_slider_min(sensorId): + res = self.get_values(sensorId, 0, int(time.time()), 1) + min_ts = 0 + # default last 7 days + cur_ts = round(time.time() - (60*60*24*7)) + max_ts = int(time.time()) + sensorType = None + if "values" in res: + min_ts = int(res["values"][0]["ts"]) + sensorType = res["sensorType"] + + return min_ts, cur_ts, max_ts + + @app.callback( + Output('slider-min-txt', 'children'), + [Input('slider-min', 'value')]) + def update_slider_min_txt(value): + return f"From: {self.pTime(value)}" + + @app.callback( + [Output('slider-max', 'min'), Output('slider-max', 'value'), + Output('slider-max', 'max')], + [Input('tabs-select-sensor', 'value')]) + def update_slider_max(sensorId): + res = self.get_values(sensorId, 0, int(time.time()), 1) + min_ts = 0 + max_ts = int(time.time()) + sensorType = None + if "values" in res: + min_ts = int(res["values"][0]["ts"]) + sensorType = res["sensorType"] + + return min_ts, max_ts, max_ts + + + @app.callback( + Output('slider-max-txt', 'children'), + [Input('slider-max', 'value')]) + def update_slider_max_txt(value): + return f"To: {self.pTime(value)}" + + + @app.callback( + Output('slider-limit-txt', 'children'), + [Input('slider-limit', 'value')]) + def update_slider_limit_txt(value): + return f"Limit: {value if value > 0 else None}" + + + @app.callback( + Output('graph-sensor-values', 'figure'), + [Input('tabs-select-sensor', 'value'), Input('slider-min', 'value'), + Input('slider-max', 'value'), Input('slider-limit', 'value')]) + def update_graph_sensor_values(sensorId, min_ts, max_ts, limit): + res = {} + if min_ts > 0: + res = self.get_values(sensorId, min_ts, max_ts, limit) + if "values" in res: + v = res["values"] + s = res["sensorType"] + x = [self.pTime(v[i]["ts"], fmt="%m%d-%H%M") for i in range(len(v))] + if s == "luminance": + y = [log10(v[i]["value"]/5+1) for i in range(len(v))] + else: + y = [v[i]["value"] for i in range(len(v))] + else: + s = sensorId + x = [0] + y = [0] + return { + 'data': [ {'x': x, 'y': y, 'mode': 'line', 'name': f'{s}'} ], + 'layout': { 'title': f'Data for sensor: {s} ({len(x)} elements)' } + } + + ## actor callbacks + @app.callback( + Output("empty", "value"), + [Input('tabs-select-actor', 'value'), Input('color-picker', 'value')]) + def set_level(actorId, level): + rgb = level.get("rgb") + if rgb: + r = (rgb.get("r") or 0) << 16 + g = (rgb.get("g") or 0) << 8 + b = (rgb.get("b") or 0) + l = r + g + b + self.set_level(actorId, l) + return "" + + + app.run_server(host="0.0.0.0", port=self.config.get("port"), debug=self.config.get("debug")) + + +def main(): + config = setup() + hcd = HCDash(config) + hcd.main() + +if __name__ == '__main__': + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..70c521b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[tool.poetry] +name = "homecontrol-dash" +version = "0.1.0" +description = "dashboard for homecontrol" +authors = ["Konstantin Koslowski "] +license = "MIT" + +[tool.poetry.dependencies] +python = "^3.8" +dash = "^1.9.1" +dash-daq = "^0.3.3" +dash-bootstrap-components = "^0.8.3" +requests = "^2.23.0" +xdg = "^4.0.1" + +[tool.poetry.dev-dependencies] + +[tool.poetry.scripts] +hcdash = "homecontrol_dash.hcdash:main" + +[build-system] +requires = ["poetry>=0.12"] +build-backend = "poetry.masonry.api"