aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEvgeny Zinoviev <me@ch1p.io>2021-05-08 01:53:47 +0300
committerEvgeny Zinoviev <me@ch1p.io>2021-05-16 02:54:29 +0300
commitb804a5d7e7dab93bf6056e8969a5dd4d4c52f452 (patch)
tree582beaf43cd92f65f55f3e4963afff25d927559c
parenta9f26cf874a74cd3cd90f4185e537609fffdb815 (diff)
migrate from isv to inverterd
-rw-r--r--README.md43
-rw-r--r--configstore.py51
-rwxr-xr-x[-rw-r--r--]inverter-bot (renamed from main.py)117
-rw-r--r--isv.py42
-rw-r--r--requirements.txt3
-rw-r--r--test.py35
6 files changed, 145 insertions, 146 deletions
diff --git a/README.md b/README.md
index 9f7f33c..abc44a0 100644
--- a/README.md
+++ b/README.md
@@ -1,33 +1,40 @@
# inverter-bot
-This is a Telegram bot for querying information from an InfiniSolar V family of hybrid solar inverters, in particular
-inverters supported by **isv** utility, which is an older version of **infinisolarctl** from **infinisolar-tools**
-package.
+This is a Telegram bot for querying information from an InfiniSolar V family of hybrid solar inverters.
It supports querying general status, such as battery voltage or power usage, printing amounts of energy generated in
the last days, dumping status or rated information and more.
It requires Python 3.6+ or so.
-## Configuration
+## Requirements
-Configuration is stored in `config.ini` file in `~/.config/inverter-bot`.
+- **`inverterd`** from [inverter-tools](https://github.com/gch1p/inverter-tools)
+- **[`inverterd`](https://pypi.org/project/inverterd/)** python library
+- Python 3.6+ or so
-Config example:
-```
-token=YOUR_TOKEN
-admins=
- 123456 ; admin id
- 000123 ; another admin id
-isv_bin=/path/to/isv
-use_sudo=0
-```
+## Configuration
+
+The bot accepts following parameters:
-Only users in `admins` are allowed to use the bot.
+* ``--token`` — your telegram bot token (required)
+* ``--users-whitelist`` — space-separated list of IDs of users who are allowed
+ to use the bot (required)
+* ``--inverterd-host`` (default is `127.0.0.1`)
+* ``--inverterd-port`` (default is `8305`)
## Launching with systemd
-Create a service file `/etc/systemd/system/inverter-bot.service` with the following content (changing stuff like paths):
+This is tested on Debian 10. Something might differ on other systems.
+
+Create environment configuration file `/etc/default/inverter-bot`:
+```
+TOKEN="YOUR_TOKEN"
+USERS="ID ID ID ..."
+OPTS="" # here you can pass other options such as --inverterd-host
+```
+
+Create systemd service file `/etc/systemd/system/inverter-bot.service` with the following content (changing stuff like paths):
```systemd
[Unit]
@@ -35,10 +42,11 @@ Description=inverter bot
After=network.target
[Service]
+EnvironmentFile=/etc/default/inverter-bot
User=user
Group=user
Restart=on-failure
-ExecStart=python3 /home/user/inverter-bot/main.py
+ExecStart=python3 /home/user/inverter-bot/inverter-bot --token $TOKEN --users-whitelist $USERS $PARAMS
WorkingDirectory=/home/user/inverter-bot
[Install]
@@ -47,7 +55,6 @@ WantedBy=multi-user.target
Then enable and start the service:
```
-systemctl daemon-reload
systemctl enable inverter-bot
systemctl start inverter-bot
```
diff --git a/configstore.py b/configstore.py
deleted file mode 100644
index fcaa5c8..0000000
--- a/configstore.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import os, re
-from configparser import ConfigParser
-
-CONFIG_DIR = os.environ['HOME'] + '/.config/inverter-bot'
-CONFIG_FILE = 'config.ini'
-
-__config__ = None
-
-
-def _get_config_path() -> str:
- return "%s/%s" % (CONFIG_DIR, CONFIG_FILE)
-
-
-def get_config():
- global __config__
- if __config__ is not None:
- return __config__['root']
-
- if not os.path.exists(CONFIG_DIR):
- raise IOError("%s directory not found" % CONFIG_DIR)
-
- if not os.path.isdir(CONFIG_DIR):
- raise IOError("%s is not a directory" % CONFIG_DIR)
-
- config_path = _get_config_path()
- if not os.path.isfile(config_path):
- raise IOError("%s file not found" % config_path)
-
- __config__ = ConfigParser()
- with open(config_path) as config_content:
- __config__.read_string("[root]\n" + config_content.read())
-
- return __config__['root']
-
-
-def get_token() -> str:
- return get_config()['token']
-
-
-def get_admins() -> tuple:
- config = get_config()
- return tuple([int(s) for s in re.findall(r'\b\d+\b', config['admins'], flags=re.MULTILINE)])
-
-
-def get_isv_bin() -> str:
- return get_config()['isv_bin']
-
-
-def use_sudo() -> bool:
- config = get_config()
- return 'use_sudo' in config and config['use_sudo'] == '1' \ No newline at end of file
diff --git a/main.py b/inverter-bot
index 1441728..921b185 100644..100755
--- a/main.py
+++ b/inverter-bot
@@ -1,10 +1,11 @@
-import logging
-import re
-import datetime
-import isv
-import configstore
+#!/usr/bin/env python3
+import logging, re, datetime, json
+from inverterd import Format, Client as InverterClient, InverterError
+from typing import Optional
+from argparse import ArgumentParser
from html import escape
+from pprint import pprint
from time import sleep
from strings import lang as _
from telegram import (
@@ -22,10 +23,41 @@ from telegram.ext import (
)
+class InverterClientWrapper:
+ def __init__(self, host: str, port: str):
+ self._host = host
+ self._port = port
+ self._inverter = None
+
+ self.create()
+
+ def create(self):
+ self._inverter = InverterClient(host=self._host, port=self._port)
+ self._inverter.connect()
+
+ def exec(self, command: str, arguments: tuple = (), format=Format.JSON):
+ try:
+ self._inverter.format(format)
+ return self._inverter.exec(command, arguments)
+ except InverterError as e:
+ raise e
+ except Exception as e:
+ # silently try to reconnect
+ try:
+ self.create()
+ except Exception:
+ pass
+ raise e
+
+
+inverter: Optional[InverterClientWrapper] = None
+
+
#
# helpers
#
+
def get_markup() -> ReplyKeyboardMarkup:
button = [
[
@@ -57,7 +89,7 @@ def start(update: Update, context: CallbackContext) -> None:
def msg_status(update: Update, context: CallbackContext) -> None:
try:
- gs = isv.general_status()
+ gs = json.loads(inverter.exec('get-status'))['data']
# render response
power_direction = gs['battery_power_direction'].lower()
@@ -65,23 +97,25 @@ def msg_status(update: Update, context: CallbackContext) -> None:
charging_rate = ''
if power_direction == 'charging':
- charging_rate = ' @ %s %s' % tuple(gs['battery_charging_current'])
+ charging_rate = ' @ %s %s' % (
+ gs['battery_charging_current']['value'], gs['battery_charging_current']['unit'])
elif power_direction == 'discharging':
- charging_rate = ' @ %s %s' % tuple(gs['battery_discharge_current'])
+ charging_rate = ' @ %s %s' % (
+ gs['battery_discharging_current']['value'], gs['battery_discharging_current']['unit'])
- html = '<b>Battery:</b> %s %s' % tuple(gs['battery_voltage'])
- html += ' (%s%s, ' % tuple(gs['battery_capacity'])
+ html = '<b>Battery:</b> %s %s' % (gs['battery_voltage']['value'], gs['battery_voltage']['unit'])
+ html += ' (%s%s, ' % (gs['battery_capacity']['value'], gs['battery_capacity']['unit'])
html += '%s%s)' % (power_direction, charging_rate)
- html += '\n<b>Load:</b> %s %s' % tuple(gs['ac_output_active_power'])
- html += ' (%s%%)' % (gs['output_load_percent'][0])
+ html += '\n<b>Load:</b> %s %s' % (gs['ac_output_active_power']['value'], gs['ac_output_active_power']['unit'])
+ html += ' (%s%%)' % (gs['output_load_percent']['value'])
- if gs['pv1_input_power'][0] > 0:
- html += '\n<b>Input power:</b> %s%s' % tuple(gs['pv1_input_power'])
+ if gs['pv1_input_power']['value'] > 0:
+ html += '\n<b>Input power:</b> %s%s' % (gs['pv1_input_power']['value'], gs['pv1_input_power']['unit'])
- if gs['grid_voltage'][0] > 0 or gs['grid_freq'][0] > 0:
- html += '\n<b>Generator:</b> %s %s' % tuple(gs['grid_voltage'])
- html += ', %s %s' % tuple(gs['grid_freq'])
+ if gs['grid_voltage']['value'] > 0 or gs['grid_freq']['value'] > 0:
+ html += '\n<b>Generator:</b> %s %s' % (gs['grid_voltage']['unit'], gs['grid_voltage']['value'])
+ html += ', %s %s' % (gs['grid_freq']['value'], gs['grid_freq']['unit'])
# send response
reply(update, html)
@@ -96,24 +130,24 @@ def msg_generation(update: Update, context: CallbackContext) -> None:
yday = today - datetime.timedelta(days=1)
yday2 = today - datetime.timedelta(days=2)
- gs = isv.general_status()
- sleep(0.1)
+ gs = json.loads(inverter.exec('get-status'))['data']
+ # sleep(0.1)
- gen_today = isv.day_generated(today.year, today.month, today.day)
+ gen_today = json.loads(inverter.exec('get-day-generated', (today.year, today.month, today.day)))['data']
gen_yday = None
gen_yday2 = None
if yday.month == today.month:
- sleep(0.1)
- gen_yday = isv.day_generated(yday.year, yday.month, yday.day)
+ # sleep(0.1)
+ gen_yday = json.loads(inverter.exec('get-day-generated', (yday.year, yday.month, yday.day)))['data']
if yday2.month == today.month:
- sleep(0.1)
- gen_yday2 = isv.day_generated(yday2.year, yday2.month, yday2.day)
+ # sleep(0.1)
+ gen_yday2 = json.loads(inverter.exec('get-day-generated', (yday2.year, yday2.month, yday2.day)))['data']
# render response
- html = '<b>Input power:</b> %s %s' % tuple(gs['pv1_input_power'])
- html += ' (%s %s)' % tuple(gs['pv1_input_voltage'])
+ html = '<b>Input power:</b> %s %s' % (gs['pv1_input_power']['value'], gs['pv1_input_power']['unit'])
+ html += ' (%s %s)' % (gs['pv1_input_voltage']['value'], gs['pv1_input_voltage']['unit'])
html += '\n<b>Today:</b> %s Wh' % (gen_today['wh'])
@@ -132,7 +166,7 @@ def msg_generation(update: Update, context: CallbackContext) -> None:
def msg_gs(update: Update, context: CallbackContext) -> None:
try:
- status = isv.general_status(as_table=True)
+ status = inverter.exec('get-status', format=Format.TABLE)
reply(update, status)
except Exception as e:
logging.exception(str(e))
@@ -141,7 +175,7 @@ def msg_gs(update: Update, context: CallbackContext) -> None:
def msg_ri(update: Update, context: CallbackContext) -> None:
try:
- rated = isv.rated_information(as_table=True)
+ rated = inverter.exec('get-rated', format=Format.TABLE)
reply(update, rated)
except Exception as e:
logging.exception(str(e))
@@ -150,8 +184,8 @@ def msg_ri(update: Update, context: CallbackContext) -> None:
def msg_errors(update: Update, context: CallbackContext) -> None:
try:
- faults = isv.faults(as_table=True)
- reply(update, faults)
+ errors = inverter.exec('get-errors', format=Format.TABLE)
+ reply(update, errors)
except Exception as e:
logging.exception(str(e))
reply(update, 'exception: ' + str(e))
@@ -162,15 +196,30 @@ def msg_all(update: Update, context: CallbackContext) -> None:
if __name__ == '__main__':
- config = configstore.get_config()
-
+ # command-line arguments
+ parser = ArgumentParser()
+ parser.add_argument('--token', required=True, type=str,
+ help='Telegram bot token')
+ parser.add_argument('--users-whitelist', nargs='+',
+ help='ID of users allowed to use the bot')
+ parser.add_argument('--inverterd-host', default='127.0.0.1', type=str)
+ parser.add_argument('--inverterd-port', default=8305, type=int)
+ args = parser.parse_args()
+
+ whitelist = list(map(lambda x: int(x), args.users_whitelist))
+
+ # connect to inverterd
+ inverter = InverterClientWrapper(host=args.inverterd_host, port=args.inverterd_port)
+
+ # configure logging
logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.INFO)
- updater = Updater(configstore.get_token(), request_kwargs={'read_timeout': 6, 'connect_timeout': 7})
+ # configure bot
+ updater = Updater(args.token, request_kwargs={'read_timeout': 6, 'connect_timeout': 7})
dispatcher = updater.dispatcher
- user_filter = Filters.user(configstore.get_admins())
+ user_filter = Filters.user(whitelist)
dispatcher.add_handler(CommandHandler('start', start))
dispatcher.add_handler(MessageHandler(Filters.text(_('status')) & user_filter, msg_status))
diff --git a/isv.py b/isv.py
deleted file mode 100644
index ab4268b..0000000
--- a/isv.py
+++ /dev/null
@@ -1,42 +0,0 @@
-import subprocess
-import configstore
-import json
-
-
-def __run(argv: list, fmt='json-w-units'):
- argv.insert(0, configstore.get_isv_bin())
- if configstore.use_sudo():
- argv.insert(0, 'sudo')
- argv.append('--format')
- argv.append(fmt)
-
- result = subprocess.run(argv, capture_output=True)
- if result.returncode != 0:
- raise ChildProcessError("isv returned %d: %s" % (result.returncode, result.stderr))
-
- return json.loads(result.stdout) if 'json' in fmt else result.stdout.decode('utf-8')
-
-
-def general_status(as_table=False):
- kwargs = {}
- if as_table:
- kwargs['fmt'] = 'table'
- return __run(['--get-general-status'], **kwargs)
-
-
-def day_generated(y: int, m: int, d: int):
- return __run(['--get-day-generated', str(y), str(m), str(d)])
-
-
-def rated_information(as_table=False):
- kwargs = {}
- if as_table:
- kwargs['fmt'] = 'table'
- return __run(['--get-rated-information'], **kwargs)
-
-
-def faults(as_table=False):
- kwargs = {}
- if as_table:
- kwargs['fmt'] = 'table'
- return __run(['--get-faults-warnings'], **kwargs)
diff --git a/requirements.txt b/requirements.txt
index ccd058e..41f958c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1 +1,2 @@
-python-telegram-bot~=13.1 \ No newline at end of file
+python-telegram-bot~=13.1
+inverterd~=1.0.2 \ No newline at end of file
diff --git a/test.py b/test.py
new file mode 100644
index 0000000..79ac277
--- /dev/null
+++ b/test.py
@@ -0,0 +1,35 @@
+import json, re
+from pprint import pprint
+
+
+s = '{"result":"ok","data":{"grid_voltage":{"unit":"V","value":0.0},"grid_freq":{"unit":"Hz","value":0.0},"ac_output_voltage":{"unit":"V","value":230.0},"ac_output_freq":{"unit":"Hz","value":50.0},"ac_output_apparent_power":{"unit":"VA","value":115},"ac_output_active_power":{"unit":"Wh","value":18},"output_load_percent":{"unit":"%","value":2},"battery_voltage":{"unit":"V","value":50.0},"battery_voltage_scc":{"unit":"V","value":0.0},"battery_voltage_scc2":{"unit":"V","value":0.0},"battery_discharging_current":{"unit":"A","value":0},"battery_charging_current":{"unit":"A","value":0},"battery_capacity":{"unit":"%","value":78},"inverter_heat_sink_temp":{"unit":"°C","value":19},"mppt1_charger_temp":{"unit":"°C","value":0},"mppt2_charger_temp":{"unit":"°C","value":0},"pv1_input_power":{"unit":"Wh","value":1000},"pv2_input_power":{"unit":"Wh","value":0},"pv1_input_voltage":{"unit":"V","value":0.0},"pv2_input_voltage":{"unit":"V","value":0.0},"settings_values_changed":"Custom","mppt1_charger_status":"Abnormal","mppt2_charger_status":"Abnormal","load_connected":"Connected","battery_power_direction":"Discharge","dc_ac_power_direction":"DC/AC","line_power_direction":"Do nothing","local_parallel_id":0}}'
+
+if __name__ == '__main__':
+ gs = json.loads(s)['data']
+ # pprint(gs)
+
+ # render response
+ power_direction = gs['battery_power_direction'].lower()
+ power_direction = re.sub(r'ge$', 'ging', power_direction)
+
+ charging_rate = ''
+ if power_direction == 'charging':
+ charging_rate = ' @ %s %s' % (gs['battery_charging_current']['value'], gs['battery_charging_current']['unit'])
+ elif power_direction == 'discharging':
+ charging_rate = ' @ %s %s' % (gs['battery_discharging_current']['value'], gs['battery_discharging_current']['unit'])
+
+ html = '<b>Battery:</b> %s %s' % (gs['battery_voltage']['value'], gs['battery_voltage']['unit'])
+ html += ' (%s%s, ' % (gs['battery_capacity']['value'], gs['battery_capacity']['unit'])
+ html += '%s%s)' % (power_direction, charging_rate)
+
+ html += '\n<b>Load:</b> %s %s' % (gs['ac_output_active_power']['value'], gs['ac_output_active_power']['unit'])
+ html += ' (%s%%)' % (gs['output_load_percent']['value'])
+
+ if gs['pv1_input_power']['value'] > 0:
+ html += '\n<b>Input power:</b> %s%s' % (gs['pv1_input_power']['value'], gs['pv1_input_power']['unit'])
+
+ if gs['grid_voltage']['value'] > 0 or gs['grid_freq']['value'] > 0:
+ html += '\n<b>Generator:</b> %s %s' % (gs['grid_voltage']['unit'], gs['grid_voltage']['value'])
+ html += ', %s %s' % (gs['grid_freq']['value'], gs['grid_freq']['unit'])
+
+ print(html) \ No newline at end of file