@@ 8,35 8,61 @@ import json
app = Quart('fronius-dashboard')
-BROKER_URL = os.environ.get("BROKER_URL", 'mqtt://mqtt.lan')
+BROKER_URL = os.environ.get("BROKER_URL")
+INVERTER = os.environ.get('INVERTER')
INVERTER_SIZE = int(os.environ.get('INVERTER_SIZE', 5000))
TOPIC = os.environ.get('TOPIC', 'solar/current')
-BROKER = urlparse(BROKER_URL)
-
@app.route('/')
async def main():
return await render_template('main.html', inverter_size=INVERTER_SIZE)
-async def messages(topic=TOPIC, broker=BROKER.hostname):
- async with Client(broker) as client:
- async with client.filtered_messages(topic) as messages:
- await client.subscribe(topic)
- async for message in messages:
- yield message
-
+if BROKER_URL:
+ async def messages(topic=TOPIC, broker=urlparse(BROKER_URL).hostname):
+ async with Client(broker) as client:
+ async with client.filtered_messages(topic) as messages:
+ await client.subscribe(topic)
+ async for message in messages:
+ yield json.loads(message.payload)
+elif INVERTER:
+ import httpx
+
+ async def messages(inverter=INVERTER):
+ async with httpx.AsyncClient() as client:
+ while True:
+ response = await client.get(f'http://{inverter}/solar_api/v1/GetPowerFlowRealtimeData.fcgi')
+ result = response.json()['Body']['Data']['Site']
+ yield {
+ 'generation': result['P_PV'],
+ 'consumption': -result['P_Load'],
+ 'grid': result['P_Grid'],
+ 'energy': result['E_Day'],
+ }
+
@app.websocket('/ws')
async def ws():
while True:
- async for message in messages():
- await websocket.send_json(json.loads(message.payload))
+ try:
+ async for message in messages():
+ await websocket.send_json(message)
+ except Exception:
+ await asyncio.sleep(1)
class ServerSentEvent:
-
+ """
+ A ServerSentEvent must be in a pretty specific format.
+
+ data: <data>\n
+ event: <event>\n
+ id: <id>\n
+ retry: <integer>\r\n\r\n
+
+ Of these, strictly only the data is required.
+ """
def __init__(
self,
data: str,
@@ 57,26 83,24 @@ class ServerSentEvent:
if getattr(self, key) is not None
) + '\r\n\r\n'
return message.encode('utf-8')
-
-
+
+
+
@app.route('/stream')
async def stream():
- async def messages(topic):
+ async def _messages():
while True:
try:
- async with Client(BROKER.hostname) as client:
- async with client.filtered_messages(topic) as messages:
- await client.subscribe(topic)
- async for message in messages:
- yield ServerSentEvent(
- message.payload.decode('utf-8'),
- event='update'
- ).encode()
+ async for message in messages():
+ yield ServerSentEvent(
+ json.dumps(message),
+ event='update'
+ ).encode()
except Exception:
await asyncio.sleep(1)
response = await make_response(
- messages(TOPIC),
+ _messages(),
{
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
@@ 26,6 26,14 @@ optional = false
python-versions = "*"
[[package]]
+name = "certifi"
+version = "2020.12.5"
+description = "Python package for providing Mozilla's CA Bundle."
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
name = "click"
version = "7.1.2"
description = "Composable command line interface toolkit"
@@ 62,6 70,39 @@ optional = false
python-versions = ">=3.6.1"
[[package]]
+name = "httpcore"
+version = "0.12.3"
+description = "A minimal low-level HTTP client."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+h11 = "<1.0.0"
+sniffio = ">=1.0.0,<2.0.0"
+
+[package.extras]
+http2 = ["h2 (>=3,<5)"]
+
+[[package]]
+name = "httpx"
+version = "0.16.1"
+description = "The next generation HTTP client."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+certifi = "*"
+httpcore = ">=0.12.0,<0.13.0"
+rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]}
+sniffio = "*"
+
+[package.extras]
+brotli = ["brotlipy (>=0.7.0,<0.8.0)"]
+http2 = ["h2 (>=3.0.0,<4.0.0)"]
+
+[[package]]
name = "hypercorn"
version = "0.11.2"
description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn."
@@ 91,6 132,14 @@ optional = false
python-versions = ">=3.6.1"
[[package]]
+name = "idna"
+version = "3.1"
+description = "Internationalized Domain Names in Applications (IDNA)"
+category = "main"
+optional = false
+python-versions = ">=3.4"
+
+[[package]]
name = "itsdangerous"
version = "1.1.0"
description = "Various helpers to pass data to untrusted environments and back."
@@ 161,6 210,28 @@ werkzeug = ">=1.0.0"
dotenv = ["python-dotenv"]
[[package]]
+name = "rfc3986"
+version = "1.4.0"
+description = "Validating URI References per RFC 3986"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+idna = {version = "*", optional = true, markers = "extra == \"idna2008\""}
+
+[package.extras]
+idna2008 = ["idna"]
+
+[[package]]
+name = "sniffio"
+version = "1.2.0"
+description = "Sniff out which async library your code is running under"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
name = "toml"
version = "0.10.2"
description = "Python Library for Tom's Obvious, Minimal Language"
@@ 194,7 265,7 @@ h11 = ">=0.9.0,<1"
[metadata]
lock-version = "1.1"
python-versions = "^3.9"
-content-hash = "28ee4dcfa98ff0b84713103310b782d79d95a0e8034aa34b86583957b372e1bb"
+content-hash = "92c8f3170c3b980c8c880c6b4f535c9a808fd83d6538d4c1253e4704c92b35ba"
[metadata.files]
aiofiles = [
@@ 208,6 279,10 @@ asyncio-mqtt = [
blinker = [
{file = "blinker-1.4.tar.gz", hash = "sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6"},
]
+certifi = [
+ {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"},
+ {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"},
+]
click = [
{file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
{file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
@@ 224,6 299,14 @@ hpack = [
{file = "hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c"},
{file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"},
]
+httpcore = [
+ {file = "httpcore-0.12.3-py3-none-any.whl", hash = "sha256:93e822cd16c32016b414b789aeff4e855d0ccbfc51df563ee34d4dbadbb3bcdc"},
+ {file = "httpcore-0.12.3.tar.gz", hash = "sha256:37ae835fb370049b2030c3290e12ed298bf1473c41bb72ca4aa78681eba9b7c9"},
+]
+httpx = [
+ {file = "httpx-0.16.1-py3-none-any.whl", hash = "sha256:9cffb8ba31fac6536f2c8cde30df859013f59e4bcc5b8d43901cb3654a8e0a5b"},
+ {file = "httpx-0.16.1.tar.gz", hash = "sha256:126424c279c842738805974687e0518a94c7ae8d140cd65b9c4f77ac46ffa537"},
+]
hypercorn = [
{file = "Hypercorn-0.11.2-py3-none-any.whl", hash = "sha256:8007c10f81566920f8ae12c0e26e146f94ca70506da964b5a727ad610aa1d821"},
{file = "Hypercorn-0.11.2.tar.gz", hash = "sha256:5ba1e719c521080abd698ff5781a2331e34ef50fc1c89a50960538115a896a9a"},
@@ 232,6 315,10 @@ hyperframe = [
{file = "hyperframe-6.0.0-py3-none-any.whl", hash = "sha256:a51026b1591cac726fc3d0b7994fbc7dc5efab861ef38503face2930fd7b2d34"},
{file = "hyperframe-6.0.0.tar.gz", hash = "sha256:742d2a4bc3152a340a49d59f32e33ec420aa8e7054c1444ef5c7efff255842f1"},
]
+idna = [
+ {file = "idna-3.1-py3-none-any.whl", hash = "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16"},
+ {file = "idna-3.1.tar.gz", hash = "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1"},
+]
itsdangerous = [
{file = "itsdangerous-1.1.0-py2.py3-none-any.whl", hash = "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"},
{file = "itsdangerous-1.1.0.tar.gz", hash = "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19"},
@@ 286,6 373,14 @@ quart = [
{file = "Quart-0.14.1-py3-none-any.whl", hash = "sha256:7b13786e07541cc9ce1466fdc6a6ccd5f36eb39118edd25a42d617593cd17707"},
{file = "Quart-0.14.1.tar.gz", hash = "sha256:429c5b4ff27e1d2f9ca0aacc38f6aba0ff49b38b815448bf24b613d3de12ea02"},
]
+rfc3986 = [
+ {file = "rfc3986-1.4.0-py2.py3-none-any.whl", hash = "sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50"},
+ {file = "rfc3986-1.4.0.tar.gz", hash = "sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d"},
+]
+sniffio = [
+ {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"},
+ {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"},
+]
toml = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
@@ 20,7 20,7 @@
<body >
<div>
<div class="main"
- data-bind="class: generating ? 'on' : 'off'">
+ data-bind="class: generating() ? 'on' : 'off'">
<svg xmlns="http://www.w3.org/2000/svg"
height="100%"
viewBox="0 0 200 200"
@@ 95,7 95,8 @@
generation: ko.observable(0),
consumption: ko.observable(0),
grid: ko.observable(0),
- energy: ko.observable(0)
+ energy: ko.observable(0),
+ last_seen_generating: ko.observable()
};
viewModel.update = function(data) {
@@ 103,9 104,12 @@
this.consumption(parseInt(data.consumption));
this.grid(parseInt(data.grid));
this.energy(parseInt(data.energy));
+ if (this.generation() > 0) {
+ this.last_seen_generating(new Date());
+ }
}.bind(viewModel);
- viewModel.generating = ko.computed(() => true);
+ viewModel.generating = ko.computed(() => new Date() - viewModel.last_seen_generating() < 300000);
viewModel.generation.percentage = ko.computed(
() => viewModel.generation() > inverter_size ?
100 : (100 * viewModel.generation() / inverter_size).toFixed(0)
@@ 144,10 148,17 @@
// websocket.onmessage = function(event) {
// viewModel.update(JSON.parse(event.data));
// }
-
+
+ window.viewModel = viewModel;
+
ko.applyBindings(viewModel);
-
+ const toggleFullScreen = () => {
+ document.webkitCurrentFullScreenElement ?
+ document.webkitExitFullscreen() :
+ document.documentElement.webkitRequestFullScreen();
+ };
+ window.addEventListener('click', toggleFullScreen);
</script>
</body>
</html>