aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEvgeny Zinoviev <me@ch1p.io>2022-11-06 20:40:42 +0300
committerEvgeny Zinoviev <me@ch1p.io>2022-11-06 20:53:55 +0300
commit75ee161b6eb64cf19c8a9718d15047443f3e4ebe (patch)
treeccebc9cbd2709ad13a14ec00372fdcfe9226cd9f
parent28c67c4510a3bee574b4077be35147dba257c8f7 (diff)
inverter_bot: refactor and introduce new functions
-rw-r--r--doc/inverter_bot.md6
-rwxr-xr-xsrc/admin_bot.py35
-rw-r--r--src/home/bot/__init__.py6
-rw-r--r--src/home/bot/errors.py2
-rw-r--r--src/home/bot/reporting.py22
-rw-r--r--src/home/bot/util.py57
-rw-r--r--src/home/bot/wrapper.py369
-rw-r--r--src/home/config/config.py13
-rw-r--r--src/home/inverter/emulator.py556
-rw-r--r--src/home/telegram/__init__.py2
-rw-r--r--src/home/telegram/_botcontext.py85
-rw-r--r--src/home/telegram/_botdb.py (renamed from src/home/bot/store.py)4
-rw-r--r--src/home/telegram/_botlang.py (renamed from src/home/bot/lang.py)46
-rw-r--r--src/home/telegram/_botutil.py47
-rw-r--r--src/home/telegram/bot.py542
-rwxr-xr-xsrc/inverter_bot.py1146
-rwxr-xr-xsrc/inverterd_emulator.py9
-rwxr-xr-xsrc/pump_bot.py137
-rwxr-xr-xsrc/sensors_bot.py105
19 files changed, 2074 insertions, 1115 deletions
diff --git a/doc/inverter_bot.md b/doc/inverter_bot.md
index 973fcf5..bd64607 100644
--- a/doc/inverter_bot.md
+++ b/doc/inverter_bot.md
@@ -1,3 +1,4 @@
+
# Inverter Bot
### Bot configuration
@@ -76,11 +77,6 @@ errors - Show errors
flags - Toggle flags
calcw - Calculate daily watts usage
calcwadv - Advanced watts usage calculator
-setbatuv - Set battery under voltage
-setgencc - Set AC charging current
-setgenct - Set AC charging thresholds
-setacmode - Set AC input mode
-setosp - Set output source priority
monstatus - Monitor: dump state
monsetcur - Monitor: set charging currents
``` \ No newline at end of file
diff --git a/src/admin_bot.py b/src/admin_bot.py
deleted file mode 100755
index 88e71e8..0000000
--- a/src/admin_bot.py
+++ /dev/null
@@ -1,35 +0,0 @@
-#!/usr/bin/env python3
-from typing import Optional
-from telegram import ReplyKeyboardMarkup
-from telegram.ext import MessageHandler
-from home.config import config
-from home.bot import Wrapper, Context, text_filter
-
-
-def get_latest_logs(ctx: Context):
- u = ctx.user
- ctx.reply(ctx.lang('blbla'))
-
-
-class AdminBot(Wrapper):
- def __init__(self):
- super().__init__()
-
- self.lang.ru(get_latest_logs="Смотреть последние логи")
- self.lang.en(get_latest_logs="Get latest logs")
-
- self.add_handler(MessageHandler(text_filter(self.lang('get_latest_logs')), self.wrap(get_latest_logs)))
-
- def markup(self, ctx: Optional[Context]) -> Optional[ReplyKeyboardMarkup]:
- buttons = [
- [self.lang('get_latest_logs')]
- ]
- return ReplyKeyboardMarkup(buttons, one_time_keyboard=False)
-
-
-if __name__ == '__main__':
- config.load('admin_bot')
-
- bot = AdminBot()
- # bot.enable_logging(BotType.ADMIN)
- bot.run()
diff --git a/src/home/bot/__init__.py b/src/home/bot/__init__.py
deleted file mode 100644
index 41ad78e..0000000
--- a/src/home/bot/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from .reporting import ReportingHelper
-from .lang import LangPack
-from .wrapper import Wrapper, Context, text_filter, handlermethod, IgnoreMarkup
-from .store import Store
-from .errors import *
-from .util import command_usage, user_any_name
diff --git a/src/home/bot/errors.py b/src/home/bot/errors.py
deleted file mode 100644
index 74eee6f..0000000
--- a/src/home/bot/errors.py
+++ /dev/null
@@ -1,2 +0,0 @@
-class StoreNotEnabledError(Exception):
- pass \ No newline at end of file
diff --git a/src/home/bot/reporting.py b/src/home/bot/reporting.py
deleted file mode 100644
index df3da2a..0000000
--- a/src/home/bot/reporting.py
+++ /dev/null
@@ -1,22 +0,0 @@
-import logging
-
-from telegram import Message
-from ..api import WebAPIClient as APIClient
-from ..api.errors import ApiResponseError
-from ..api.types import BotType
-
-logger = logging.getLogger(__name__)
-
-
-class ReportingHelper:
- def __init__(self, client: APIClient, bot_type: BotType):
- self.client = client
- self.bot_type = bot_type
-
- def report(self, message, text: str = None) -> None:
- if text is None:
- text = message.text
- try:
- self.client.log_bot_request(self.bot_type, message.chat_id, text)
- except ApiResponseError as error:
- logger.exception(error)
diff --git a/src/home/bot/util.py b/src/home/bot/util.py
deleted file mode 100644
index 4f80a67..0000000
--- a/src/home/bot/util.py
+++ /dev/null
@@ -1,57 +0,0 @@
-from telegram import User
-from .lang import LangStrings
-
-_strings = {
- 'en': LangStrings(
- usage='Usage',
- arguments='Arguments'
- ),
- 'ru': LangStrings(
- usage='Использование',
- arguments='Аргументы'
- )
-}
-
-
-def command_usage(command: str, arguments: dict, language='en') -> str:
- if language not in _strings:
- raise ValueError('unsupported language')
-
- blocks = []
- argument_names = []
- argument_lines = []
- for k, v in arguments.items():
- argument_names.append(k)
- argument_lines.append(
- f'<code>{k}</code>: {v}'
- )
-
- command = f'/{command}'
- if argument_names:
- command += ' ' + ' '.join(argument_names)
-
- blocks.append(
- f'<b>{_strings[language]["usage"]}</b>\n'
- f'<code>{command}</code>'
- )
-
- if argument_lines:
- blocks.append(
- f'<b>{_strings[language]["arguments"]}</b>\n' + '\n'.join(argument_lines)
- )
-
- return '\n\n'.join(blocks)
-
-
-def user_any_name(user: User) -> str:
- name = [user.first_name, user.last_name]
- name = list(filter(lambda s: s is not None, name))
- name = ' '.join(name).strip()
-
- if not name:
- name = user.username
-
- if not name:
- name = str(user.id)
-
- return name
diff --git a/src/home/bot/wrapper.py b/src/home/bot/wrapper.py
deleted file mode 100644
index 98946ed..0000000
--- a/src/home/bot/wrapper.py
+++ /dev/null
@@ -1,369 +0,0 @@
-import logging
-import traceback
-
-from html import escape
-from telegram import (
- Update,
- ParseMode,
- ReplyKeyboardMarkup,
- CallbackQuery,
- User,
- Message,
-)
-from telegram.ext import (
- Updater,
- Filters,
- BaseFilter,
- Handler,
- CommandHandler,
- MessageHandler,
- CallbackQueryHandler,
- CallbackContext,
- ConversationHandler
-)
-from telegram.error import TimedOut
-from ..config import config
-from typing import Optional, Union, List, Tuple
-from .store import Store
-from .lang import LangPack
-from ..api.types import BotType
-from ..api import WebAPIClient
-from .reporting import ReportingHelper
-
-logger = logging.getLogger(__name__)
-languages = {
- 'en': 'English',
- 'ru': 'Русский'
-}
-LANG_STARTED, = range(1)
-user_filter: Optional[BaseFilter] = None
-
-
-def default_langpack() -> LangPack:
- lang = LangPack()
- lang.en(
- start_message="Select command on the keyboard.",
- unknown_message="Unknown message",
- cancel="Cancel",
- select_language="Select language on the keyboard.",
- invalid_language="Invalid language. Please try again.",
- saved='Saved.',
- )
- lang.ru(
- start_message="Выберите команду на клавиатуре.",
- unknown_message="Неизвестная команда",
- cancel="Отмена",
- select_language="Выберите язык на клавиатуре.",
- invalid_language="Неверный язык. Пожалуйста, попробуйте снова",
- saved="Настройки сохранены."
- )
- return lang
-
-
-def init_user_filter():
- global user_filter
- if user_filter is None:
- if 'users' in config['bot']:
- logger.info('allowed users: ' + str(config['bot']['users']))
- user_filter = Filters.user(config['bot']['users'])
- else:
- user_filter = Filters.all # not sure if this is correct
-
-
-def text_filter(*args):
- init_user_filter()
- return Filters.text(args[0] if isinstance(args[0], list) else [*args]) & user_filter
-
-
-def exc2text(e: Exception) -> str:
- tb = ''.join(traceback.format_tb(e.__traceback__))
- return f'{e.__class__.__name__}: ' + escape(str(e)) + "\n\n" + escape(tb)
-
-
-class IgnoreMarkup:
- pass
-
-
-class Context:
- _update: Optional[Update]
- _callback_context: Optional[CallbackContext]
- _markup_getter: callable
- _lang: LangPack
- _store: Optional[Store]
- _user_lang: Optional[str]
-
- def __init__(self,
- update: Optional[Update],
- callback_context: Optional[CallbackContext],
- markup_getter: callable,
- lang: LangPack,
- store: Optional[Store]):
- self._update = update
- self._callback_context = callback_context
- self._markup_getter = markup_getter
- self._lang = lang
- self._store = store
- self._user_lang = None
-
- def reply(self, text, markup=None):
- if markup is None:
- markup = self._markup_getter(self)
- kwargs = dict(parse_mode=ParseMode.HTML)
- if not isinstance(markup, IgnoreMarkup):
- kwargs['reply_markup'] = markup
- return self._update.message.reply_text(text, **kwargs)
-
- def reply_exc(self, e: Exception) -> None:
- self.reply(exc2text(e))
-
- def answer(self, text: str = None):
- self.callback_query.answer(text)
-
- def edit(self, text, markup=None):
- kwargs = dict(parse_mode=ParseMode.HTML)
- if not isinstance(markup, IgnoreMarkup):
- kwargs['reply_markup'] = markup
- self.callback_query.edit_message_text(text, **kwargs)
-
- @property
- def text(self) -> str:
- return self._update.message.text
-
- @property
- def callback_query(self) -> CallbackQuery:
- return self._update.callback_query
-
- @property
- def args(self) -> Optional[List[str]]:
- return self._callback_context.args
-
- @property
- def user_id(self) -> int:
- return self.user.id
-
- @property
- def user(self) -> User:
- return self._update.effective_user
-
- @property
- def user_lang(self) -> str:
- if self._user_lang is None:
- self._user_lang = self._store.get_user_lang(self.user_id)
- return self._user_lang
-
- def lang(self, key: str, *args) -> str:
- return self._lang.get(key, self.user_lang, *args)
-
- def is_callback_context(self) -> bool:
- return self._update.callback_query and self._update.callback_query.data and self._update.callback_query.data != ''
-
-
-def handlermethod(f: callable):
- def _handler(self, update: Update, context: CallbackContext, *args, **kwargs):
- ctx = Context(update,
- callback_context=context,
- markup_getter=self.markup,
- lang=self.lang,
- store=self.store)
- try:
- return f(self, ctx, *args, **kwargs)
- except Exception as e:
- if not self.exception_handler(e, ctx) and not isinstance(e, TimedOut):
- logger.exception(e)
- if not ctx.is_callback_context():
- ctx.reply_exc(e)
- else:
- self.notify_user(ctx.user_id, exc2text(e))
- return _handler
-
-
-class Wrapper:
- store: Optional[Store]
- updater: Updater
- lang: LangPack
- reporting: Optional[ReportingHelper]
-
- def __init__(self,
- store: Optional[Store] = None):
- self.updater = Updater(config['bot']['token'],
- request_kwargs={'read_timeout': 6, 'connect_timeout': 7})
- self.lang = default_langpack()
- self.store = store if store else Store()
- self.reporting = None
-
- init_user_filter()
-
- dispatcher = self.updater.dispatcher
- dispatcher.add_handler(CommandHandler('start', self.wrap(self.start), user_filter))
-
- # transparently log all messages
- self.add_handler(MessageHandler(Filters.all & user_filter, self.logging_message_handler), group=10)
- self.add_handler(CallbackQueryHandler(self.logging_callback_handler), group=10)
-
- def run(self):
- self._lang_setup()
- self.updater.dispatcher.add_handler(
- MessageHandler(Filters.all & user_filter, self.wrap(self.any))
- )
-
- # start the bot
- self.updater.start_polling()
-
- # run the bot until the user presses Ctrl-C or the process receives SIGINT, SIGTERM or SIGABRT
- self.updater.idle()
-
- def enable_logging(self, bot_type: BotType):
- api = WebAPIClient(timeout=3)
- api.enable_async()
-
- self.reporting = ReportingHelper(api, bot_type)
-
- def logging_message_handler(self, update: Update, context: CallbackContext):
- if self.reporting is None:
- return
-
- self.reporting.report(update.message)
-
- def logging_callback_handler(self, update: Update, context: CallbackContext):
- if self.reporting is None:
- return
-
- self.reporting.report(update.callback_query.message, text=update.callback_query.data)
-
- def wrap(self, f: callable):
- def handler(update: Update, context: CallbackContext):
- ctx = Context(update,
- callback_context=context,
- markup_getter=self.markup,
- lang=self.lang,
- store=self.store)
-
- try:
- return f(ctx)
- except Exception as e:
- if not self.exception_handler(e, ctx) and not isinstance(e, TimedOut):
- logger.exception(e)
- if not ctx.is_callback_context():
- ctx.reply_exc(e)
- else:
- self.notify_user(ctx.user_id, exc2text(e))
-
- return handler
-
- def add_handler(self, handler: Handler, group=0):
- self.updater.dispatcher.add_handler(handler, group=group)
-
- def start(self, ctx: Context):
- if 'start_message' not in self.lang:
- ctx.reply('Please define start_message or override start()')
- return
-
- ctx.reply(ctx.lang('start_message'))
-
- def any(self, ctx: Context):
- if 'invalid_command' not in self.lang:
- ctx.reply('Please define invalid_command or override any()')
- return
-
- ctx.reply(ctx.lang('invalid_command'))
-
- def markup(self, ctx: Optional[Context]) -> Optional[ReplyKeyboardMarkup]:
- return None
-
- def exception_handler(self, e: Exception, ctx: Context) -> Optional[bool]:
- pass
-
- def notify_all(self, text_getter: callable, exclude: Tuple[int] = ()) -> None:
- if 'notify_users' not in config['bot']:
- logger.error('notify_all() called but no notify_users directive found in the config')
- return
-
- for user_id in config['bot']['notify_users']:
- if user_id in exclude:
- continue
-
- text = text_getter(self.store.get_user_lang(user_id))
- self.updater.bot.send_message(chat_id=user_id,
- text=text,
- parse_mode='HTML')
-
- def notify_user(self, user_id: int, text: Union[str, Exception], **kwargs) -> None:
- if isinstance(text, Exception):
- text = exc2text(text)
- self.updater.bot.send_message(chat_id=user_id, text=text, parse_mode='HTML', **kwargs)
-
- def send_photo(self, user_id, **kwargs):
- self.updater.bot.send_photo(chat_id=user_id, **kwargs)
-
- def send_audio(self, user_id, **kwargs):
- self.updater.bot.send_audio(chat_id=user_id, **kwargs)
-
- def send_file(self, user_id, **kwargs):
- self.updater.bot.send_document(chat_id=user_id, **kwargs)
-
- def edit_message_text(self, user_id, message_id, *args, **kwargs):
- self.updater.bot.edit_message_text(chat_id=user_id, message_id=message_id, parse_mode='HTML', *args, **kwargs)
-
- def delete_message(self, user_id, message_id):
- self.updater.bot.delete_message(chat_id=user_id, message_id=message_id)
-
- #
- # Language Selection
- #
-
- def _lang_setup(self):
- supported = self.lang.languages
- if len(supported) > 1:
- cancel_filter = Filters.text(self.lang.all('cancel'))
-
- self.add_handler(ConversationHandler(
- entry_points=[CommandHandler('lang', self.wrap(self._lang_command), user_filter)],
- states={
- LANG_STARTED: [
- *list(map(lambda key: MessageHandler(text_filter(languages[key]),
- self.wrap(self._lang_input)), supported)),
- MessageHandler(user_filter & ~cancel_filter, self.wrap(self._lang_invalid_input))
- ]
- },
- fallbacks=[MessageHandler(user_filter & cancel_filter, self.wrap(self._lang_cancel_input))]
- ))
-
- def _lang_command(self, ctx: Context):
- logger.debug(f'current language: {ctx.user_lang}')
-
- buttons = []
- for name in languages.values():
- buttons.append(name)
- markup = ReplyKeyboardMarkup([buttons, [ctx.lang('cancel')]], one_time_keyboard=False)
-
- ctx.reply(ctx.lang('select_language'), markup=markup)
- return LANG_STARTED
-
- def _lang_input(self, ctx: Context):
- lang = None
- for key, value in languages.items():
- if value == ctx.text:
- lang = key
- break
-
- if lang is None:
- raise ValueError('could not find the language')
-
- self.store.set_user_lang(ctx.user_id, lang)
-
- ctx.reply(ctx.lang('saved'), markup=IgnoreMarkup())
-
- self.start(ctx)
- return ConversationHandler.END
-
- def _lang_invalid_input(self, ctx: Context):
- ctx.reply(self.lang('invalid_language'), markup=IgnoreMarkup())
- return LANG_STARTED
-
- def _lang_cancel_input(self, ctx: Context):
- self.start(ctx)
- return ConversationHandler.END
-
- @property
- def user_filter(self):
- return user_filter
diff --git a/src/home/config/config.py b/src/home/config/config.py
index 7d18f99..0c0e944 100644
--- a/src/home/config/config.py
+++ b/src/home/config/config.py
@@ -105,6 +105,19 @@ class ConfigStore:
def __contains__(self, key):
return key in self.data
+ def get(self, key: str, default=None):
+ cur = self.data
+ pts = key.split('.')
+ for i in range(len(pts)):
+ k = pts[i]
+ if i < len(pts)-1:
+ if k not in cur:
+ raise KeyError(f'key {k} not found')
+ else:
+ return cur[k] if k in cur else default
+ cur = self.data[k]
+ raise KeyError(f'option {key} not found')
+
def items(self):
return self.data.items()
diff --git a/src/home/inverter/emulator.py b/src/home/inverter/emulator.py
new file mode 100644
index 0000000..e86b8bb
--- /dev/null
+++ b/src/home/inverter/emulator.py
@@ -0,0 +1,556 @@
+import asyncio
+import logging
+
+from inverterd import Format
+
+from typing import Union
+from enum import Enum
+from ..util import Addr, stringify
+
+
+class InverterEnum(Enum):
+ def as_text(self) -> str:
+ raise RuntimeError('abstract method')
+
+
+class BatteryType(InverterEnum):
+ AGM = 0
+ Flooded = 1
+ User = 2
+
+ def as_text(self) -> str:
+ return ('AGM', 'Flooded', 'User')[self.value]
+
+
+class InputVoltageRange(InverterEnum):
+ Appliance = 0
+ USP = 1
+
+ def as_text(self) -> str:
+ return ('Appliance', 'USP')[self.value]
+
+
+class OutputSourcePriority(InverterEnum):
+ SolarUtilityBattery = 0
+ SolarBatteryUtility = 1
+
+ def as_text(self) -> str:
+ return ('Solar-Utility-Battery', 'Solar-Battery-Utility')[self.value]
+
+
+class ChargeSourcePriority(InverterEnum):
+ SolarFirst = 0
+ SolarAndUtility = 1
+ SolarOnly = 2
+
+ def as_text(self) -> str:
+ return ('Solar-First', 'Solar-and-Utility', 'Solar-only')[self.value]
+
+
+class MachineType(InverterEnum):
+ OffGridTie = 0
+ GridTie = 1
+
+ def as_text(self) -> str:
+ return ('Off-Grid-Tie', 'Grid-Tie')[self.value]
+
+
+class Topology(InverterEnum):
+ TransformerLess = 0
+ Transformer = 1
+
+ def as_text(self) -> str:
+ return ('Transformer-less', 'Transformer')[self.value]
+
+
+class OutputMode(InverterEnum):
+ SingleOutput = 0
+ ParallelOutput = 1
+ Phase_1_of_3 = 2
+ Phase_2_of_3 = 3
+ Phase_3_of_3 = 4
+
+ def as_text(self) -> str:
+ return (
+ 'Single output',
+ 'Parallel output',
+ 'Phase 1 of 3-phase output',
+ 'Phase 2 of 3-phase output',
+ 'Phase 3 of 3-phase'
+ )[self.value]
+
+
+class SolarPowerPriority(InverterEnum):
+ BatteryLoadUtility = 0
+ LoadBatteryUtility = 1
+
+ def as_text(self) -> str:
+ return ('Battery-Load-Utility', 'Load-Battery-Utility')[self.value]
+
+
+class MPPTChargerStatus(InverterEnum):
+ Abnormal = 0
+ NotCharging = 1
+ Charging = 2
+
+ def as_text(self) -> str:
+ return ('Abnormal', 'Not charging', 'Charging')[self.value]
+
+
+class BatteryPowerDirection(InverterEnum):
+ DoNothing = 0
+ Charge = 1
+ Discharge = 2
+
+ def as_text(self) -> str:
+ return ('Do nothing', 'Charge', 'Discharge')[self.value]
+
+
+class DC_AC_PowerDirection(InverterEnum):
+ DoNothing = 0
+ AC_DC = 1
+ DC_AC = 2
+
+ def as_text(self) -> str:
+ return ('Do nothing', 'AC/DC', 'DC/AC')[self.value]
+
+
+class LinePowerDirection(InverterEnum):
+ DoNothing = 0
+ Input = 1
+ Output = 2
+
+ def as_text(self) -> str:
+ return ('Do nothing', 'Input', 'Output')[self.value]
+
+
+class WorkingMode(InverterEnum):
+ PowerOnMode = 0
+ StandbyMode = 1
+ BypassMode = 2
+ BatteryMode = 3
+ FaultMode = 4
+ HybridMode = 5
+
+ def as_text(self) -> str:
+ return (
+ 'Power on mode',
+ 'Standby mode',
+ 'Bypass mode',
+ 'Battery mode',
+ 'Fault mode',
+ 'Hybrid mode'
+ )[self.value]
+
+
+class ParallelConnectionStatus(InverterEnum):
+ NotExistent = 0
+ Existent = 1
+
+ def as_text(self) -> str:
+ return ('Non-existent', 'Existent')[self.value]
+
+
+class LoadConnectionStatus(InverterEnum):
+ Disconnected = 0
+ Connected = 1
+
+ def as_text(self) -> str:
+ return ('Disconnected', 'Connected')[self.value]
+
+
+class ConfigurationStatus(InverterEnum):
+ Default = 0
+ Changed = 1
+
+ def as_text(self) -> str:
+ return ('Default', 'Changed')[self.value]
+
+
+_g_human_readable = {"grid_voltage": "Grid voltage",
+ "grid_freq": "Grid frequency",
+ "ac_output_voltage": "AC output voltage",
+ "ac_output_freq": "AC output frequency",
+ "ac_output_apparent_power": "AC output apparent power",
+ "ac_output_active_power": "AC output active power",
+ "output_load_percent": "Output load percent",
+ "battery_voltage": "Battery voltage",
+ "battery_voltage_scc": "Battery voltage from SCC",
+ "battery_voltage_scc2": "Battery voltage from SCC2",
+ "battery_discharge_current": "Battery discharge current",
+ "battery_charge_current": "Battery charge current",
+ "battery_capacity": "Battery capacity",
+ "inverter_heat_sink_temp": "Inverter heat sink temperature",
+ "mppt1_charger_temp": "MPPT1 charger temperature",
+ "mppt2_charger_temp": "MPPT2 charger temperature",
+ "pv1_input_power": "PV1 input power",
+ "pv2_input_power": "PV2 input power",
+ "pv1_input_voltage": "PV1 input voltage",
+ "pv2_input_voltage": "PV2 input voltage",
+ "configuration_status": "Configuration state",
+ "mppt1_charger_status": "MPPT1 charger status",
+ "mppt2_charger_status": "MPPT2 charger status",
+ "load_connected": "Load connection",
+ "battery_power_direction": "Battery power direction",
+ "dc_ac_power_direction": "DC/AC power direction",
+ "line_power_direction": "Line power direction",
+ "local_parallel_id": "Local parallel ID",
+ "ac_input_rating_voltage": "AC input rating voltage",
+ "ac_input_rating_current": "AC input rating current",
+ "ac_output_rating_voltage": "AC output rating voltage",
+ "ac_output_rating_freq": "AC output rating frequency",
+ "ac_output_rating_current": "AC output rating current",
+ "ac_output_rating_apparent_power": "AC output rating apparent power",
+ "ac_output_rating_active_power": "AC output rating active power",
+ "battery_rating_voltage": "Battery rating voltage",
+ "battery_recharge_voltage": "Battery re-charge voltage",
+ "battery_redischarge_voltage": "Battery re-discharge voltage",
+ "battery_under_voltage": "Battery under voltage",
+ "battery_bulk_voltage": "Battery bulk voltage",
+ "battery_float_voltage": "Battery float voltage",
+ "battery_type": "Battery type",
+ "max_charge_current": "Max charge current",
+ "max_ac_charge_current": "Max AC charge current",
+ "input_voltage_range": "Input voltage range",
+ "output_source_priority": "Output source priority",
+ "charge_source_priority": "Charge source priority",
+ "parallel_max_num": "Parallel max num",
+ "machine_type": "Machine type",
+ "topology": "Topology",
+ "output_mode": "Output mode",
+ "solar_power_priority": "Solar power priority",
+ "mppt": "MPPT string",
+ "fault_code": "Fault code",
+ "line_fail": "Line fail",
+ "output_circuit_short": "Output circuit short",
+ "inverter_over_temperature": "Inverter over temperature",
+ "fan_lock": "Fan lock",
+ "battery_voltage_high": "Battery voltage high",
+ "battery_low": "Battery low",
+ "battery_under": "Battery under",
+ "over_load": "Over load",
+ "eeprom_fail": "EEPROM fail",
+ "power_limit": "Power limit",
+ "pv1_voltage_high": "PV1 voltage high",
+ "pv2_voltage_high": "PV2 voltage high",
+ "mppt1_overload_warning": "MPPT1 overload warning",
+ "mppt2_overload_warning": "MPPT2 overload warning",
+ "battery_too_low_to_charge_for_scc1": "Battery too low to charge for SCC1",
+ "battery_too_low_to_charge_for_scc2": "Battery too low to charge for SCC2",
+ "buzzer": "Buzzer",
+ "overload_bypass": "Overload bypass function",
+ "escape_to_default_screen_after_1min_timeout": "Escape to default screen after 1min timeout",
+ "overload_restart": "Overload restart",
+ "over_temp_restart": "Over temperature restart",
+ "backlight_on": "Backlight on",
+ "alarm_on_on_primary_source_interrupt": "Alarm on on primary source interrupt",
+ "fault_code_record": "Fault code record",
+ "wh": "Wh"}
+
+
+class InverterEmulator:
+ def __init__(self, addr: Addr, wait=True):
+ self.status = {"grid_voltage": {"unit": "V", "value": 236.3},
+ "grid_freq": {"unit": "Hz", "value": 50.0},
+ "ac_output_voltage": {"unit": "V", "value": 229.9},
+ "ac_output_freq": {"unit": "Hz", "value": 50.0},
+ "ac_output_apparent_power": {"unit": "VA", "value": 207},
+ "ac_output_active_power": {"unit": "Wh", "value": 146},
+ "output_load_percent": {"unit": "%", "value": 4},
+ "battery_voltage": {"unit": "V", "value": 49.1},
+ "battery_voltage_scc": {"unit": "V", "value": 0.0},
+ "battery_voltage_scc2": {"unit": "V", "value": 0.0},
+ "battery_discharge_current": {"unit": "A", "value": 3},
+ "battery_charge_current": {"unit": "A", "value": 0},
+ "battery_capacity": {"unit": "%", "value": 69},
+ "inverter_heat_sink_temp": {"unit": "°C", "value": 17},
+ "mppt1_charger_temp": {"unit": "°C", "value": 0},
+ "mppt2_charger_temp": {"unit": "°C", "value": 0},
+ "pv1_input_power": {"unit": "Wh", "value": 0},
+ "pv2_input_power": {"unit": "Wh", "value": 0},
+ "pv1_input_voltage": {"unit": "V", "value": 0.0},
+ "pv2_input_voltage": {"unit": "V", "value": 0.0},
+ "configuration_status": ConfigurationStatus.Default,
+ "mppt1_charger_status": MPPTChargerStatus.Abnormal,
+ "mppt2_charger_status": MPPTChargerStatus.Abnormal,
+ "load_connected": LoadConnectionStatus.Connected,
+ "battery_power_direction": BatteryPowerDirection.Discharge,
+ "dc_ac_power_direction": DC_AC_PowerDirection.DC_AC,
+ "line_power_direction": LinePowerDirection.DoNothing,
+ "local_parallel_id": 0}
+
+ self.rated = {"ac_input_rating_voltage": {"unit": "V", "value": 230.0},
+ "ac_input_rating_current": {"unit": "A", "value": 21.7},
+ "ac_output_rating_voltage": {"unit": "V", "value": 230.0},
+ "ac_output_rating_freq": {"unit": "Hz", "value": 50.0},
+ "ac_output_rating_current": {"unit": "A", "value": 21.7},
+ "ac_output_rating_apparent_power": {"unit": "VA", "value": 5000},
+ "ac_output_rating_active_power": {"unit": "Wh", "value": 5000},
+ "battery_rating_voltage": {"unit": "V", "value": 48.0},
+ "battery_recharge_voltage": {"unit": "V", "value": 48.0},
+ "battery_redischarge_voltage": {"unit": "V", "value": 55.0},
+ "battery_under_voltage": {"unit": "V", "value": 42.0},
+ "battery_bulk_voltage": {"unit": "V", "value": 57.6},
+ "battery_float_voltage": {"unit": "V", "value": 54.0},
+ "battery_type": BatteryType.User,
+ "max_charge_current": {"unit": "A", "value": 60},
+ "max_ac_charge_current": {"unit": "A", "value": 30},
+ "input_voltage_range": InputVoltageRange.Appliance,
+ "output_source_priority": OutputSourcePriority.SolarBatteryUtility,
+ "charge_source_priority": ChargeSourcePriority.SolarAndUtility,
+ "parallel_max_num": 6,
+ "machine_type": MachineType.OffGridTie,
+ "topology": Topology.TransformerLess,
+ "output_mode": OutputMode.SingleOutput,
+ "solar_power_priority": SolarPowerPriority.LoadBatteryUtility,
+ "mppt": "2"}
+
+ self.errors = {"fault_code": 0,
+ "line_fail": False,
+ "output_circuit_short": False,
+ "inverter_over_temperature": False,
+ "fan_lock": False,
+ "battery_voltage_high": False,
+ "battery_low": False,
+ "battery_under": False,
+ "over_load": False,
+ "eeprom_fail": False,
+ "power_limit": False,
+ "pv1_voltage_high": False,
+ "pv2_voltage_high": False,
+ "mppt1_overload_warning": False,
+ "mppt2_overload_warning": False,
+ "battery_too_low_to_charge_for_scc1": False,
+ "battery_too_low_to_charge_for_scc2": False}
+
+ self.flags = {"buzzer": False,
+ "overload_bypass": True,
+ "escape_to_default_screen_after_1min_timeout": False,
+ "overload_restart": True,
+ "over_temp_restart": True,
+ "backlight_on": False,
+ "alarm_on_on_primary_source_interrupt": True,
+ "fault_code_record": False}
+
+ self.day_generated = 1000
+
+ self.logger = logging.getLogger(self.__class__.__name__)
+
+ host, port = addr
+ asyncio.run(self.run_server(host, port, wait))
+ # self.max_ac_charge_current = 30
+ # self.max_charge_current = 60
+ # self.charge_thresholds = [48, 54]
+
+ async def run_server(self, host, port, wait: bool):
+ server = await asyncio.start_server(self.client_handler, host, port)
+ async with server:
+ self.logger.info(f'listening on {host}:{port}')
+ if wait:
+ await server.serve_forever()
+ else:
+ asyncio.ensure_future(server.serve_forever())
+
+ async def client_handler(self, reader, writer):
+ client_fmt = Format.JSON
+
+ def w(s: str):
+ writer.write(s.encode('utf-8'))
+
+ def return_error(message=None):
+ w('err\r\n')
+ if message:
+ if client_fmt in (Format.JSON, Format.SIMPLE_JSON):
+ w(stringify({
+ 'result': 'error',
+ 'message': message
+ }))
+ elif client_fmt in (Format.TABLE, Format.SIMPLE_TABLE):
+ w(f'error: {message}')
+ w('\r\n')
+ w('\r\n')
+
+ def return_ok(data=None):
+ w('ok\r\n')
+ if client_fmt in (Format.JSON, Format.SIMPLE_JSON):
+ jdata = {
+ 'result': 'ok'
+ }
+ if data:
+ jdata['data'] = data
+ w(stringify(jdata))
+ w('\r\n')
+ elif data:
+ w(data)
+ w('\r\n')
+ w('\r\n')
+
+ request = None
+ while request != 'quit':
+ try:
+ request = await reader.read(255)
+ if request == b'\x04':
+ break
+ request = request.decode('utf-8').strip()
+ except Exception:
+ break
+
+ if request.startswith('format '):
+ requested_format = request[7:]
+ try:
+ client_fmt = Format(requested_format)
+ except ValueError:
+ return_error('invalid format')
+
+ return_ok()
+
+ elif request.startswith('exec '):
+ buf = request[5:].split(' ')
+ command = buf[0]
+ args = buf[1:]
+
+ try:
+ return_ok(self.process_command(client_fmt, command, *args))
+ except ValueError as e:
+ return_error(str(e))
+
+ else:
+ return_error(f'invalid token: {request}')
+
+ try:
+ await writer.drain()
+ except ConnectionResetError as e:
+ # self.logger.exception(e)
+ pass
+
+ writer.close()
+
+ def process_command(self, fmt: Format, c: str, *args) -> Union[dict, str, list[int], None]:
+ ac_charge_currents = [2, 10, 20, 30, 40, 50, 60]
+
+ if c == 'get-status':
+ return self.format_dict(self.status, fmt)
+
+ elif c == 'get-rated':
+ return self.format_dict(self.rated, fmt)
+
+ elif c == 'get-errors':
+ return self.format_dict(self.errors, fmt)
+
+ elif c == 'get-flags':
+ return self.format_dict(self.flags, fmt)
+
+ elif c == 'get-day-generated':
+ return self.format_dict({'wh': 1000}, fmt)
+
+ elif c == 'get-allowed-ac-charge-currents':
+ return self.format_list(ac_charge_currents, fmt)
+
+ elif c == 'set-max-ac-charge-current':
+ if int(args[0]) != 0:
+ raise ValueError(f'invalid machine id: {args[0]}')
+ amps = int(args[1])
+ if amps not in ac_charge_currents:
+ raise ValueError(f'invalid value: {amps}')
+ self.rated['max_ac_charge_current']['value'] = amps
+
+ elif c == 'set-charge-thresholds':
+ self.rated['battery_recharge_voltage']['value'] = float(args[0])
+ self.rated['battery_redischarge_voltage']['value'] = float(args[1])
+
+ elif c == 'set-output-source-priority':
+ self.rated['output_source_priority'] = OutputSourcePriority.SolarBatteryUtility if args[0] == 'SBU' else OutputSourcePriority.SolarUtilityBattery
+
+ elif c == 'set-battery-cutoff-voltage':
+ self.rated['battery_under_voltage']['value'] = float(args[0])
+
+ elif c == 'set-flag':
+ flag = args[0]
+ val = bool(int(args[1]))
+
+ if flag == 'BUZZ':
+ k = 'buzzer'
+ elif flag == 'OLBP':
+ k = 'overload_bypass'
+ elif flag == 'LCDE':
+ k = 'escape_to_default_screen_after_1min_timeout'
+ elif flag == 'OLRS':
+ k = 'overload_restart'
+ elif flag == 'OTRS':
+ k = 'over_temp_restart'
+ elif flag == 'BLON':
+ k = 'backlight_on'
+ elif flag == 'ALRM':
+ k = 'alarm_on_on_primary_source_interrupt'
+ elif flag == 'FTCR':
+ k = 'fault_code_record'
+ else:
+ raise ValueError('invalid flag')
+
+ self.flags[k] = val
+
+ else:
+ raise ValueError(f'{c}: unsupported command')
+
+ @staticmethod
+ def format_list(values: list, fmt: Format) -> Union[str, list]:
+ if fmt in (Format.JSON, Format.SIMPLE_JSON):
+ return values
+ return '\n'.join(map(lambda v: str(v), values))
+
+ @staticmethod
+ def format_dict(data: dict, fmt: Format) -> Union[str, dict]:
+ new_data = {}
+ for k, v in data.items():
+ new_val = None
+ if fmt in (Format.JSON, Format.TABLE, Format.SIMPLE_TABLE):
+ if isinstance(v, dict):
+ new_val = v
+ elif isinstance(v, InverterEnum):
+ new_val = v.as_text()
+ else:
+ new_val = v
+ elif fmt == Format.SIMPLE_JSON:
+ if isinstance(v, dict):
+ new_val = v['value']
+ elif isinstance(v, InverterEnum):
+ new_val = v.value
+ else:
+ new_val = str(v)
+ new_data[k] = new_val
+
+ if fmt in (Format.JSON, Format.SIMPLE_JSON):
+ return new_data
+
+ lines = []
+
+ if fmt == Format.SIMPLE_TABLE:
+ for k, v in new_data.items():
+ buf = k
+ if isinstance(v, dict):
+ buf += ' ' + str(v['value']) + ' ' + v['unit']
+ elif isinstance(v, InverterEnum):
+ buf += ' ' + v.as_text()
+ else:
+ buf += ' ' + str(v)
+ lines.append(buf)
+
+ elif fmt == Format.TABLE:
+ max_k_len = 0
+ for k in new_data.keys():
+ if len(_g_human_readable[k]) > max_k_len:
+ max_k_len = len(_g_human_readable[k])
+ for k, v in new_data.items():
+ buf = _g_human_readable[k] + ':'
+ buf += ' ' * (max_k_len - len(_g_human_readable[k]) + 1)
+ if isinstance(v, dict):
+ buf += str(v['value']) + ' ' + v['unit']
+ elif isinstance(v, InverterEnum):
+ buf += v.as_text()
+ elif isinstance(v, bool):
+ buf += str(int(v))
+ else:
+ buf += str(v)
+ lines.append(buf)
+
+ return '\n'.join(lines)
diff --git a/src/home/telegram/__init__.py b/src/home/telegram/__init__.py
index 8565b40..a68dae1 100644
--- a/src/home/telegram/__init__.py
+++ b/src/home/telegram/__init__.py
@@ -1 +1 @@
-from .telegram import send_message, send_photo \ No newline at end of file
+from .telegram import send_message, send_photo
diff --git a/src/home/telegram/_botcontext.py b/src/home/telegram/_botcontext.py
new file mode 100644
index 0000000..f343eeb
--- /dev/null
+++ b/src/home/telegram/_botcontext.py
@@ -0,0 +1,85 @@
+from typing import Optional, List
+
+from telegram import Update, ParseMode, User, CallbackQuery
+from telegram.ext import CallbackContext
+
+from ._botdb import BotDatabase
+from ._botlang import lang
+from ._botutil import IgnoreMarkup, exc2text
+
+
+class Context:
+ _update: Optional[Update]
+ _callback_context: Optional[CallbackContext]
+ _markup_getter: callable
+ db: Optional[BotDatabase]
+ _user_lang: Optional[str]
+
+ def __init__(self,
+ update: Optional[Update],
+ callback_context: Optional[CallbackContext],
+ markup_getter: callable,
+ store: Optional[BotDatabase]):
+ self._update = update
+ self._callback_context = callback_context
+ self._markup_getter = markup_getter
+ self._store = store
+ self._user_lang = None
+
+ def reply(self, text, markup=None):
+ if markup is None:
+ markup = self._markup_getter(self)
+ kwargs = dict(parse_mode=ParseMode.HTML)
+ if not isinstance(markup, IgnoreMarkup):
+ kwargs['reply_markup'] = markup
+ return self._update.message.reply_text(text, **kwargs)
+
+ def reply_exc(self, e: Exception) -> None:
+ self.reply(exc2text(e), markup=IgnoreMarkup())
+
+ def answer(self, text: str = None):
+ self.callback_query.answer(text)
+
+ def edit(self, text, markup=None):
+ kwargs = dict(parse_mode=ParseMode.HTML)
+ if not isinstance(markup, IgnoreMarkup):
+ kwargs['reply_markup'] = markup
+ self.callback_query.edit_message_text(text, **kwargs)
+
+ @property
+ def text(self) -> str:
+ return self._update.message.text
+
+ @property
+ def callback_query(self) -> CallbackQuery:
+ return self._update.callback_query
+
+ @property
+ def args(self) -> Optional[List[str]]:
+ return self._callback_context.args
+
+ @property
+ def user_id(self) -> int:
+ return self.user.id
+
+ @property
+ def user_data(self):
+ return self._callback_context.user_data
+
+ @property
+ def user(self) -> User:
+ return self._update.effective_user
+
+ @property
+ def user_lang(self) -> str:
+ if self._user_lang is None:
+ self._user_lang = self._store.get_user_lang(self.user_id)
+ return self._user_lang
+
+ def lang(self, key: str, *args) -> str:
+ return lang.get(key, self.user_lang, *args)
+
+ def is_callback_context(self) -> bool:
+ return self._update.callback_query \
+ and self._update.callback_query.data \
+ and self._update.callback_query.data != ''
diff --git a/src/home/bot/store.py b/src/home/telegram/_botdb.py
index e655d8f..9e9cf94 100644
--- a/src/home/bot/store.py
+++ b/src/home/telegram/_botdb.py
@@ -1,7 +1,7 @@
-from ..database.sqlite import SQLiteBase
+from home.database.sqlite import SQLiteBase
-class Store(SQLiteBase):
+class BotDatabase(SQLiteBase):
def __init__(self):
super().__init__()
diff --git a/src/home/bot/lang.py b/src/home/telegram/_botlang.py
index 624c748..318b8b0 100644
--- a/src/home/bot/lang.py
+++ b/src/home/telegram/_botlang.py
@@ -1,10 +1,8 @@
-from __future__ import annotations
-
import logging
-from typing import Union, Optional, List, Dict
+from typing import Optional, Dict, List, Union
-logger = logging.getLogger(__name__)
+_logger = logging.getLogger(__name__)
class LangStrings(dict):
@@ -18,7 +16,7 @@ class LangStrings(dict):
self._lang = lang
def __missing__(self, key):
- logger.warning(f'key {key} is missing in language {self._lang}')
+ _logger.warning(f'key {key} is missing in language {self._lang}')
return '{%s}' % key
def __setitem__(self, key, value):
@@ -79,3 +77,41 @@ class LangPack:
def __contains__(self, key):
return key in self.strings[self.default_lang]
+
+ @staticmethod
+ def pfx(prefix: str, l: list) -> list:
+ return list(map(lambda s: f'{prefix}{s}', l))
+
+
+
+languages = {
+ 'en': 'English',
+ 'ru': 'Русский'
+}
+
+
+lang = LangPack()
+lang.en(
+ en='English',
+ ru='Russian',
+ start_message="Select command on the keyboard.",
+ unknown_message="Unknown message",
+ cancel="🚫 Cancel",
+ back='🔙 Back',
+ select_language="Select language on the keyboard.",
+ invalid_language="Invalid language. Please try again.",
+ saved='Saved.',
+ please_wait="⏳ Please wait..."
+)
+lang.ru(
+ en='Английский',
+ ru='Русский',
+ start_message="Выберите команду на клавиатуре.",
+ unknown_message="Неизвестная команда",
+ cancel="🚫 Отмена",
+ back='🔙 Назад',
+ select_language="Выберите язык на клавиатуре.",
+ invalid_language="Неверный язык. Пожалуйста, попробуйте снова",
+ saved="Настройки сохранены.",
+ please_wait="⏳ Ожидайте..."
+) \ No newline at end of file
diff --git a/src/home/telegram/_botutil.py b/src/home/telegram/_botutil.py
new file mode 100644
index 0000000..6d1ee8f
--- /dev/null
+++ b/src/home/telegram/_botutil.py
@@ -0,0 +1,47 @@
+import logging
+import traceback
+
+from html import escape
+from telegram import User
+from home.api import WebAPIClient as APIClient
+from home.api.types import BotType
+from home.api.errors import ApiResponseError
+
+_logger = logging.getLogger(__name__)
+
+
+def user_any_name(user: User) -> str:
+ name = [user.first_name, user.last_name]
+ name = list(filter(lambda s: s is not None, name))
+ name = ' '.join(name).strip()
+
+ if not name:
+ name = user.username
+
+ if not name:
+ name = str(user.id)
+
+ return name
+
+
+class ReportingHelper:
+ def __init__(self, client: APIClient, bot_type: BotType):
+ self.client = client
+ self.bot_type = bot_type
+
+ def report(self, message, text: str = None) -> None:
+ if text is None:
+ text = message.text
+ try:
+ self.client.log_bot_request(self.bot_type, message.chat_id, text)
+ except ApiResponseError as error:
+ _logger.exception(error)
+
+
+def exc2text(e: Exception) -> str:
+ tb = ''.join(traceback.format_tb(e.__traceback__))
+ return f'{e.__class__.__name__}: ' + escape(str(e)) + "\n\n" + escape(tb)
+
+
+class IgnoreMarkup:
+ pass
diff --git a/src/home/telegram/bot.py b/src/home/telegram/bot.py
new file mode 100644
index 0000000..602573b
--- /dev/null
+++ b/src/home/telegram/bot.py
@@ -0,0 +1,542 @@
+from __future__ import annotations
+
+import logging
+
+from enum import Enum, auto
+from functools import wraps
+from typing import Optional, Union, List, Tuple, Dict
+
+from telegram import (
+ Update,
+ ParseMode,
+ ReplyKeyboardMarkup,
+ CallbackQuery,
+ User,
+ Message,
+)
+from telegram.ext import (
+ Updater,
+ Filters,
+ BaseFilter,
+ Handler,
+ CommandHandler,
+ MessageHandler,
+ CallbackQueryHandler,
+ CallbackContext,
+ ConversationHandler
+)
+from telegram.error import TimedOut
+
+from home.config import config
+from home.api import WebAPIClient
+from home.api.types import BotType
+from home.api.errors import ApiResponseError
+
+from ._botlang import lang, languages
+from ._botdb import BotDatabase
+from ._botutil import ReportingHelper, exc2text, IgnoreMarkup
+from ._botcontext import Context
+
+
+# LANG_STARTED, = range(1)
+
+user_filter: Optional[BaseFilter] = None
+cancel_filter = Filters.text(lang.all('cancel'))
+back_filter = Filters.text(lang.all('back'))
+cancel_and_back_filter = Filters.text(lang.all('back') + lang.all('cancel'))
+
+db: Optional[BotDatabase] = None
+
+_logger = logging.getLogger(__name__)
+_updater: Optional[Updater] = None
+_reporting: Optional[ReportingHelper] = None
+_exception_handler: Optional[callable] = None
+_dispatcher = None
+_markup_getter: Optional[callable] = None
+_start_handler_ref: Optional[callable] = None
+
+
+def text_filter(*args):
+ if not user_filter:
+ raise RuntimeError('user_filter is not initialized')
+ return Filters.text(args[0] if isinstance(args[0], list) else [*args]) & user_filter
+
+
+def _handler_of_handler(*args, **kwargs):
+ self = None
+ context = None
+ update = None
+
+ _args = list(args)
+ while len(_args):
+ v = _args[0]
+ if isinstance(v, conversation):
+ self = v
+ _args.pop(0)
+ elif isinstance(v, Update):
+ update = v
+ _args.pop(0)
+ elif isinstance(v, CallbackContext):
+ context = v
+ _args.pop(0)
+ break
+
+ ctx = Context(update,
+ callback_context=context,
+ markup_getter=lambda _ctx: None if not _markup_getter else _markup_getter(_ctx),
+ store=db)
+ try:
+ _args.insert(0, ctx)
+ if self:
+ _args.insert(0, self)
+
+ f = kwargs['f']
+ del kwargs['f']
+
+ if 'return_with_context' in kwargs:
+ return_with_context = True
+ del kwargs['return_with_context']
+ else:
+ return_with_context = False
+
+ result = f(*_args, **kwargs)
+ return result if not return_with_context else (result, ctx)
+
+ except Exception as e:
+ if _exception_handler:
+ if not _exception_handler(e, ctx) and not isinstance(e, TimedOut):
+ _logger.exception(e)
+ if not ctx.is_callback_context():
+ ctx.reply_exc(e)
+ else:
+ notify_user(ctx.user_id, exc2text(e))
+
+
+def handler(**kwargs):
+ def inner(f):
+ @wraps(f)
+ def _handler(*args, **kwargs):
+ return _handler_of_handler(f=f, *args, **kwargs)
+
+ if 'message' in kwargs:
+ _updater.dispatcher.add_handler(MessageHandler(text_filter(lang.all(kwargs['message'])), _handler), group=0)
+ elif 'command' in kwargs:
+ _updater.dispatcher.add_handler(CommandHandler(kwargs['command'], _handler), group=0)
+ elif 'callback' in kwargs:
+ _updater.dispatcher.add_handler(CallbackQueryHandler(_handler), group=0)
+ return _handler
+ return inner
+
+
+def simplehandler(f: callable):
+ @wraps(f)
+ def _handler(*args, **kwargs):
+ return _handler_of_handler(f=f, *args, **kwargs)
+ return _handler
+
+
+def callbackhandler(f: callable):
+ @wraps(f)
+ def _handler(*args, **kwargs):
+ return _handler_of_handler(f=f, *args, **kwargs)
+ _updater.dispatcher.add_handler(CallbackQueryHandler(_handler), group=0)
+ return _handler
+
+
+def exceptionhandler(f: callable):
+ global _exception_handler
+ if _exception_handler:
+ _logger.warning('exception handler already set, we will overwrite it')
+ _exception_handler = f
+
+
+def defaultreplymarkup(f: callable):
+ global _markup_getter
+ _markup_getter = f
+
+
+def convinput(state, is_enter=False, **kwargs):
+ def inner(f):
+ f.__dict__['_conv_data'] = dict(
+ orig_f=f,
+ enter=is_enter,
+ type=ConversationMethodType.ENTRY if is_enter and state == 0 else ConversationMethodType.STATE_HANDLER,
+ state=state,
+ **kwargs
+ )
+
+ @wraps(f)
+ def _impl(*args, **kwargs):
+ result, ctx = _handler_of_handler(f=f, *args, **kwargs, return_with_context=True)
+ if result == conversation.END:
+ start(ctx)
+ return result
+
+ return _impl
+
+ return inner
+
+
+def conventer(state, **kwargs):
+ return convinput(state, is_enter=True, **kwargs)
+
+
+class ConversationMethodType(Enum):
+ ENTRY = auto()
+ STATE_HANDLER = auto()
+
+
+class conversation:
+ END = ConversationHandler.END
+ STATE_SEQS = []
+
+ def __init__(self, enable_back=False):
+ self._logger = logging.getLogger(self.__class__.__name__)
+ self._user_state_cache = {}
+ self._back_enabled = enable_back
+
+ def make_handlers(self, f: callable, **kwargs) -> list:
+ messages = {}
+ handlers = []
+
+ if 'messages' in kwargs:
+ if isinstance(kwargs['messages'], dict):
+ messages = kwargs['messages']
+ else:
+ for m in kwargs['messages']:
+ messages[m] = None
+
+ if 'message' in kwargs:
+ if isinstance(kwargs['message'], str):
+ messages[kwargs['message']] = None
+ else:
+ AttributeError('invalid message type: ' + type(kwargs['message']))
+
+ if messages:
+ for message, target_state in messages.items():
+ if not target_state:
+ handlers.append(MessageHandler(text_filter(lang.all(message) if 'messages_lang_completed' not in kwargs else message), f))
+ else:
+ handlers.append(MessageHandler(text_filter(lang.all(message) if 'messages_lang_completed' not in kwargs else message), self.make_invoker(target_state)))
+
+ if 'regex' in kwargs:
+ handlers.append(MessageHandler(Filters.regex(kwargs['regex']) & user_filter, f))
+
+ if 'command' in kwargs:
+ handlers.append(CommandHandler(kwargs['command'], f, user_filter))
+
+ return handlers
+
+ def make_invoker(self, state):
+ def _invoke(update: Update, context: CallbackContext):
+ ctx = Context(update,
+ callback_context=context,
+ markup_getter=lambda _ctx: None if not _markup_getter else _markup_getter(_ctx),
+ store=db)
+ return self.invoke(state, ctx)
+ return _invoke
+
+ def invoke(self, state, ctx: Context):
+ self._logger.debug(f'invoke, state={state}')
+ for item in dir(self):
+ f = getattr(self, item)
+ if not callable(f) or item.startswith('_') or '_conv_data' not in f.__dict__:
+ continue
+ cd = f.__dict__['_conv_data']
+ if cd['enter'] and cd['state'] == state:
+ return cd['orig_f'](self, ctx)
+
+ raise RuntimeError(f'invoke: failed to find method for state {state}')
+
+ def get_handler(self) -> ConversationHandler:
+ entry_points = []
+ states = {}
+
+ l_cancel_filter = cancel_filter if not self._back_enabled else cancel_and_back_filter
+
+ for item in dir(self):
+ f = getattr(self, item)
+ if not callable(f) or item.startswith('_') or '_conv_data' not in f.__dict__:
+ continue
+
+ cd = f.__dict__['_conv_data']
+
+ if cd['type'] == ConversationMethodType.ENTRY:
+ entry_points = self.make_handlers(f, **cd)
+ elif cd['type'] == ConversationMethodType.STATE_HANDLER:
+ states[cd['state']] = self.make_handlers(f, **cd)
+ states[cd['state']].append(
+ MessageHandler(user_filter & ~l_cancel_filter, conversation.invalid)
+ )
+
+ fallbacks = [MessageHandler(user_filter & cancel_filter, self.cancel)]
+ if self._back_enabled:
+ fallbacks.append(MessageHandler(user_filter & back_filter, self.back))
+
+ return ConversationHandler(
+ entry_points=entry_points,
+ states=states,
+ fallbacks=fallbacks
+ )
+
+ def get_user_state(self, user_id: int) -> Optional[int]:
+ if user_id not in self._user_state_cache:
+ return None
+ return self._user_state_cache[user_id]
+
+ # TODO store in ctx.user_state
+ def set_user_state(self, user_id: int, state: Union[int, None]):
+ if not self._back_enabled:
+ return
+ if state is not None:
+ self._user_state_cache[user_id] = state
+ else:
+ del self._user_state_cache[user_id]
+
+ @staticmethod
+ @simplehandler
+ def invalid(ctx: Context):
+ ctx.reply(ctx.lang('invalid_input'), markup=IgnoreMarkup())
+ # return 0 # FIXME is this needed
+
+ @simplehandler
+ def cancel(self, ctx: Context):
+ start(ctx)
+ self.set_user_state(ctx.user_id, None)
+ return conversation.END
+
+ @simplehandler
+ def back(self, ctx: Context):
+ cur_state = self.get_user_state(ctx.user_id)
+ if cur_state is None:
+ start(ctx)
+ self.set_user_state(ctx.user_id, None)
+ return conversation.END
+
+ new_state = None
+ for seq in self.STATE_SEQS:
+ if cur_state in seq:
+ idx = seq.index(cur_state)
+ if idx > 0:
+ return self.invoke(seq[idx-1], ctx)
+
+ if new_state is None:
+ raise RuntimeError('failed to determine state to go back to')
+
+ @classmethod
+ def add_cancel_button(cls, ctx: Context, buttons):
+ buttons.append([ctx.lang('cancel')])
+
+ @classmethod
+ def add_back_button(cls, ctx: Context, buttons):
+ # buttons.insert(0, [ctx.lang('back')])
+ buttons.append([ctx.lang('back')])
+
+ def reply(self,
+ ctx: Context,
+ state: Union[int, Enum],
+ text: str,
+ buttons: Optional[list],
+ with_cancel=False,
+ with_back=False,
+ buttons_lang_completed=False):
+
+ if buttons:
+ new_buttons = []
+ if not buttons_lang_completed:
+ for item in buttons:
+ if isinstance(item, list):
+ item = map(lambda s: ctx.lang(s), item)
+ new_buttons.append(list(item))
+ elif isinstance(item, str):
+ new_buttons.append([ctx.lang(item)])
+ else:
+ raise ValueError('invalid type: ' + type(item))
+ else:
+ new_buttons = list(buttons)
+
+ buttons = None
+ else:
+ if with_cancel or with_back:
+ new_buttons = []
+ else:
+ new_buttons = None
+
+ if with_cancel:
+ self.add_cancel_button(ctx, new_buttons)
+ if with_back:
+ if not self._back_enabled:
+ raise AttributeError(f'back is not enabled for this conversation ({self.__class__.__name__})')
+ self.add_back_button(ctx, new_buttons)
+
+ markup = ReplyKeyboardMarkup(new_buttons, one_time_keyboard=True) if new_buttons else IgnoreMarkup()
+ ctx.reply(text, markup=markup)
+ self.set_user_state(ctx.user_id, state)
+ return state
+
+
+class LangConversation(conversation):
+ START, = range(1)
+
+ @conventer(START, command='lang')
+ def entry(self, ctx: Context):
+ self._logger.debug(f'current language: {ctx.user_lang}')
+
+ buttons = []
+ for name in languages.values():
+ buttons.append(name)
+ markup = ReplyKeyboardMarkup([buttons, [ctx.lang('cancel')]], one_time_keyboard=False)
+
+ ctx.reply(ctx.lang('select_language'), markup=markup)
+ return self.START
+
+ @convinput(START, messages=lang.languages)
+ def input(self, ctx: Context):
+ selected_lang = None
+ for key, value in languages.items():
+ if value == ctx.text:
+ selected_lang = key
+ break
+
+ if selected_lang is None:
+ raise ValueError('could not find the language')
+
+ db.set_user_lang(ctx.user_id, selected_lang)
+ ctx.reply(ctx.lang('saved'), markup=IgnoreMarkup())
+
+ return self.END
+
+
+def initialize():
+ global user_filter
+ global _updater
+ global _dispatcher
+
+ # init user_filter
+ if 'users' in config['bot']:
+ _logger.info('allowed users: ' + str(config['bot']['users']))
+ user_filter = Filters.user(config['bot']['users'])
+ else:
+ user_filter = Filters.all # not sure if this is correct
+
+ # init updater
+ _updater = Updater(config['bot']['token'],
+ request_kwargs={'read_timeout': 6, 'connect_timeout': 7})
+
+ # transparently log all messages
+ _updater.dispatcher.add_handler(MessageHandler(Filters.all & user_filter, _logging_message_handler), group=10)
+ _updater.dispatcher.add_handler(CallbackQueryHandler(_logging_callback_handler), group=10)
+
+
+def run(start_handler=None, any_handler=None):
+ global db
+ global _start_handler_ref
+
+ if not start_handler:
+ start_handler = _default_start_handler
+ if not any_handler:
+ any_handler = _default_any_handler
+ if not db:
+ db = BotDatabase()
+
+ _start_handler_ref = start_handler
+
+ _updater.dispatcher.add_handler(LangConversation().get_handler(), group=0)
+ _updater.dispatcher.add_handler(CommandHandler('start', simplehandler(start_handler), user_filter))
+ _updater.dispatcher.add_handler(MessageHandler(Filters.all & user_filter, any_handler))
+
+ _updater.start_polling()
+ _updater.idle()
+
+
+def add_conversation(conv: conversation) -> None:
+ _updater.dispatcher.add_handler(conv.get_handler(), group=0)
+
+
+def start(ctx: Context):
+ return _start_handler_ref(ctx)
+
+
+def _default_start_handler(ctx: Context):
+ if 'start_message' not in lang:
+ return ctx.reply('Please define start_message or override start()')
+ ctx.reply(ctx.lang('start_message'))
+
+
+@simplehandler
+def _default_any_handler(ctx: Context):
+ if 'invalid_command' not in lang:
+ return ctx.reply('Please define invalid_command or override any()')
+ ctx.reply(ctx.lang('invalid_command'))
+
+
+def _logging_message_handler(update: Update, context: CallbackContext):
+ if _reporting:
+ _reporting.report(update.message)
+
+
+def _logging_callback_handler(update: Update, context: CallbackContext):
+ if _reporting:
+ _reporting.report(update.callback_query.message, text=update.callback_query.data)
+
+
+def enable_logging(bot_type: BotType):
+ api = WebAPIClient(timeout=3)
+ api.enable_async()
+
+ global _reporting
+ _reporting = ReportingHelper(api, bot_type)
+
+
+def notify_all(text_getter: callable,
+ exclude: Tuple[int] = ()) -> None:
+ if 'notify_users' not in config['bot']:
+ _logger.error('notify_all() called but no notify_users directive found in the config')
+ return
+
+ for user_id in config['bot']['notify_users']:
+ if user_id in exclude:
+ continue
+
+ text = text_getter(db.get_user_lang(user_id))
+ _updater.bot.send_message(chat_id=user_id,
+ text=text,
+ parse_mode='HTML')
+
+
+def notify_user(user_id: int, text: Union[str, Exception], **kwargs) -> None:
+ if isinstance(text, Exception):
+ text = exc2text(text)
+ _updater.bot.send_message(chat_id=user_id,
+ text=text,
+ parse_mode='HTML',
+ **kwargs)
+
+
+def send_photo(user_id, **kwargs):
+ _updater.bot.send_photo(chat_id=user_id, **kwargs)
+
+
+def send_audio(user_id, **kwargs):
+ _updater.bot.send_audio(chat_id=user_id, **kwargs)
+
+
+def send_file(user_id, **kwargs):
+ _updater.bot.send_document(chat_id=user_id, **kwargs)
+
+
+def edit_message_text(user_id, message_id, *args, **kwargs):
+ _updater.bot.edit_message_text(chat_id=user_id,
+ message_id=message_id,
+ parse_mode='HTML',
+ *args, **kwargs)
+
+
+def delete_message(user_id, message_id):
+ _updater.bot.delete_message(chat_id=user_id, message_id=message_id)
+
+
+def set_database(_db: BotDatabase):
+ global db
+ db = _db
+
diff --git a/src/inverter_bot.py b/src/inverter_bot.py
index 3df720c..c2e138f 100755
--- a/src/inverter_bot.py
+++ b/src/inverter_bot.py
@@ -3,19 +3,17 @@ import logging
import re
import datetime
import json
+import itertools
+from enum import Enum
from inverterd import Format, InverterError
from html import escape
from typing import Optional, Tuple, Union
+
+from home.util import chunks
from home.config import config
-from home.bot import (
- Wrapper,
- Context,
- text_filter,
- command_usage,
- Store,
- IgnoreMarkup
-)
+from home.telegram import bot
+from home.telegram._botlang import LangStrings
from home.inverter import (
wrapper_instance as inverter,
beautify_table,
@@ -28,18 +26,12 @@ from home.inverter.types import (
ACMode,
OutputSourcePriority
)
+from home.database.inverter_time_formats import *
from home.api.types import BotType
+from home.api import WebAPIClient
from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton
-from telegram.ext import (
- MessageHandler,
- CommandHandler,
- CallbackQueryHandler,
- ConversationHandler,
- Filters
-)
monitor: Optional[InverterMonitor] = None
-bot: Optional[Wrapper] = None
db = None
LT = escape('<=')
flags_map = {
@@ -52,10 +44,262 @@ flags_map = {
'alarm_on_on_primary_source_interrupt': 'ALRM',
'fault_code_record': 'FTCR',
}
+
logger = logging.getLogger(__name__)
+config.load('inverter_bot')
+
+bot.initialize()
+bot.lang.ru(
+ status='Статус',
+ generation='Генерация',
+ priority='Приоритет',
+ # osp='Приоритет питания нагрузки',
+ battery="АКБ",
+ load="Нагрузка",
+ generator="Генератор",
+ utilities="Столб",
+ consumption="Статистика потребления",
+ settings="Настройки",
+ done="Готово",
+ unexpected_callback_data="Ошибка: неверные данные",
+ # settings_osp_msg="Выберите режим:",
+ invalid_input="Неверное значение",
+ invalid_mode="Invalid mode",
+
+ flags_press_button='Нажмите кнопку для переключения настройки',
+ flags_fail='Не удалось установить настройку',
+ flags_invalid='Неизвестная настройка',
+
+ # generation
+ gen_input_power='Зарядная мощность',
+
+ # settings
+ settings_msg="Что вы хотите настроить?",
+ settings_osp='Приоритет питания нагрузки',
+ settings_ac_preset="Применить шаблон режима AC",
+ settings_bat_thresholds="Пороги заряда АКБ от AC",
+ settings_bat_cut_off_voltage="Порог отключения АКБ",
+ settings_ac_max_charging_current="Максимальный ток заряда от AC",
+
+ settings_osp_msg="Установите приоритет:",
+ settings_osp_sub='Solar-Utility-Battery',
+ settings_osp_sbu='Solar-Battery-Utility',
+
+ settings_select_bottom_threshold="Выберите нижний порог:",
+ settings_select_upper_threshold="Выберите верхний порог:",
+ settings_select_max_current='Выберите максимальный ток:',
+ settings_enter_cutoff_voltage=f'Введите напряжение V, где 40.0 {LT} V {LT} 48.0',
+
+ # time and date
+ today='Сегодня',
+ yday1='Вчера',
+ yday2='Позавчера',
+ for_7days='За 7 дней',
+ for_30days='За 30 дней',
+ # to_select_interval='Выбрать интервал',
+
+ # consumption
+ consumption_msg="Выберите тип:",
+ consumption_total="Общее",
+ consumption_grid="Со столба",
+ consumption_select_interval='Выберите период:',
+ consumption_request_sent="⏳ Запрос отправлен...",
+
+ # status
+ charging_at=', ',
+ pd_charging='заряжается',
+ pd_discharging='разряжается',
+ pd_nothing='не используется',
+
+ # flags
+ flag_buzzer='Звуковой сигнал',
+ flag_overload_bypass='Разрешить перегрузку',
+ flag_escape_to_default_screen_after_1min_timeout='Возврат на главный экран через 1 минуту',
+ flag_overload_restart='Перезапуск при перегрузке',
+ flag_over_temp_restart='Перезапуск при перегреве',
+ flag_backlight_on='Подсветка экрана',
+ flag_alarm_on_on_primary_source_interrupt='Сигнал при разрыве основного источника питания',
+ flag_fault_code_record='Запись кодов ошибок',
+
+ # commands
+ setbatuv_v=f'напряжение, 40.0 {LT} V {LT} 48.0',
+ setgenct_cv=f'напряжение включения заряда, 44 {LT} CV {LT} 51',
+ setgenct_dv=f'напряжение отключения заряда, 48 {LT} DV {LT} 58',
+ setgencc_a='максимальный ток заряда, допустимые значения: %s',
+
+ # monitor
+ chrg_evt_started='✅ Начали заряжать от генератора.',
+ chrg_evt_finished='✅ Зарядили. Генератор пора выключать.',
+ chrg_evt_disconnected='ℹ️ Генератор отключен.',
+ chrg_evt_current_changed='ℹ️ Ток заряда от генератора установлен в %d A.',
+ chrg_evt_not_charging='ℹ️ Генератор подключен, но не заряжает.',
+ chrg_evt_na_solar='⛔️ Генератор подключен, но аккумуляторы не заряжаются из-за подключенных панелей.',
+ chrg_evt_mostly_charged='✅ Аккумуляторы более-менее заряжены, генератор пора выключать.',
+ battery_level_changed='Уровень заряда АКБ: <b>%s %s</b> (<b>%0.1f V</b> при нагрузке <b>%d W</b>)',
+ error_message='<b>Ошибка:</b> %s.',
+
+ util_chrg_evt_started='✅ Начали заряжать от столба.',
+ util_chrg_evt_stopped='ℹ️ Перестали заряжать от столба.',
+ util_chrg_evt_stopped_solar='ℹ️ Перестали заряжать от столба из-за подключения панелей.',
+
+ util_connected='✅️ Столб подключён.',
+ util_disconnected='‼️ Столб отключён.',
+
+ # other notifications
+ ac_mode_changed_notification='Пользователь <a href="tg://user?id=%d">%s</a> установил режим AC: <b>%s</b>.',
+ osp_changed_notification='Пользователь <a href="tg://user?id=%d">%s</a> установил приоритет источника питания нагрузки: <b>%s</b>.',
+ osp_auto_changed_notification='ℹ️ Бот установил приоритет источника питания нагрузки: <b>%s</b>. Причины: напряжение АКБ %.1f V, мощность заряда с панелей %d W.',
+
+ bat_state_normal='Нормальный',
+ bat_state_low='Низкий',
+ bat_state_critical='Критический',
+)
+
+bot.lang.en(
+ status='Status',
+ generation='Generation',
+ priority='Priority',
+ battery="Battery",
+ load="Load",
+ generator="Generator",
+ utilities="Utilities",
+ consumption="Consumption statistics",
+ settings="Settings",
+ done="Done",
+ unexpected_callback_data="Unexpected callback data",
+ select_priortiy="Select priority:",
+ invalid_input="Invalid input",
+ invalid_mode="Invalid mode",
+
+ flags_press_button='Press a button to toggle a flag.',
+ flags_fail='Failed to toggle flag',
+ flags_invalid='Invalid flag',
+
+ # settings
+ settings_msg='What do you want to configure?',
+ settings_osp='Output source priority',
+ settings_ac_preset="AC preset",
+ settings_bat_thresholds="Battery charging thresholds",
+ settings_bat_cut_off_voltage="Battery cut-off voltage",
+ settings_ac_max_charging_current="Max AC charging current",
+
+ settings_osp_msg="Select priority:",
+ settings_osp_sub='Solar-Utility-Battery',
+ settings_osp_sbu='Solar-Battery-Utility',
+
+ settings_select_bottom_threshold="Select bottom (lower) threshold:",
+ settings_select_upper_threshold="Select top (upper) threshold:",
+ settings_select_max_current='Select max current:',
+ settings_enter_cutoff_voltage=f'Enter voltage V (40.0 {LT} V {LT} 48.0):',
+
+ # generation
+ gen_input_power='Input power',
+
+ # time and date
+ today='Today',
+ yday1='Yesterday',
+ yday2='The day before yesterday',
+ for_7days='7 days',
+ for_30days='30 days',
+ # to_select_interval='Select interval',
+
+ # consumption
+ consumption_msg="Select type:",
+ consumption_total="Total",
+ consumption_grid="Grid",
+ consumption_select_interval='Select period:',
+ consumption_request_sent="⏳ Request sent...",
+
+ # status
+ charging_at=' @ ',
+ pd_charging='charging',
+ pd_discharging='discharging',
+ pd_nothing='not used',
+
+ # 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',
+
+ # commands
+ setbatuv_v=f'floating point number, 40.0 {LT} V {LT} 48.0',
+ setgenct_cv=f'charging voltage, 44 {LT} CV {LT} 51',
+ setgenct_dv=f'discharging voltage, 48 {LT} DV {LT} 58',
+ setgencc_a='max charging current, allowed values: %s',
+
+ # monitor
+ chrg_evt_started='✅ Started charging from AC.',
+ chrg_evt_finished='✅ Finished charging, it\'s time to stop the generator.',
+ 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: <b>%s</b> (<b>%0.1f V</b> under <b>%d W</b> load)',
+ error_message='<b>Error:</b> %s.',
+
+ util_chrg_evt_started='✅ Started charging from utilities.',
+ util_chrg_evt_stopped='ℹ️ Stopped charging from utilities.',
+ util_chrg_evt_stopped_solar='ℹ️ Stopped charging from utilities because solar panels were connected.',
+
+ util_connected='✅️ Utilities connected.',
+ util_disconnected='‼️ Utilities disconnected.',
+
+ # other notifications
+ ac_mode_changed_notification='User <a href="tg://user?id=%d">%s</a> set AC mode to <b>%s</b>.',
+ osp_changed_notification='User <a href="tg://user?id=%d">%s</a> set output source priority: <b>%s</b>.',
+ osp_auto_changed_notification='Bot changed output source priority to <b>%s</b>. Reasons: battery voltage is %.1f V, solar input is %d W.',
+
+ bat_state_normal='Normal',
+ bat_state_low='Low',
+ bat_state_critical='Critical',
+)
-SETACMODE_STARTED, = range(1)
-SETOSP_STARTED, = range(1)
+
+def command_usage(command: str, arguments: dict, language='en') -> str:
+ _strings = {
+ 'en': LangStrings(
+ usage='Usage',
+ arguments='Arguments'
+ ),
+ 'ru': LangStrings(
+ usage='Использование',
+ arguments='Аргументы'
+ )
+ }
+
+ if language not in _strings:
+ raise ValueError('unsupported language')
+
+ blocks = []
+ argument_names = []
+ argument_lines = []
+ for k, v in arguments.items():
+ argument_names.append(k)
+ argument_lines.append(
+ f'<code>{k}</code>: {v}'
+ )
+
+ command = f'/{command}'
+ if argument_names:
+ command += ' ' + ' '.join(argument_names)
+
+ blocks.append(
+ f'<b>{_strings[language]["usage"]}</b>\n'
+ f'<code>{command}</code>'
+ )
+
+ if argument_lines:
+ blocks.append(
+ f'<b>{_strings[language]["arguments"]}</b>\n' + '\n'.join(argument_lines)
+ )
+
+ return '\n\n'.join(blocks)
def monitor_charging(event: ChargingEvent, **kwargs) -> None:
@@ -139,32 +383,36 @@ def osp_change_cb(new_osp: OutputSourcePriority,
bot.notify_all(
lambda lang: bot.lang.get('osp_auto_changed_notification', lang,
- bot.lang.get(f'setosp_{new_osp.value.lower()}', lang), v, solar_input),
+ bot.lang.get(f'settings_osp_{new_osp.value.lower()}', lang), v, solar_input),
)
-def full_status(ctx: Context) -> None:
+@bot.handler(command='status')
+def full_status(ctx: bot.Context) -> None:
status = inverter.exec('get-status', format=Format.TABLE)
ctx.reply(beautify_table(status))
-def full_rated(ctx: Context) -> None:
+@bot.handler(command='config')
+def full_rated(ctx: bot.Context) -> None:
rated = inverter.exec('get-rated', format=Format.TABLE)
ctx.reply(beautify_table(rated))
-def full_errors(ctx: Context) -> None:
+@bot.handler(command='errors')
+def full_errors(ctx: bot.Context) -> None:
errors = inverter.exec('get-errors', format=Format.TABLE)
ctx.reply(beautify_table(errors))
-def flags(ctx: Context) -> None:
+@bot.handler(command='flags')
+def flags_handler(ctx: bot.Context) -> None:
flags = inverter.exec('get-flags')['data']
text, markup = build_flags_keyboard(flags, ctx)
ctx.reply(text, markup=markup)
-def build_flags_keyboard(flags: dict, ctx: Context) -> Tuple[str, InlineKeyboardMarkup]:
+def build_flags_keyboard(flags: dict, ctx: bot.Context) -> Tuple[str, InlineKeyboardMarkup]:
keyboard = []
for k, v in flags.items():
label = ('✅' if v else '❌') + ' ' + ctx.lang(f'flag_{k}')
@@ -174,120 +422,8 @@ def build_flags_keyboard(flags: dict, ctx: Context) -> Tuple[str, InlineKeyboard
return ctx.lang('flags_press_button'), InlineKeyboardMarkup(keyboard)
-def status(ctx: Context) -> None:
- gs = inverter.exec('get-status')['data']
- rated = inverter.exec('get-rated')['data']
-
- # render response
- power_direction = gs['battery_power_direction'].lower()
- power_direction = re.sub(r'ge$', 'ging', power_direction)
-
- charging_rate = ''
- chrg_at = ctx.lang('charging_at')
-
- if power_direction == 'charging':
- charging_rate = f'{chrg_at}%s %s' % (
- gs['battery_charge_current']['value'], gs['battery_charge_current']['unit'])
- pd_label = ctx.lang('pd_charging')
- elif power_direction == 'discharging':
- charging_rate = f'{chrg_at}%s %s' % (
- gs['battery_discharge_current']['value'], gs['battery_discharge_current']['unit'])
- pd_label = ctx.lang('pd_discharging')
- else:
- pd_label = ctx.lang('pd_nothing')
-
- html = f'<b>{ctx.lang("battery")}:</b> %s %s' % (gs['battery_voltage']['value'], gs['battery_voltage']['unit'])
- html += ' (%s%s)' % (pd_label, charging_rate)
-
- html += f'\n<b>{ctx.lang("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 += f'\n<b>{ctx.lang("gen_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:
- ac_mode = getacmode()
- html += f'\n<b>{ctx.lang(ac_mode.value)}:</b> %s %s' % (gs['grid_voltage']['unit'], gs['grid_voltage']['value'])
- html += ', %s %s' % (gs['grid_freq']['value'], gs['grid_freq']['unit'])
-
- html += f'\n<b>{ctx.lang("priority")}</b>: {rated["output_source_priority"]}'
-
- # send response
- ctx.reply(html)
-
-
-def generation(ctx: Context) -> None:
- today = datetime.date.today()
- yday = today - datetime.timedelta(days=1)
- yday2 = today - datetime.timedelta(days=2)
-
- gs = inverter.exec('get-status')['data']
-
- 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:
- gen_yday = inverter.exec('get-day-generated', (yday.year, yday.month, yday.day))['data']
-
- if yday2.month == today.month:
- gen_yday2 = inverter.exec('get-day-generated', (yday2.year, yday2.month, yday2.day))['data']
-
- # render response
- html = f'<b>{ctx.lang("gen_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 += f'\n<b>{ctx.lang("gen_today")}:</b> %s Wh' % (gen_today['wh'])
-
- if gen_yday is not None:
- html += f'\n<b>{ctx.lang("gen_yday1")}:</b> %s Wh' % (gen_yday['wh'])
-
- if gen_yday2 is not None:
- html += f'\n<b>{ctx.lang("gen_yday2")}:</b> %s Wh' % (gen_yday2['wh'])
-
- # send response
- ctx.reply(html)
-
-
-def setgencc(ctx: Context) -> None:
- allowed_values = inverter.exec('get-allowed-ac-charge-currents')['data']
-
- try:
- current = int(ctx.args[0])
- if current not in allowed_values:
- raise ValueError(f'invalid value {current}')
-
- response = inverter.exec('set-max-ac-charge-current', (0, current))
- ctx.reply('OK' if response['result'] == 'ok' else 'ERROR')
-
- # TODO notify monitor
-
- except (IndexError, ValueError):
- ctx.reply(command_usage('setgencc', {
- 'A': ctx.lang('setgencc_a', ', '.join(map(lambda x: str(x), allowed_values)))
- }, language=ctx.user_lang))
-
-
-def setgenct(ctx: Context) -> None:
- try:
- cv = float(ctx.args[0])
- dv = float(ctx.args[1])
-
- if 44 <= cv <= 51 and 48 <= dv <= 58:
- response = inverter.exec('set-charge-thresholds', (cv, dv))
- ctx.reply('OK' if response['result'] == 'ok' else 'ERROR')
- else:
- raise ValueError('invalid values')
-
- except (IndexError, ValueError):
- ctx.reply(command_usage('setgenct', {
- 'CV': ctx.lang('setgenct_cv'),
- 'DV': ctx.lang('setgenct_dv')
- }, language=ctx.user_lang))
-
-
def getacmode() -> ACMode:
- return ACMode(db.get_param('ac_mode', default=ACMode.GENERATOR))
+ return ACMode(bot.db.get_param('ac_mode', default=ACMode.GENERATOR))
def setacmode(mode: ACMode):
@@ -308,135 +444,287 @@ def setosp(sp: OutputSourcePriority):
monitor.notify_osp(sp)
-# /setacmode
-# ----------
-
-def setacmode_start(ctx: Context) -> None:
- if monitor.active_current is not None:
- raise RuntimeError('generator charging program is active')
-
- buttons = []
- for mode in ACMode:
- buttons.append(ctx.lang(str(mode.value)))
- markup = ReplyKeyboardMarkup([buttons, [ctx.lang('cancel')]], one_time_keyboard=False)
-
- ctx.reply(ctx.lang('select_ac_mode'), markup=markup)
- return SETACMODE_STARTED
-
-
-def setacmode_input(ctx: Context):
- if monitor.active_current is not None:
- raise RuntimeError('generator charging program is active')
-
- if ctx.text == ctx.lang('utilities'):
- newmode = ACMode.UTILITIES
- elif ctx.text == ctx.lang('generator'):
- newmode = ACMode.GENERATOR
- else:
- raise ValueError('invalid mode')
-
- # apply the mode
- setacmode(newmode)
-
- # save
- db.set_param('ac_mode', str(newmode.value))
-
- # reply to user
- ctx.reply(ctx.lang('saved'), markup=IgnoreMarkup())
-
- # notify other users
- bot.notify_all(
- lambda lang: bot.lang.get('ac_mode_changed_notification', lang,
- ctx.user.id, ctx.user.name,
- bot.lang.get(str(newmode.value), lang)),
- exclude=(ctx.user_id,)
- )
-
- bot.start(ctx)
- return ConversationHandler.END
-
-
-def setacmode_invalid(ctx: Context):
- ctx.reply(ctx.lang('invalid_mode'), markup=IgnoreMarkup())
- return SETACMODE_STARTED
-
-
-def setacmode_cancel(ctx: Context):
- bot.start(ctx)
- return ConversationHandler.END
-
-
-# /setosp
-# ------
-
-def setosp_start(ctx: Context) -> None:
- buttons = []
- for sp in OutputSourcePriority:
- buttons.append(ctx.lang(f'setosp_{sp.value.lower()}'))
- markup = ReplyKeyboardMarkup([buttons, [ctx.lang('cancel')]], one_time_keyboard=False)
-
- ctx.reply(ctx.lang('select_ac_mode'), markup=markup)
- return SETOSP_STARTED
-
-
-def setosp_input(ctx: Context):
- selected_sp = None
- for sp in OutputSourcePriority:
- if ctx.text == ctx.lang(f'setosp_{sp.value.lower()}'):
- selected_sp = sp
- break
-
- if selected_sp is None:
- raise ValueError('invalid sp')
-
- # apply the mode
- setosp(selected_sp)
-
- # reply to user
- ctx.reply(ctx.lang('saved'), markup=IgnoreMarkup())
-
- # notify other users
- bot.notify_all(
- lambda lang: bot.lang.get('osp_changed_notification', lang,
- ctx.user.id, ctx.user.name,
- bot.lang.get(f'setosp_{selected_sp.value.lower()}', lang)),
- exclude=(ctx.user_id,)
- )
-
- bot.start(ctx)
- return ConversationHandler.END
+class SettingsConversation(bot.conversation):
+ START, OSP, AC_PRESET, BAT_THRESHOLDS_1, BAT_THRESHOLDS_2, BAT_CUT_OFF_VOLTAGE, AC_MAX_CHARGING_CURRENT = range(7)
+ STATE_SEQS = [
+ [START, OSP],
+ [START, AC_PRESET],
+ [START, BAT_THRESHOLDS_1, BAT_THRESHOLDS_2],
+ [START, BAT_CUT_OFF_VOLTAGE],
+ [START, AC_MAX_CHARGING_CURRENT]
+ ]
+
+ START_BUTTONS = bot.lang.pfx('settings_', ['ac_preset',
+ 'ac_max_charging_current',
+ 'bat_thresholds',
+ 'bat_cut_off_voltage',
+ 'osp'])
+ OSP_BUTTONS = bot.lang.pfx('settings_osp_', [sp.value.lower() for sp in OutputSourcePriority])
+ AC_PRESET_BUTTONS = [mode.value for mode in ACMode]
+
+ RECHARGE_VOLTAGES = [44, 45, 46, 47, 48, 49, 50, 51]
+ REDISCHARGE_VOLTAGES = [48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58]
+
+ @bot.conventer(START, message='settings')
+ def start_enter(self, ctx: bot.Context):
+ buttons = list(chunks(list(self.START_BUTTONS), 2))
+ buttons.reverse()
+ return self.reply(ctx, self.START, ctx.lang('settings_msg'), buttons,
+ with_cancel=True)
+
+ @bot.convinput(START, messages={
+ 'settings_osp': OSP,
+ 'settings_ac_preset': AC_PRESET,
+ 'settings_bat_thresholds': BAT_THRESHOLDS_1,
+ 'settings_bat_cut_off_voltage': BAT_CUT_OFF_VOLTAGE,
+ 'settings_ac_max_charging_current': AC_MAX_CHARGING_CURRENT
+ })
+ def start_input(self, ctx: bot.Context):
+ pass
+
+ @bot.conventer(OSP)
+ def osp_enter(self, ctx: bot.Context):
+ return self.reply(ctx, self.OSP, ctx.lang('settings_osp_msg'), self.OSP_BUTTONS,
+ with_back=True)
+
+ @bot.convinput(OSP, messages=OSP_BUTTONS)
+ def osp_input(self, ctx: bot.Context):
+ selected_sp = None
+ for sp in OutputSourcePriority:
+ if ctx.text == ctx.lang(f'settings_osp_{sp.value.lower()}'):
+ selected_sp = sp
+ break
+ if selected_sp is None:
+ raise ValueError('invalid sp')
-def setosp_invalid(ctx: Context):
- ctx.reply(ctx.lang('invalid_input'), markup=IgnoreMarkup())
- return SETOSP_STARTED
+ # apply the mode
+ setosp(selected_sp)
+ # reply to user
+ ctx.reply(ctx.lang('saved'), markup=bot.IgnoreMarkup())
-def setosp_cancel(ctx: Context):
- bot.start(ctx)
- return ConversationHandler.END
+ # notify other users
+ bot.notify_all(
+ lambda lang: bot.lang.get('osp_changed_notification', lang,
+ ctx.user.id, ctx.user.name,
+ bot.lang.get(f'settings_osp_{selected_sp.value.lower()}', lang)),
+ exclude=(ctx.user_id,)
+ )
+ return self.END
+
+ @bot.conventer(AC_PRESET)
+ def acpreset_enter(self, ctx: bot.Context):
+ return self.reply(ctx, self.AC_PRESET, ctx.lang('settings_ac_preset_msg'), self.AC_PRESET_BUTTONS,
+ with_back=True)
+
+ @bot.convinput(AC_PRESET, messages=AC_PRESET_BUTTONS)
+ def acpreset_input(self, ctx: bot.Context):
+ if monitor.active_current is not None:
+ raise RuntimeError('generator charging program is active')
+
+ if ctx.text == ctx.lang('utilities'):
+ newmode = ACMode.UTILITIES
+ elif ctx.text == ctx.lang('generator'):
+ newmode = ACMode.GENERATOR
+ else:
+ raise ValueError('invalid mode')
+ # apply the mode
+ setacmode(newmode)
-# other
-# -----
+ # save
+ bot.db.set_param('ac_mode', str(newmode.value))
-def setbatuv(ctx: Context) -> None:
- try:
- v = float(ctx.args[0])
+ # reply to user
+ ctx.reply(ctx.lang('saved'), markup=bot.IgnoreMarkup())
+ # notify other users
+ bot.notify_all(
+ lambda lang: bot.lang.get('ac_mode_changed_notification', lang,
+ ctx.user.id, ctx.user.name,
+ bot.lang.get(str(newmode.value), lang)),
+ exclude=(ctx.user_id,)
+ )
+ return self.END
+
+ @bot.conventer(BAT_THRESHOLDS_1)
+ def thresholds1_enter(self, ctx: bot.Context):
+ buttons = list(map(lambda v: f'{v} V', self.RECHARGE_VOLTAGES))
+ buttons = chunks(buttons, 4)
+ return self.reply(ctx, self.BAT_THRESHOLDS_1, ctx.lang('settings_select_bottom_threshold'), buttons,
+ with_back=True, buttons_lang_completed=True)
+
+ @bot.convinput(BAT_THRESHOLDS_1,
+ messages=list(map(lambda n: f'{n} V', RECHARGE_VOLTAGES)),
+ messages_lang_completed=True)
+ def thresholds1_input(self, ctx: bot.Context):
+ v = self._parse_voltage(ctx.text)
+ ctx.user_data['bat_thrsh_v1'] = v
+ return self.invoke(self.BAT_THRESHOLDS_2, ctx)
+
+ @bot.conventer(BAT_THRESHOLDS_2)
+ def thresholds2_enter(self, ctx: bot.Context):
+ buttons = list(map(lambda v: f'{v} V', self.REDISCHARGE_VOLTAGES))
+ buttons = chunks(buttons, 4)
+ return self.reply(ctx, self.BAT_THRESHOLDS_2, ctx.lang('settings_select_upper_threshold'), buttons,
+ with_back=True, buttons_lang_completed=True)
+
+ @bot.convinput(BAT_THRESHOLDS_2,
+ messages=list(map(lambda n: f'{n} V', REDISCHARGE_VOLTAGES)),
+ messages_lang_completed=True)
+ def thresholds2_input(self, ctx: bot.Context):
+ v2 = v = self._parse_voltage(ctx.text)
+ v1 = ctx.user_data['bat_thrsh_v1']
+ del ctx.user_data['bat_thrsh_v1']
+
+ response = inverter.exec('set-charge-thresholds', (v1, v2))
+ ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR',
+ markup=bot.IgnoreMarkup())
+ return self.END
+
+ @bot.conventer(AC_MAX_CHARGING_CURRENT)
+ def ac_max_enter(self, ctx: bot.Context):
+ buttons = self._get_allowed_ac_charge_amps()
+ buttons = map(lambda n: f'{n} A', buttons)
+ buttons = [list(buttons)]
+ return self.reply(ctx, self.AC_MAX_CHARGING_CURRENT, ctx.lang('settings_select_max_current'), buttons,
+ with_back=True, buttons_lang_completed=True)
+
+ @bot.convinput(AC_MAX_CHARGING_CURRENT, regex=r'^\d+ A$')
+ def ac_max_input(self, ctx: bot.Context):
+ a = self._parse_amps(ctx.text)
+ allowed = self._get_allowed_ac_charge_amps()
+ if a not in allowed:
+ raise ValueError('input is not allowed')
+
+ response = inverter.exec('set-max-ac-charge-current', (0, a))
+ ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR',
+ markup=bot.IgnoreMarkup())
+ return self.END
+
+ @bot.conventer(BAT_CUT_OFF_VOLTAGE)
+ def cutoff_enter(self, ctx: bot.Context):
+ return self.reply(ctx, self.BAT_CUT_OFF_VOLTAGE, ctx.lang('settings_enter_cutoff_voltage'), None,
+ with_back=True)
+
+ @bot.convinput(BAT_CUT_OFF_VOLTAGE, regex=r'^(\d{2}(\.\d{1})?)$')
+ def cutoff_input(self, ctx: bot.Context):
+ v = float(ctx.text)
if 40.0 <= v <= 48.0:
response = inverter.exec('set-battery-cutoff-voltage', (v,))
- ctx.reply('OK' if response['result'] == 'ok' else 'ERROR')
+ ctx.reply(ctx.lang('saved') if response['result'] == 'ok' else 'ERROR',
+ markup=bot.IgnoreMarkup())
else:
raise ValueError('invalid voltage')
- except (IndexError, ValueError):
- ctx.reply(command_usage('setbatuv', {
- 'V': ctx.lang('setbatuv_v')
- }, language=ctx.user_lang))
+ return self.END
+
+ def _get_allowed_ac_charge_amps(self) -> list[int]:
+ l = inverter.exec('get-allowed-ac-charge-currents')['data']
+ l = filter(lambda n: n <= 40, l)
+ return list(l)
+
+ def _parse_voltage(self, s: str) -> int:
+ return int(re.match(r'^(\d{2}) V$', s).group(1))
+
+ def _parse_amps(self, s: str) -> int:
+ return int(re.match(r'^(\d{1,2}) A$', s).group(1))
+
+
+class ConsumptionConversation(bot.conversation):
+ START, TOTAL, GRID = range(3)
+ STATE_SEQS = [
+ [START, TOTAL],
+ [START, GRID]
+ ]
+
+ START_BUTTONS = bot.lang.pfx('consumption_', ['total', 'grid'])
+ INTERVAL_BUTTONS = [
+ ['today'],
+ ['yday1'],
+ ['for_7days', 'for_30days'],
+ # ['to_select_interval']
+ ]
+ INTERVAL_BUTTONS_FLAT = list(itertools.chain.from_iterable(INTERVAL_BUTTONS))
+
+ @bot.conventer(START, message='consumption')
+ def start_enter(self, ctx: bot.Context):
+ return self.reply(ctx, self.START, ctx.lang('consumption_msg'), [self.START_BUTTONS],
+ with_cancel=True)
+
+ @bot.convinput(START, messages={
+ 'consumption_total': TOTAL,
+ 'consumption_grid': GRID
+ })
+ def start_input(self, ctx: bot.Context):
+ pass
+
+ @bot.conventer(TOTAL)
+ def total_enter(self, ctx: bot.Context):
+ return self._render_interval_btns(ctx, self.TOTAL)
+
+ @bot.conventer(GRID)
+ def grid_enter(self, ctx: bot.Context):
+ return self._render_interval_btns(ctx, self.GRID)
+
+ def _render_interval_btns(self, ctx: bot.Context, state):
+ return self.reply(ctx, state, ctx.lang('consumption_select_interval'), self.INTERVAL_BUTTONS,
+ with_back=True)
+
+ @bot.convinput(TOTAL, messages=INTERVAL_BUTTONS_FLAT)
+ def total_input(self, ctx: bot.Context):
+ return self._render_interval_results(ctx, self.TOTAL)
+
+ @bot.convinput(GRID, messages=INTERVAL_BUTTONS_FLAT)
+ def grid_input(self, ctx: bot.Context):
+ return self._render_interval_results(ctx, self.GRID)
+
+ def _render_interval_results(self, ctx: bot.Context, state):
+ # if ctx.text == ctx.lang('to_select_interval'):
+ # TODO
+ # pass
+ #
+ # else:
+
+ now = datetime.datetime.now()
+ s_to = now.strftime(FormatDate)
+
+ if ctx.text == ctx.lang('today'):
+ s_from = now.strftime(FormatDate)
+ s_to = 'now'
+ elif ctx.text == ctx.lang('yday1'):
+ s_from = (now - datetime.timedelta(days=1)).strftime(FormatDate)
+ elif ctx.text == ctx.lang('for_7days'):
+ s_from = (now - datetime.timedelta(days=7)).strftime(FormatDate)
+ elif ctx.text == ctx.lang('for_30days'):
+ s_from = (now - datetime.timedelta(days=30)).strftime(FormatDate)
+
+ # markup = InlineKeyboardMarkup([
+ # [InlineKeyboardButton(ctx.lang('please_wait'), callback_data='wait')]
+ # ])
+
+ message = ctx.reply(ctx.lang('consumption_request_sent'),
+ markup=bot.IgnoreMarkup())
+
+ api = WebAPIClient(timeout=60)
+ method = 'inverter_get_consumed_energy' if state == self.TOTAL else 'inverter_get_grid_consumed_energy'
+
+ try:
+ wh = getattr(api, method)(s_from, s_to)
+ bot.delete_message(message.chat_id, message.message_id)
+ ctx.reply('%.2f Wh' % (wh,),
+ markup=bot.IgnoreMarkup())
+ return self.END
+ except Exception as e:
+ bot.delete_message(message.chat_id, message.message_id)
+ ctx.reply_exc(e)
+# other
+# -----
-def monstatus(ctx: Context) -> None:
+@bot.handler(command='monstatus')
+def monstatus_handler(ctx: bot.Context) -> None:
msg = ''
st = monitor.dump_status()
for k, v in st.items():
@@ -444,19 +732,23 @@ def monstatus(ctx: Context) -> None:
ctx.reply(msg)
-def monsetcur(ctx: Context) -> None:
+@bot.handler(command='monsetcur')
+def monsetcur_handler(ctx: bot.Context) -> None:
ctx.reply('not implemented yet')
-def calcw(ctx: Context) -> None:
+@bot.handler(command='calcw')
+def calcw_handler(ctx: bot.Context) -> None:
ctx.reply('not implemented yet')
-def calcwadv(ctx: Context) -> None:
+@bot.handler(command='calcwadv')
+def calcwadv_handler(ctx: bot.Context) -> None:
ctx.reply('not implemented yet')
-def button_callback(ctx: Context) -> None:
+@bot.callbackhandler
+def button_callback(ctx: bot.Context) -> None:
query = ctx.callback_query
if query.data.startswith('flag_'):
@@ -491,235 +783,107 @@ def button_callback(ctx: Context) -> None:
query.answer(ctx.lang('unexpected_callback_data'))
-class InverterBot(Wrapper):
- def __init__(self, **kwargs):
- super().__init__(**kwargs)
-
- self.lang.ru(
- status='Статус',
- generation='Генерация',
- priority='Приоритет',
- osp='Приоритет питания нагрузки',
- battery="АКБ",
- load="Нагрузка",
- generator="Генератор",
- utilities="Столб",
- done="Готово",
- unexpected_callback_data="Ошибка: неверные данные",
- select_ac_mode="Выберите режим:",
- select_priortiy="Установите приоритет:",
- invalid_input="Неверное значение",
- invalid_mode="Invalid mode",
-
- flags_press_button='Нажмите кнопку для переключения настройки',
- flags_fail='Не удалось установить настройку',
- flags_invalid='Неизвестная настройка',
-
- # generation
- gen_today='Сегодня',
- gen_yday1='Вчера',
- gen_yday2='Позавчера',
- gen_input_power='Зарядная мощность',
-
- # status
- charging_at=', ',
- pd_charging='заряжается',
- pd_discharging='разряжается',
- pd_nothing='не используется',
-
- # flags
- flag_buzzer='Звуковой сигнал',
- flag_overload_bypass='Разрешить перегрузку',
- flag_escape_to_default_screen_after_1min_timeout='Возврат на главный экран через 1 минуту',
- flag_overload_restart='Перезапуск при перегрузке',
- flag_over_temp_restart='Перезапуск при перегреве',
- flag_backlight_on='Подсветка экрана',
- flag_alarm_on_on_primary_source_interrupt='Сигнал при разрыве основного источника питания',
- flag_fault_code_record='Запись кодов ошибок',
-
- # commands
- setbatuv_v=f'напряжение, 40.0 {LT} V {LT} 48.0',
- setgenct_cv=f'напряжение включения заряда, 44 {LT} CV {LT} 51',
- setgenct_dv=f'напряжение отключения заряда, 48 {LT} DV {LT} 58',
- setgencc_a='максимальный ток заряда, допустимые значения: %s',
-
- setosp_sub='Solar-Utility-Battery',
- setosp_sbu='Solar-Battery-Utility',
-
- # monitor
- chrg_evt_started='✅ Начали заряжать от генератора.',
- chrg_evt_finished='✅ Зарядили. Генератор пора выключать.',
- chrg_evt_disconnected='ℹ️ Генератор отключен.',
- chrg_evt_current_changed='ℹ️ Ток заряда от генератора установлен в %d A.',
- chrg_evt_not_charging='ℹ️ Генератор подключен, но не заряжает.',
- chrg_evt_na_solar='⛔️ Генератор подключен, но аккумуляторы не заряжаются из-за подключенных панелей.',
- chrg_evt_mostly_charged='✅ Аккумуляторы более-менее заряжены, генератор пора выключать.',
- battery_level_changed='Уровень заряда АКБ: <b>%s %s</b> (<b>%0.1f V</b> при нагрузке <b>%d W</b>)',
- error_message='<b>Ошибка:</b> %s.',
-
- util_chrg_evt_started='✅ Начали заряжать от столба.',
- util_chrg_evt_stopped='ℹ️ Перестали заряжать от столба.',
- util_chrg_evt_stopped_solar='ℹ️ Перестали заряжать от столба из-за подключения панелей.',
-
- util_connected='✅️ Столб подключён.',
- util_disconnected='‼️ Столб отключён.',
-
- # other notifications
- ac_mode_changed_notification='Пользователь <a href="tg://user?id=%d">%s</a> установил режим AC: <b>%s</b>.',
- osp_changed_notification='Пользователь <a href="tg://user?id=%d">%s</a> установил приоритет источника питания нагрузки: <b>%s</b>.',
- osp_auto_changed_notification='ℹ️ Бот установил приоритет источника питания нагрузки: <b>%s</b>. Причины: напряжение АКБ %.1f V, мощность заряда с панелей %d W.',
-
- bat_state_normal='Нормальный',
- bat_state_low='Низкий',
- bat_state_critical='Критический',
- )
+@bot.exceptionhandler
+def exception_handler(e: Exception, ctx: bot.Context) -> Optional[bool]:
+ if isinstance(e, InverterError):
+ try:
+ err = json.loads(str(e))['message']
+ except json.decoder.JSONDecodeError:
+ err = str(e)
+ err = re.sub(r'((?:.*)?error:) (.*)', r'<b>\1</b> \2', err)
+ ctx.reply(err,
+ markup=bot.IgnoreMarkup())
+ return True
- self.lang.en(
- status='Status',
- generation='Generation',
- priority='Priority',
- osp='Output source priority',
- battery="Battery",
- load="Load",
- generator="Generator",
- utilities="Utilities",
- done="Done",
- unexpected_callback_data="Unexpected callback data",
- select_ac_mode="Select AC input mode:",
- select_priortiy="Select priority:",
- invalid_input="Invalid input",
- invalid_mode="Invalid mode",
-
- flags_press_button='Press a button to toggle a flag.',
- flags_fail='Failed to toggle flag',
- flags_invalid='Invalid flag',
-
- # generation
- gen_today='Today',
- gen_yday1='Yesterday',
- gen_yday2='The day before yesterday',
- gen_input_power='Input power',
-
- # status
- charging_at=' @ ',
- pd_charging='charging',
- pd_discharging='discharging',
- pd_nothing='not used',
-
- # 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',
-
- # commands
- setbatuv_v=f'floating point number, 40.0 {LT} V {LT} 48.0',
- setgenct_cv=f'charging voltage, 44 {LT} CV {LT} 51',
- setgenct_dv=f'discharging voltage, 48 {LT} DV {LT} 58',
- setgencc_a='max charging current, allowed values: %s',
-
- setosp_sub='Solar-Utility-Battery',
- setosp_sbu='Solar-Battery-Utility',
-
- # monitor
- chrg_evt_started='✅ Started charging from AC.',
- chrg_evt_finished='✅ Finished charging, it\'s time to stop the generator.',
- 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: <b>%s</b> (<b>%0.1f V</b> under <b>%d W</b> load)',
- error_message='<b>Error:</b> %s.',
-
- util_chrg_evt_started='✅ Started charging from utilities.',
- util_chrg_evt_stopped='ℹ️ Stopped charging from utilities.',
- util_chrg_evt_stopped_solar='ℹ️ Stopped charging from utilities because solar panels were connected.',
-
- util_connected='✅️ Utilities connected.',
- util_disconnected='‼️ Utilities disconnected.',
-
- # other notifications
- ac_mode_changed_notification='User <a href="tg://user?id=%d">%s</a> set AC mode to <b>%s</b>.',
- osp_changed_notification='User <a href="tg://user?id=%d">%s</a> set output source priority: <b>%s</b>.',
- osp_auto_changed_notification='Bot changed output source priority to <b>%s</b>. Reasons: battery voltage is %.1f V, solar input is %d W.',
-
- bat_state_normal='Normal',
- bat_state_low='Low',
- bat_state_critical='Critical',
- )
- self.add_handler(MessageHandler(text_filter(self.lang.all('status')), self.wrap(status)))
- self.add_handler(MessageHandler(text_filter(self.lang.all('generation')), self.wrap(generation)))
-
- self.add_handler(CommandHandler('setgencc', self.wrap(setgencc)))
- self.add_handler(CommandHandler('setgenct', self.wrap(setgenct)))
- self.add_handler(CommandHandler('setbatuv', self.wrap(setbatuv)))
- self.add_handler(CommandHandler('monstatus', self.wrap(monstatus)))
- self.add_handler(CommandHandler('monsetcur', self.wrap(monsetcur)))
- self.add_handler(CommandHandler('calcw', self.wrap(calcw)))
- self.add_handler(CommandHandler('calcwadv', self.wrap(calcwadv)))
-
- self.add_handler(CommandHandler('flags', self.wrap(flags)))
- self.add_handler(CommandHandler('status', self.wrap(full_status)))
- self.add_handler(CommandHandler('config', self.wrap(full_rated)))
- self.add_handler(CommandHandler('errors', self.wrap(full_errors)))
-
- self.add_handler(CallbackQueryHandler(self.wrap(button_callback)))
-
- def run(self):
- cancel_filter = Filters.text(self.lang.all('cancel'))
-
- self.add_handler(ConversationHandler(
- entry_points=[CommandHandler('setacmode', self.wrap(setacmode_start), self.user_filter)],
- states={
- SETACMODE_STARTED: [
- *[MessageHandler(text_filter(self.lang.all(mode.value)), self.wrap(setacmode_input)) for mode in ACMode],
- MessageHandler(self.user_filter & ~cancel_filter, self.wrap(setacmode_invalid))
- ]
- },
- fallbacks=[MessageHandler(self.user_filter & cancel_filter, self.wrap(setacmode_cancel))]
- ))
-
- self.add_handler(ConversationHandler(
- entry_points=[
- CommandHandler('setosp', self.wrap(setosp_start), self.user_filter),
- MessageHandler(text_filter(self.lang.all('osp')), self.wrap(setosp_start))
- ],
- states={
- SETOSP_STARTED: [
- *[MessageHandler(text_filter(self.lang.all(f'setosp_{sp.value.lower()}')), self.wrap(setosp_input)) for sp in OutputSourcePriority],
- MessageHandler(self.user_filter & ~cancel_filter, self.wrap(setosp_invalid))
- ]
- },
- fallbacks=[MessageHandler(self.user_filter & cancel_filter, self.wrap(setosp_cancel))]
- ))
-
- super().run()
-
- def markup(self, ctx: Optional[Context]) -> Optional[ReplyKeyboardMarkup]:
- button = [
- [ctx.lang('status'), ctx.lang('generation')],
- [ctx.lang('osp')]
- ]
- return ReplyKeyboardMarkup(button, one_time_keyboard=False)
-
- def exception_handler(self, e: Exception, ctx: Context) -> Optional[bool]:
- if isinstance(e, InverterError):
- try:
- err = json.loads(str(e))['message']
- except json.decoder.JSONDecodeError:
- err = str(e)
- err = re.sub(r'((?:.*)?error:) (.*)', r'<b>\1</b> \2', err)
- ctx.reply(err)
- return True
-
-
-class InverterStore(Store):
+@bot.handler(message='status')
+def status_handler(ctx: bot.Context) -> None:
+ gs = inverter.exec('get-status')['data']
+ rated = inverter.exec('get-rated')['data']
+
+ # render response
+ power_direction = gs['battery_power_direction'].lower()
+ power_direction = re.sub(r'ge$', 'ging', power_direction)
+
+ charging_rate = ''
+ chrg_at = ctx.lang('charging_at')
+
+ if power_direction == 'charging':
+ charging_rate = f'{chrg_at}%s %s' % (
+ gs['battery_charge_current']['value'], gs['battery_charge_current']['unit'])
+ pd_label = ctx.lang('pd_charging')
+ elif power_direction == 'discharging':
+ charging_rate = f'{chrg_at}%s %s' % (
+ gs['battery_discharge_current']['value'], gs['battery_discharge_current']['unit'])
+ pd_label = ctx.lang('pd_discharging')
+ else:
+ pd_label = ctx.lang('pd_nothing')
+
+ html = f'<b>{ctx.lang("battery")}:</b> %s %s' % (gs['battery_voltage']['value'], gs['battery_voltage']['unit'])
+ html += ' (%s%s)' % (pd_label, charging_rate)
+
+ html += f'\n<b>{ctx.lang("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 += f'\n<b>{ctx.lang("gen_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:
+ ac_mode = getacmode()
+ html += f'\n<b>{ctx.lang(ac_mode.value)}:</b> %s %s' % (gs['grid_voltage']['unit'], gs['grid_voltage']['value'])
+ html += ', %s %s' % (gs['grid_freq']['value'], gs['grid_freq']['unit'])
+
+ html += f'\n<b>{ctx.lang("priority")}</b>: {rated["output_source_priority"]}'
+
+ # send response
+ ctx.reply(html)
+
+
+@bot.handler(message='generation')
+def generation_handler(ctx: bot.Context) -> None:
+ today = datetime.date.today()
+ yday = today - datetime.timedelta(days=1)
+ yday2 = today - datetime.timedelta(days=2)
+
+ gs = inverter.exec('get-status')['data']
+
+ today = inverter.exec('get-day-generated', (today.year, today.month, today.day))['data']
+ gen_yday = None
+ gen_yday2 = None
+
+ if yday.month == today.month:
+ gen_yday = inverter.exec('get-day-generated', (yday.year, yday.month, yday.day))['data']
+
+ if yday2.month == today.month:
+ gen_yday2 = inverter.exec('get-day-generated', (yday2.year, yday2.month, yday2.day))['data']
+
+ # render response
+ html = f'<b>{ctx.lang("gen_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 += f'\n<b>{ctx.lang("today")}:</b> %s Wh' % (today['wh'])
+
+ if gen_yday is not None:
+ html += f'\n<b>{ctx.lang("yday1")}:</b> %s Wh' % (gen_yday['wh'])
+
+ if gen_yday2 is not None:
+ html += f'\n<b>{ctx.lang("yday2")}:</b> %s Wh' % (gen_yday2['wh'])
+
+ # send response
+ ctx.reply(html)
+
+
+@bot.defaultreplymarkup
+def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]:
+ button = [
+ [ctx.lang('status'), ctx.lang('generation')],
+ [ctx.lang('consumption')],
+ [ctx.lang('settings')]
+ ]
+ return ReplyKeyboardMarkup(button, one_time_keyboard=False)
+
+
+class InverterStore(bot.BotDatabase):
SCHEMA = 2
def schema_init(self, version: int) -> None:
@@ -748,11 +912,13 @@ class InverterStore(Store):
if __name__ == '__main__':
- config.load('inverter_bot')
-
inverter.init(host=config['inverter']['ip'], port=config['inverter']['port'])
- db = InverterStore()
+ bot.set_database(InverterStore())
+ # bot.enable_logging(BotType.INVERTER)
+
+ bot.add_conversation(SettingsConversation(enable_back=True))
+ bot.add_conversation(ConsumptionConversation(enable_back=True))
monitor = InverterMonitor()
monitor.set_charging_event_handler(monitor_charging)
@@ -762,12 +928,10 @@ if __name__ == '__main__':
monitor.set_osp_need_change_callback(osp_change_cb)
setacmode(getacmode())
-
- bot = InverterBot(store=db)
- bot.enable_logging(BotType.INVERTER)
-
- monitor.start()
-
bot.run()
+ if not config.get('monitor.disabled'):
+ logging.info('starting monitor')
+ monitor.start()
+
monitor.stop()
diff --git a/src/inverterd_emulator.py b/src/inverterd_emulator.py
new file mode 100755
index 0000000..8c4d0bd
--- /dev/null
+++ b/src/inverterd_emulator.py
@@ -0,0 +1,9 @@
+#!/usr/bin/env python3
+import logging
+
+from home.inverter.emulator import InverterEmulator
+
+
+if __name__ == '__main__':
+ logging.basicConfig(level=logging.DEBUG)
+ InverterEmulator(addr=('127.0.0.1', 8305))
diff --git a/src/pump_bot.py b/src/pump_bot.py
index ae36e27..bf791d3 100755
--- a/src/pump_bot.py
+++ b/src/pump_bot.py
@@ -1,15 +1,53 @@
#!/usr/bin/env python3
+from enum import Enum
from typing import Optional
+from telegram import ReplyKeyboardMarkup, User
+
from home.config import config
-from home.bot import Wrapper, Context, text_filter, user_any_name
+from home.telegram import bot
+from home.telegram._botutil import user_any_name
from home.relay import RelayClient
from home.api.types import BotType
-from telegram import ReplyKeyboardMarkup, User
-from telegram.ext import MessageHandler
-from enum import Enum
-from functools import partial
-bot: Optional[Wrapper] = None
+config.load('pump_bot')
+
+bot.initialize()
+bot.lang.ru(
+ start_message="Выберите команду на клавиатуре",
+ unknown_command="Неизвестная команда",
+
+ enable="Включить",
+ enable_silently="Включить тихо",
+ enabled="Включен ✅",
+
+ disable="Выключить",
+ disable_silently="Выключить тихо",
+ disabled="Выключен ❌",
+
+ status="Статус",
+ done="Готово 👌",
+ user_action_notification='Пользователь <a href="tg://user?id=%d">%s</a> <b>%s</b> насос.',
+ user_action_on="включил",
+ user_action_off="выключил",
+)
+bot.lang.en(
+ start_message="Select command on the keyboard",
+ unknown_command="Unknown command",
+
+ enable="Turn ON",
+ enable_silently="Turn ON silently",
+ enabled="Turned ON ✅",
+
+ disable="Turn OFF",
+ disable_silently="Turn OFF silently",
+ disabled="Turned OFF ❌",
+
+ status="Status",
+ done="Done 👌",
+ user_action_notification='User <a href="tg://user?id=%d">%s</a> turned the pump <b>%s</b>.',
+ user_action_on="ON",
+ user_action_off="OFF",
+)
class UserAction(Enum):
@@ -23,26 +61,20 @@ def get_relay() -> RelayClient:
return relay
-def on(silent: bool, ctx: Context) -> None:
+def on(ctx: bot.Context, silent=False) -> None:
get_relay().on()
ctx.reply(ctx.lang('done'))
if not silent:
notify(ctx.user, UserAction.ON)
-def off(silent: bool, ctx: Context) -> None:
+def off(ctx: bot.Context, silent=False) -> None:
get_relay().off()
ctx.reply(ctx.lang('done'))
if not silent:
notify(ctx.user, UserAction.OFF)
-def status(ctx: Context) -> None:
- ctx.reply(
- ctx.lang('enabled') if get_relay().status() == 'on' else ctx.lang('disabled')
- )
-
-
def notify(user: User, action: UserAction) -> None:
def text_getter(lang: str):
action_name = bot.lang.get(f'user_action_{action.value}', lang)
@@ -53,72 +85,47 @@ def notify(user: User, action: UserAction) -> None:
bot.notify_all(text_getter, exclude=(user.id,))
-class PumpBot(Wrapper):
- def __init__(self):
- super().__init__()
-
- self.lang.ru(
- start_message="Выберите команду на клавиатуре",
- unknown_command="Неизвестная команда",
+@bot.handler(message='enable')
+def enable_handler(ctx: bot.Context) -> None:
+ on(ctx)
- enable="Включить",
- enable_silently="Включить тихо",
- enabled="Включен ✅",
- disable="Выключить",
- disable_silently="Выключить тихо",
- disabled="Выключен ❌",
+@bot.handler(message='enable_silently')
+def enable_s_handler(ctx: bot.Context) -> None:
+ on(ctx, True)
- status="Статус",
- done="Готово 👌",
- user_action_notification='Пользователь <a href="tg://user?id=%d">%s</a> <b>%s</b> насос.',
- user_action_on="включил",
- user_action_off="выключил",
- )
- self.lang.en(
- start_message="Select command on the keyboard",
- unknown_command="Unknown command",
+@bot.handler(message='disable')
+def disable_handler(ctx: bot.Context) -> None:
+ off(ctx)
- enable="Turn ON",
- enable_silently="Turn ON silently",
- enabled="Turned ON ✅",
- disable="Turn OFF",
- disable_silently="Turn OFF silently",
- disabled="Turned OFF ❌",
+@bot.handler(message='disable_silently')
+def disable_s_handler(ctx: bot.Context) -> None:
+ off(ctx, True)
- status="Status",
- done="Done 👌",
- user_action_notification='User <a href="tg://user?id=%d">%s</a> turned the pump <b>%s</b>.',
- user_action_on="ON",
- user_action_off="OFF",
- )
- self.add_handler(MessageHandler(text_filter(self.lang.all('enable')), self.wrap(partial(on, False))))
- self.add_handler(MessageHandler(text_filter(self.lang.all('disable')), self.wrap(partial(off, False))))
-
- self.add_handler(MessageHandler(text_filter(self.lang.all('enable_silently')), self.wrap(partial(on, True))))
- self.add_handler(MessageHandler(text_filter(self.lang.all('disable_silently')), self.wrap(partial(off, True))))
+@bot.handler(message='status')
+def status(ctx: bot.Context) -> None:
+ ctx.reply(
+ ctx.lang('enabled') if get_relay().status() == 'on' else ctx.lang('disabled')
+ )
- self.add_handler(MessageHandler(text_filter(self.lang.all('status')), self.wrap(status)))
- def markup(self, ctx: Optional[Context]) -> Optional[ReplyKeyboardMarkup]:
- buttons = [
- [ctx.lang('enable'), ctx.lang('disable')],
- ]
+@bot.defaultreplymarkup
+def markup(ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]:
+ buttons = [
+ [ctx.lang('enable'), ctx.lang('disable')],
+ ]
- if ctx.user_id in config['bot']['silent_users']:
- buttons.append([ctx.lang('enable_silently'), ctx.lang('disable_silently')])
+ if ctx.user_id in config['bot']['silent_users']:
+ buttons.append([ctx.lang('enable_silently'), ctx.lang('disable_silently')])
- buttons.append([ctx.lang('status')])
+ buttons.append([ctx.lang('status')])
- return ReplyKeyboardMarkup(buttons, one_time_keyboard=False)
+ return ReplyKeyboardMarkup(buttons, one_time_keyboard=False)
if __name__ == '__main__':
- config.load('pump_bot')
-
- bot = PumpBot()
bot.enable_logging(BotType.PUMP)
bot.run()
diff --git a/src/sensors_bot.py b/src/sensors_bot.py
index ea3dc9e..9cf9c5d 100755
--- a/src/sensors_bot.py
+++ b/src/sensors_bot.py
@@ -7,17 +7,15 @@ import gc
from io import BytesIO
from typing import Optional
-from functools import partial
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import matplotlib.ticker as mticker
from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton
-from telegram.ext import MessageHandler, CallbackQueryHandler
from home.config import config
-from home.bot import Wrapper, Context, text_filter
+from home.telegram import bot
from home.util import chunks, MySimpleSocketClient
from home.api import WebAPIClient
from home.api.types import (
@@ -25,13 +23,50 @@ from home.api.types import (
TemperatureSensorLocation
)
-bot: Optional[Wrapper] = None
+config.load('sensors_bot')
+bot.initialize()
+
+_sensor_names = []
+for k, v in config['sensors'].items():
+ _sensor_names.append(k)
+ bot.lang.set({k: v['label_ru']}, 'ru')
+ bot.lang.set({k: v['label_en']}, 'en')
+
+bot.lang.ru(
+ start_message="Выберите датчик на клавиатуре",
+ unknown_command="Неизвестная команда",
+ temperature="Температура",
+ humidity="Влажность",
+ plot_3h="График за 3 часа",
+ plot_6h="График за 6 часов",
+ plot_12h="График за 12 часов",
+ plot_24h="График за 24 часа",
+ unexpected_callback_data="Ошибка: неверные данные",
+ loading="Загрузка...",
+ n_hrs="график за %d ч."
+)
+
+bot.lang.en(
+ start_message="Select the sensor on the keyboard",
+ unknown_command="Unknown command",
+ temperature="Temperature",
+ humidity="Relative humidity",
+ plot_3h="Graph for 3 hours",
+ plot_6h="Graph for 6 hours",
+ plot_12h="Graph for 12 hours",
+ plot_24h="Graph for 24 hours",
+ unexpected_callback_data="Unexpected callback data",
+ loading="Loading...",
+ n_hrs="graph for %d hours"
+)
+
plt.rcParams['font.size'] = 7
logger = logging.getLogger(__name__)
plot_hours = [3, 6, 12, 24]
-def read_sensor(sensor: str, ctx: Context) -> None:
+@bot.handler(messages=_sensor_names)
+def read_sensor(sensor: str, ctx: bot.Context) -> None:
host = config['sensors'][sensor]['ip']
port = config['sensors'][sensor]['port']
@@ -55,7 +90,8 @@ def read_sensor(sensor: str, ctx: Context) -> None:
ctx.reply(text, markup=InlineKeyboardMarkup(chunks(buttons, 2)))
-def callback_handler(ctx: Context) -> None:
+@bot.callbackhandler
+def callback_handler(ctx: bot.Context) -> None:
query = ctx.callback_query
sensors_variants = '|'.join(config['sensors'].keys())
@@ -82,7 +118,7 @@ def callback_handler(ctx: Context) -> None:
plot = draw_plot(data, title,
ctx.lang('temperature'),
ctx.lang('humidity'))
- bot.updater.bot.send_photo(ctx.user_id, plot)
+ bot.send_photo(ctx.user_id, photo=plot)
gc.collect()
@@ -129,57 +165,16 @@ def draw_plot(data,
return buf
-class SensorsBot(Wrapper):
- def __init__(self):
- super().__init__()
-
- self.lang.ru(
- start_message="Выберите датчик на клавиатуре",
- unknown_command="Неизвестная команда",
- temperature="Температура",
- humidity="Влажность",
- plot_3h="График за 3 часа",
- plot_6h="График за 6 часов",
- plot_12h="График за 12 часов",
- plot_24h="График за 24 часа",
- unexpected_callback_data="Ошибка: неверные данные",
- loading="Загрузка...",
- n_hrs="график за %d ч."
- )
-
- self.lang.en(
- start_message="Select the sensor on the keyboard",
- unknown_command="Unknown command",
- temperature="Temperature",
- humidity="Relative humidity",
- plot_3h="Graph for 3 hours",
- plot_6h="Graph for 6 hours",
- plot_12h="Graph for 12 hours",
- plot_24h="Graph for 24 hours",
- unexpected_callback_data="Unexpected callback data",
- loading="Loading...",
- n_hrs="graph for %d hours"
- )
-
- for k, v in config['sensors'].items():
- self.lang.set({k: v['label_ru']}, 'ru')
- self.lang.set({k: v['label_en']}, 'en')
- self.add_handler(MessageHandler(text_filter(self.lang.all(k)), self.wrap(partial(read_sensor, k))))
-
- self.add_handler(CallbackQueryHandler(self.wrap(callback_handler)))
-
- def markup(self, ctx: Optional[Context]) -> Optional[ReplyKeyboardMarkup]:
- buttons = []
- for k in config['sensors'].keys():
- buttons.append(ctx.lang(k))
- buttons = chunks(buttons, 2)
- return ReplyKeyboardMarkup(buttons, one_time_keyboard=False)
+@bot.defaultreplymarkup
+def markup(self, ctx: Optional[bot.Context]) -> Optional[ReplyKeyboardMarkup]:
+ buttons = []
+ for k in config['sensors'].keys():
+ buttons.append(ctx.lang(k))
+ buttons = chunks(buttons, 2)
+ return ReplyKeyboardMarkup(buttons, one_time_keyboard=False)
if __name__ == '__main__':
- config.load('sensors_bot')
-
- bot = SensorsBot()
if 'api' in config:
bot.enable_logging(BotType.SENSORS)
bot.run()