#!/usr/bin/env python3 import logging import re import datetime import json import solarmon_api from typing import Optional, Tuple from argparse import ArgumentParser from html import escape from inverter_wrapper import wrapper_instance as inverter from monitor import InverterMonitor, ChargingEvent, BatteryState from inverterd import Format, InverterError from telegram import ( Update, ParseMode, InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup ) from telegram.ext import ( Updater, Filters, CommandHandler, MessageHandler, CallbackContext, CallbackQueryHandler ) from telegram.error import TimedOut monitor: Optional[InverterMonitor] = None updater: Optional[Updater] = None solarmon: Optional[solarmon_api.Client] = None notify_to: list[int] = [] LT = escape('<=') flags_map = { 'buzzer': 'BUZZ', 'overload_bypass': 'OLBP', 'escape_to_default_screen_after_1min_timeout': 'LCDE', 'overload_restart': 'OLRS', 'over_temp_restart': 'OTRS', 'backlight_on': 'BLON', 'alarm_on_on_primary_source_interrupt': 'ALRM', 'fault_code_record': 'FTCR', } _strings = { 'status': 'Status', 'generation': 'Generation', # flags 'flag_buzzer': 'Buzzer', 'flag_overload_bypass': 'Overload bypass', 'flag_escape_to_default_screen_after_1min_timeout': 'Reset to default LCD page after 1min timeout', 'flag_overload_restart': 'Restart on overload', 'flag_over_temp_restart': 'Restart on overtemp', 'flag_backlight_on': 'LCD backlight', 'flag_alarm_on_on_primary_source_interrupt': 'Beep on primary source interruption', 'flag_fault_code_record': 'Fault code recording', # monitor 'chrg_evt_started': '✅ Started charging from AC.', 'chrg_evt_finished': '✅ Finished charging from AC.', 'chrg_evt_disconnected': 'ℹ️ AC disconnected.', 'chrg_evt_current_changed': 'ℹ️ AC charging current set to %d A.', 'chrg_evt_not_charging': 'ℹ️ AC connected but not charging.', 'chrg_evt_na_solar': '⛔️ AC connected, but battery won\'t be charged due to active solar power line.', 'chrg_evt_mostly_charged': '✅ The battery is mostly charged now. The generator can be turned off.', 'battery_level_changed': 'Battery level: %s (%0.1f V under %d W load)', 'error_message': 'Error: %s.' } logger = logging.getLogger(__name__) # # helpers # def _(key, *args): global _strings return (_strings[key] if key in _strings else f'{{{key}}}') % args def get_usage(command: str, arguments: dict) -> str: blocks = [] argument_names = [] argument_lines = [] for k, v in arguments.items(): argument_names.append(k) argument_lines.append( f'{k}: {v}' ) command = f'/{command}' if argument_names: command += ' ' + ' '.join(argument_names) blocks.append( 'Usage\n' f'{command}' ) if argument_lines: blocks.append( 'Arguments\n' + '\n'.join(argument_lines) ) return '\n\n'.join(blocks) def get_markup() -> ReplyKeyboardMarkup: button = [ [ _('status'), _('generation') ], ] return ReplyKeyboardMarkup(button, one_time_keyboard=False) def reply(update: Update, text: str, reply_markup=None) -> None: if reply_markup is None: reply_markup = get_markup() update.message.reply_text(text, reply_markup=reply_markup, parse_mode=ParseMode.HTML) def notify_all(text: str) -> None: for chat_id in notify_to: updater.bot.send_message(chat_id=chat_id, text=text, parse_mode='HTML', reply_markup=get_markup()) def handle_exc(update: Update, e) -> None: logging.exception(str(e)) if isinstance(e, InverterError): try: err = json.loads(str(e))['message'] except json.decoder.JSONDecodeError: err = str(e) err = re.sub(r'((?:.*)?error:) (.*)', r'\1 \2', err) reply(update, err) elif not isinstance(e, TimedOut): reply(update, 'exception: ' + str(e)) def beautify_table(s): lines = s.split('\n') lines = list(map(lambda line: re.sub(r'\s+', ' ', line), lines)) lines = list(map(lambda line: re.sub(r'(.*?): (.*)', r'\1: \2', line), lines)) return '\n'.join(lines) def solarmon_report(update: Update, message: str = None) -> None: if message is None: message = update.message.text solarmon.log_bot_request(solarmon_api.BotType.INVERTER, update.message.chat_id, message) # # command/message handlers # def start(update: Update, context: CallbackContext) -> None: reply(update, 'Select a command on the keyboard.') def msg_status(update: Update, context: CallbackContext) -> None: try: gs = inverter.exec('get-status')['data'] # 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 = 'Battery: %s %s' % (gs['battery_voltage']['value'], gs['battery_voltage']['unit']) html += ' (%s%s)' % (power_direction, charging_rate) html += '\nLoad: %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 += '\nInput power: %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 += '\nGenerator: %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) except Exception as e: handle_exc(update, e) solarmon_report(update) def msg_generation(update: Update, context: CallbackContext) -> None: try: today = datetime.date.today() yday = today - datetime.timedelta(days=1) yday2 = today - datetime.timedelta(days=2) gs = inverter.exec('get-status')['data'] # sleep(0.1) gen_today = 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 = inverter.exec('get-day-generated', (yday.year, yday.month, yday.day))['data'] if yday2.month == today.month: # sleep(0.1) gen_yday2 = inverter.exec('get-day-generated', (yday2.year, yday2.month, yday2.day))['data'] # render response html = 'Input power: %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 += '\nToday: %s Wh' % (gen_today['wh']) if gen_yday is not None: html += '\nYesterday: %s Wh' % (gen_yday['wh']) if gen_yday2 is not None: html += '\nThe day before yesterday: %s Wh' % (gen_yday2['wh']) # send response reply(update, html) except Exception as e: handle_exc(update, e) solarmon_report(update) def msg_all(update: Update, context: CallbackContext) -> None: reply(update, "Command not recognized. Please try again.") def on_set_ac_charging_current(update: Update, context: CallbackContext) -> None: allowed_values = inverter.exec('get-allowed-ac-charging-currents')['data'] try: current = int(context.args[0]) if current not in allowed_values: raise ValueError(f'invalid value {current}') response = inverter.exec('set-max-ac-charging-current', (0, current)) reply(update, 'OK' if response['result'] == 'ok' else 'ERROR') except (IndexError, ValueError): usage = get_usage('setgencc', { 'A': 'max charging current, allowed values: ' + ', '.join(map(lambda x: str(x), allowed_values)) }) reply(update, usage) solarmon_report(update) def on_set_ac_charging_thresholds(update: Update, context: CallbackContext) -> None: try: cv = float(context.args[0]) dv = float(context.args[1]) if 44 <= cv <= 51 and 48 <= dv <= 58: response = inverter.exec('set-charging-thresholds', (cv, dv)) reply(update, 'OK' if response['result'] == 'ok' else 'ERROR') monitor.set_battery_ac_charging_thresholds(cv, dv) else: raise ValueError('invalid values') except (IndexError, ValueError): usage = get_usage('setgenct', { 'CV': f'charging voltage, 44 {LT} CV {LT} 51', 'DV': f'discharging voltage, 48 {LT} DV {LT} 58' }) reply(update, usage) solarmon_report(update) def on_set_battery_under_voltage(update: Update, context: CallbackContext) -> None: try: v = float(context.args[0]) if 40.0 <= v <= 48.0: response = inverter.exec('set-battery-cut-off-voltage', (v,)) reply(update, 'OK' if response['result'] == 'ok' else 'ERROR') monitor.set_battery_under_voltage(v) else: raise ValueError('invalid voltage') except (IndexError, ValueError): usage = get_usage('setbatuv', { 'V': f'floating point number, 40.0 {LT} V {LT} 48.0' }) reply(update, usage) solarmon_report(update) def build_flags_keyboard(flags: dict) -> Tuple[str, InlineKeyboardMarkup]: keyboard = [] for k, v in flags.items(): label = ('✅' if v else '❌') + ' ' + _(f'flag_{k}') proto_flag = flags_map[k] keyboard.append([InlineKeyboardButton(label, callback_data=f'flag_{proto_flag}')]) text = 'Press a button to toggle a flag.' return text, InlineKeyboardMarkup(keyboard) def on_flags(update: Update, context: CallbackContext) -> None: flags = inverter.exec('get-flags')['data'] text, markup = build_flags_keyboard(flags) reply(update, text, reply_markup=markup) solarmon_report(update) def on_status(update: Update, context: CallbackContext) -> None: try: status = inverter.exec('get-status', format=Format.TABLE) reply(update, beautify_table(status)) except Exception as e: handle_exc(update, e) solarmon_report(update) def on_config(update: Update, context: CallbackContext) -> None: try: rated = inverter.exec('get-rated', format=Format.TABLE) reply(update, beautify_table(rated)) except Exception as e: handle_exc(update, e) solarmon_report(update) def on_errors(update: Update, context: CallbackContext) -> None: try: errors = inverter.exec('get-errors', format=Format.TABLE) reply(update, beautify_table(errors)) except Exception as e: handle_exc(update, e) solarmon_report(update) def on_button(update: Update, context: CallbackContext) -> None: query = update.callback_query if query.data.startswith('flag_'): flag = query.data[5:] found = False json_key = None for k, v in flags_map.items(): if v == flag: found = True json_key = k break if not found: query.answer('unknown flag') return flags = inverter.exec('get-flags')['data'] cur_flag_value = flags[json_key] target_flag_value = '0' if cur_flag_value else '1' # set flag response = inverter.exec('set-flag', (flag, target_flag_value)) # notify user query.answer('Done' if response['result'] == 'ok' else 'failed to toggle flag') # edit message flags[json_key] = not cur_flag_value text, markup = build_flags_keyboard(flags) query.edit_message_text(text, reply_markup=markup) solarmon_report(update, message=query.data) else: query.answer('unexpected callback data') # # InverterMonitor event handlers # def monitor_charging_event_handler(event: ChargingEvent, **kwargs) -> None: args = [] if event == ChargingEvent.AC_CHARGING_STARTED: key = 'started' elif event == ChargingEvent.AC_CHARGING_FINISHED: key = 'finished' elif event == ChargingEvent.AC_DISCONNECTED: key = 'disconnected' elif event == ChargingEvent.AC_NOT_CHARGING: key = 'not_charging' elif event == ChargingEvent.AC_CURRENT_CHANGED: key = 'current_changed' args.append(kwargs['current']) elif event == ChargingEvent.AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR: key = 'na_solar' elif event == ChargingEvent.AC_MOSTLY_CHARGED: key = 'mostly_charged' else: logger.error('unknown charging event:', event) return notify_all(_(f'chrg_evt_{key}', *args)) def monitor_battery_event_handler(state: BatteryState, v: float, load_watts: int) -> None: if state == BatteryState.NORMAL: label = '✅ Normal' elif state == BatteryState.LOW: label = '⚠️ Low' elif state == BatteryState.CRITICAL: label = '‼️ Critical' else: logger.error('unknown battery state:', state) return notify_all(_('battery_level_changed', label, v, load_watts)) def monitor_error_handler(error: str) -> None: notify_all(_('error_message', error)) if __name__ == '__main__': # 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('--notify-to', nargs='+') parser.add_argument('--ac-current-range', nargs='+', default=(10, 30)) parser.add_argument('--inverterd-host', default='127.0.0.1', type=str) parser.add_argument('--inverterd-port', default=8305, type=int) parser.add_argument('--verbose', action='store_true') parser.add_argument('--solarmon-api-token', type=str, required=True) args = parser.parse_args() whitelist = list(map(lambda x: int(x), args.users_whitelist)) notify_to = list(map(lambda x: int(x), args.notify_to)) if args.notify_to is not None else [] # connect to inverterd inverter.init(host=args.inverterd_host, port=args.inverterd_port) # start monitoring monitor = InverterMonitor(list(map(lambda x: int(x), args.ac_current_range))) monitor.set_charging_event_handler(monitor_charging_event_handler) monitor.set_battery_event_handler(monitor_battery_event_handler) monitor.set_error_handler(monitor_error_handler) monitor.start() # configure logging logging_level = logging.DEBUG if args.verbose else logging.INFO logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging_level) # configure bot updater = Updater(args.token, request_kwargs={'read_timeout': 6, 'connect_timeout': 7}) dispatcher = updater.dispatcher user_filter = Filters.user(whitelist) dispatcher.add_handler(CommandHandler('start', start)) dispatcher.add_handler(MessageHandler(Filters.text(_('status')) & user_filter, msg_status)) dispatcher.add_handler(MessageHandler(Filters.text(_('generation')) & user_filter, msg_generation)) dispatcher.add_handler(CommandHandler('setgencc', on_set_ac_charging_current)) dispatcher.add_handler(CommandHandler('setgenct', on_set_ac_charging_thresholds)) dispatcher.add_handler(CommandHandler('setbatuv', on_set_battery_under_voltage)) dispatcher.add_handler(CallbackQueryHandler(on_button)) dispatcher.add_handler(CommandHandler('flags', on_flags)) dispatcher.add_handler(CommandHandler('status', on_status)) dispatcher.add_handler(CommandHandler('config', on_config)) dispatcher.add_handler(CommandHandler('errors', on_errors)) dispatcher.add_handler(MessageHandler(Filters.all & user_filter, msg_all)) # create api client instance solarmon = solarmon_api.Client(args.solarmon_api_token, timeout=3) solarmon.enable_async() # start the bot updater.start_polling() # run the bot until the user presses Ctrl-C or the process receives SIGINT, SIGTERM or SIGABRT updater.idle() monitor.stop()