<gh_stars>1-10
from datetime import datetime, timedelta
import json
import logging
import socket

from typing import Dict, List

import broadlink

from telegram import ReplyKeyboardMarkup
from telegram.ext import (
    Updater, CommandHandler, CallbackQueryHandler
)

from iot import constants
from iot.conversations.cmd_adduser import AddUserConversation
from iot.devices.base import BaseDevice
from iot.devices.errors import (
    CommandNotFound, InvalidArgument,
    SendCommandError
)
from iot.rooms import Room
from iot.utils import return_mac
from iot.utils.decorators import (
    valid_device, valid_device_or_room,
    valid_device_feature, valid_user
)
from iot.utils.keyboard.cmd_keyboard import (
    CommandKeyboardCBHandler
)
from iot.utils.keyboard.cmd_user import (
    TOP_MENU_TEXT as USER_TOP_MENU_TEXT,
    CommandUserCBHandler
)


logger = logging.getLogger(__name__)


KEYBOARD_HANDLER_NAME = "/keyboard"
USER_HANDLER_NAME = "/user"


class TelegramIOTServer:

    def __init__(self):
        # Telegram Bot settings
        self.bot_id = None
        self.bot_secret = None
        self.bot_name = None

        # Telegram
        self.updater: Updater = None
        self.dp = None

        # JSON files path
        self.devices_path = None
        self.commands_path = None
        self.users_path = None

        # Broadlink and Devices
        self.broadlink_devices = {}
        self.rooms = {}
        self.devices = {}
        self.commands = {}
        self.approved_users = {}

        # Keyboard query handlers
        self.kb_handlers = {}

        # Others
        self.start_time: datetime = None
        self.last_command_handled = None

    def start_server(self, bot_id, bot_secret, bot_name,
            devices_path, commands_path, users_path):
        self.bot_id = bot_id
        self.bot_secret = bot_secret
        self.bot_name = bot_name

        self.devices_path = devices_path
        self.commands_path = commands_path
        self.users_path = users_path

        self.discover_broadlink_device()

        self.reload_commands()
        self.reload_rooms_and_devices()
        self.reload_users()

        self.kb_handlers[KEYBOARD_HANDLER_NAME] = \
            CommandKeyboardCBHandler(self, KEYBOARD_HANDLER_NAME)

        self.kb_handlers[USER_HANDLER_NAME] = \
            CommandUserCBHandler(self, USER_HANDLER_NAME)

        self.init_telegram_server()

    def init_telegram_server(self):
        token: str = '{}:{}'.format(self.bot_id, self.bot_secret)
        self.updater = Updater(
            token,
            user_sig_handler=self.stop_server
        )

        # Get the dispatcher to register handlers
        self.dp = self.updater.dispatcher

        self.dp.add_handler(CommandHandler("start", self.command_start))
        self.dp.add_handler(CommandHandler("ping", self.command_ping))
        self.dp.add_handler(CommandHandler("status", self.command_status))
        self.dp.add_handler(CommandHandler("list", self.command_list))
        self.dp.add_handler(CommandHandler(
            "keyboard", self.command_keyboard, pass_args=True
        ))
        self.dp.add_handler(CommandHandler(
            "user", self.command_user, pass_args=True
        ))
        self.dp.add_handler(CallbackQueryHandler(self.handle_keyboard_response))
        self.dp.add_handler(CommandHandler(
            "on", self.command_on, pass_args=True
        ))
        self.dp.add_handler(CommandHandler(
            "off", self.command_off, pass_args=True
        ))
        self.dp.add_handler(CommandHandler(
            "d", self.command_device, pass_args=True
        ))

        self.add_conversations()

        self.dp.add_error_handler(self.error)

        self.updater.start_polling()
        logger.info("Telegram IOT Server Running...")
        self.start_time = datetime.now()

        self.updater.idle()

    def add_conversations(self):
        # TODO: make a map and initialize it?
        AddUserConversation(
            self, ["adduser"], ["canceladduser"]
        )

    def reload_rooms_and_devices(self):
        try:
            with open(self.devices_path) as f:
                try:
                    data: dict = json.load(f)

                    if len(data) == 0:
                        logger.warning(
                            "Please add your rooms and devices to %s",
                            self.devices_path
                        )
                        return
                except (ValueError, TypeError) as e:
                    logger.error("Decoding devices json file failed: %s", e)
                    return
                else:
                    for room_name, value in data.items():
                        if room_name not in self.rooms:
                            try:
                                rm_device = None
                                to_populate_bl_device = False

                                # Check for RM Device
                                if all(
                                    k in value for k in (
                                        "mac_address",
                                        "broadlink_type"
                                    )
                                ):
                                    rm_mac_address: str = value["mac_address"]
                                    rm_broadlink_type: str = value["broadlink_type"]
                                    rm_device = self.find_broadlink_device(
                                        rm_mac_address, rm_broadlink_type
                                    )

                                if 'broadlink_devices' in value.keys():
                                    to_populate_bl_device = True

                                if rm_device or to_populate_bl_device:
                                    r: Room = Room(room_name, rm_device)
                                    pop_device: List[BaseDevice] = r.populate_devices(
                                        value.get("devices", []))

                                    r.populate_broadlink_devices(
                                        value.get("broadlink_devices", [])
                                    )

                                    # Only populate room if there are any
                                    # normal / broadlink devices
                                    if (
                                        len(r.DEVICES) > 0 or
                                        len(r.BL_DEVICES) > 0
                                    ):
                                        self.rooms[room_name] = r

                                    for pd in pop_device:
                                        commands: Dict = self.get_commands(
                                            pd.device_type.value,
                                            pd.brand,
                                            pd.model
                                        )
                                        if commands:
                                            pd.populate_device_commands(commands)

                                        self.devices[pd.id] = pd

                            except Exception as e:
                                logger.error("Error While reloading rooms and devices: %s", e)
                                continue
        except FileNotFoundError as e:
            logger.error("Devices file not found %s", self.devices_path)
            raise e

    def find_broadlink_device(self, mac, bl_type):
            bl = self.broadlink_devices.get(mac)

            if bl and bl_type == bl.type:
                return bl

    def discover_broadlink_device(self):
        # Temp Code for when running server
        # with multiple network interface
        # TODO: Remove this code as it should be added to
        # https://github.com/mjg59/python-broadlink/blob/master/broadlink/__init__.py#L66
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

        try:
            s.connect(('10.255.255.255', 1))
            local_ip = s.getsockname()[0]
        except:
            local_ip = '127.0.0.1'
        finally:
            s.close()

        logger.info("Local IP: %s", local_ip)
        logger.info("Discovering broadlink devices...")
        bl_devices: list = broadlink.discover(
            timeout=5, local_ip_address=local_ip
        )

        for bl in bl_devices:
            bl.auth()
            mac = return_mac(bl.mac)
            self.broadlink_devices[mac] = bl
            logger.info("Discovered %s device with %s mac", bl.type, mac)

    def reload_commands(self):
        try:
            with open(self.commands_path) as f:
                try:
                    data: dict = json.load(f)
                except (ValueError, TypeError) as e:
                    logger.error("Decoding commands json file failed: %s", e)
                    return
                else:
                    self.commands = data
        except FileNotFoundError as e:
            logger.error("Commands file not found %s", self.commands_path)
            raise e

    def get_commands(self, device_type, brand, model) -> Dict[str, str]:
        # Always convert device_type to String,
        # as populated command dict key is in String
        device_type = str(device_type)

        try:
            return self.commands[device_type][brand][model]
        except KeyError:
            logger.error("Command not found for %s-%s-%s",
                device_type, brand, model)

            return {}

    def reload_users(self):
        try:
            with open(self.users_path) as f:
                try:
                    data: Dict[str, str] = json.load(f)

                    if len(data) == 0:
                        logger.warning(
                            "Please populate at least one user to %s",
                            self.users_path
                        )
                except (ValueError, TypeError) as e:
                    logger.error("Decoding users json file failed: %s", e)
                    return
                else:
                    self.approved_users = data
        except FileNotFoundError as e:
            logger.error("Users file not found %s", self.users_path)
            raise e

    def save_users(self):
        logger.info("Saving approved users to %s", self.users_path)

        with open(self.users_path, "w") as f:
            try:
                json.dump(self.approved_users, f, indent=4, sort_keys=True)
            except Exception as e:
                logger.error("Error while saving users to json file: %s", e)

    def error(self, update, context):
        """Log Errors caused by Updates."""
        logger.warning('Update "%s" caused error "%s"', update, context.error)

    @property
    def uptime(self):
        now = datetime.now()
        uptime = str(
            timedelta(
                seconds=(now-self.start_time).total_seconds()
            )
        )

        return uptime

    @property
    def broadlink_devices_info(self):
        all_bb_info = []

        for bb in self.broadlink_devices.values():
            bb_info = "Type: {}, IP: {}, Mac: {}".format(
                bb.type, bb.host[0],
                return_mac(bb.mac)
            )
            all_bb_info.append(bb_info)

        return '\n'.join(all_bb_info)

    @valid_user
    def command_start(self, update, context):
        """Send a message when the command `/start` is issued."""
        update.message.reply_markdown(
            constants.START_MESSAGE.format(self.bot_name)
        )

    def command_ping(self, update, context):
        """Sends message `pong` and user's id, name"""
        user = update.effective_user
        update.message.reply_markdown(constants.PONG_MESSAGE.format(
            user.username, user.id
        ))

    @valid_user
    def command_status(self, update, context):
        """Sends server status back"""
        server_info = constants.STATUS_MESSAGE.format(
            str(datetime.now()).split(".")[0],
            self.uptime,
            self.last_command_handled,
            self.broadlink_devices_info,
            len(self.rooms),
            len(self.devices),
            ", ".join(self.approved_users.values())
        )

        update.message.reply_text(server_info)

    @valid_user
    def command_list(self, update, context):
        """Sends list of broadlink devices, rooms and devices in room"""
        if len(self.rooms) == 0:
            update.message.reply_markdown(
                constants.NO_ROOM_MESSAGE.format(self.devices_path)
            )
            return

        rooms_info = [str(r.room_list_info()) for r in self.rooms.values()]

        update.message.reply_markdown(constants.LIST_MESSAGE.format(
            "\n".join(rooms_info)
        ))

    @valid_user
    @valid_device_or_room(compulsory=False)
    def command_keyboard(self, update, context, *args, **kwargs):
        """Sends Inline keyboard to access rooms and devices"""
        if len(self.rooms) == 0:
            update.message.reply_markdown(
                constants.NO_ROOM_MESSAGE.format(self.devices_path)
            )
            return

        handler = self.kb_handlers[KEYBOARD_HANDLER_NAME]

        # By default, reply markup will be rooms keyboard
        reply_markup = handler.build_rooms_keyboard()
        text = "Select room"

        room = kwargs.pop("room", None)
        device = kwargs.pop("device", None)

        # If room, device can be found, that will be the markup
        if room:
            reply_markup = handler.build_room_devices_keyboard(room.name)
            text = "Select {} device".format(room.name)
        elif device:
            reply_markup = handler.build_device_keyboard(device.id)
            text = "Select {} feature".format(device.id)

        update.message.reply_text(text, reply_markup=reply_markup)

    @valid_user
    def command_user(self, update, context, *args, **kwargs):
        """Sends inline keyboard to view approved users"""
        handler = self.kb_handlers[USER_HANDLER_NAME]

        reply_markup = handler.build_users_keyboard()

        update.message.reply_text(USER_TOP_MENU_TEXT,
            reply_markup=reply_markup)

    @valid_user
    def handle_keyboard_response(self, update, context):
        query = update.callback_query
        handler_name, internal_cb_data = query.data.split(" ", 1)

        if handler_name in self.kb_handlers.keys():
            self.kb_handlers[handler_name].process_query(
                update, context, internal_cb_data
            )
        else:
            logger.error(
                "Unable to find handler_name, %s in kb_handlers",
                handler_name
            )

    @valid_user
    @valid_device
    def command_on(self, update, context, device, *args, **kwargs):
        """Turn ON targeted device if device id can be found"""
        device.power_on()

    @valid_user
    @valid_device
    def command_off(self, update, context, device, *args, **kwargs):
        """Turn OFF targeted device if device id can be found"""
        device.power_off()

    @valid_user
    @valid_device_feature
    def command_device(self, update, context, device, feature,
            action=None, *args, **kwargs
        ):
        """
        Command device specific feature if device_id, feature and
        action can be found, passthrough function and call
        server's call_device method
        """
        self.call_device(update, context, device, feature,
            action=action, *args, **kwargs)

    def call_device(self, update, context, device, feature,
            action=None, handler_name=None, *args, **kwargs):
        """
        Call specified device's feature and action,
        if it can be found
        """
        def send_text(update, context, message):
            if update.callback_query:
                self.kb_handlers[handler_name].answer_query(
                    update.callback_query, context,
                    text=message
                )
            else:
                update.message.reply_markdown(message)

        func = getattr(device, feature)

        new_args = args

        if action:
            new_args = (action,)

        try:
            result = func(*new_args)
            text = "Sent {} with {}".format(device.id, feature)

            if isinstance(result, tuple):
                text = result[1]

            if update.callback_query:
                self.kb_handlers[handler_name].answer_query(
                    update.callback_query, context,
                    text=text
                )
        except (NotImplementedError, CommandNotFound):
            action = '' if not action else action

            send_text(update, context,
                constants.DEVICE_COMMAND_NOT_IMPLEMENTED.format(
                    device.id, feature, action)
            )
        except SendCommandError as e:
            send_text(update, context,
                constants.SEND_DEVICE_COMMAND_ERROR.format(
                    device.id, feature, e
                )
            )
        except (TypeError, InvalidArgument):
            send_text(update, context, constants.ARGS_ERROR)

    def stop_server(self, *args, **kwargs):
        logger.info("Telegram IOT Server stopping...")

        # Save approved users json
        # TODO: optimize this, save only on approved_users change
        self.save_users()


iot_server: TelegramIOTServer = TelegramIOTServer()

from datetime import datetime, timedelta
import json
import logging
import socket

from typing import Dict, List

import broadlink

from telegram import ReplyKeyboardMarkup
from telegram.ext import (
    Updater, CommandHandler, CallbackQueryHandler
)

from iot import constants
from iot.conversations.cmd_adduser import AddUserConversation
from iot.devices.base import BaseDevice
from iot.devices.errors import (
    CommandNotFound, InvalidArgument,
    SendCommandError
)
from iot.rooms import Room
from iot.utils import return_mac
from iot.utils.decorators import (
    valid_device, valid_device_or_room,
    valid_device_feature, valid_user
)
from iot.utils.keyboard.cmd_keyboard import (
    CommandKeyboardCBHandler
)
from iot.utils.keyboard.cmd_user import (
    TOP_MENU_TEXT as USER_TOP_MENU_TEXT,
    CommandUserCBHandler
)


logger = logging.getLogger(__name__)


KEYBOARD_HANDLER_NAME = "/keyboard"
USER_HANDLER_NAME = "/user"


class TelegramIOTServer:

    def __init__(self):
        # Telegram Bot settings
        self.bot_id = None
        self.bot_secret = None
        self.bot_name = None

        # Telegram
        self.updater: Updater = None
        self.dp = None

        # JSON files path
        self.devices_path = None
        self.commands_path = None
        self.users_path = None

        # Broadlink and Devices
        self.broadlink_devices = {}
        self.rooms = {}
        self.devices = {}
        self.commands = {}
        self.approved_users = {}

        # Keyboard query handlers
        self.kb_handlers = {}

        # Others
        self.start_time: datetime = None
        self.last_command_handled = None

    def start_server(self, bot_id, bot_secret, bot_name,
            devices_path, commands_path, users_path):
        self.bot_id = bot_id
        self.bot_secret = bot_secret
        self.bot_name = bot_name

        self.devices_path = devices_path
        self.commands_path = commands_path
        self.users_path = users_path

        self.discover_broadlink_device()

        self.reload_commands()
        self.reload_rooms_and_devices()
        self.reload_users()

        self.kb_handlers[KEYBOARD_HANDLER_NAME] = \
            CommandKeyboardCBHandler(self, KEYBOARD_HANDLER_NAME)

        self.kb_handlers[USER_HANDLER_NAME] = \
            CommandUserCBHandler(self, USER_HANDLER_NAME)

        self.init_telegram_server()

    def init_telegram_server(self):
        token: str = '{}:{}'.format(self.bot_id, self.bot_secret)
        self.updater = Updater(
            token,
            user_sig_handler=self.stop_server
        )

        # Get the dispatcher to register handlers
        self.dp = self.updater.dispatcher

        self.dp.add_handler(CommandHandler("start", self.command_start))
        self.dp.add_handler(CommandHandler("ping", self.command_ping))
        self.dp.add_handler(CommandHandler("status", self.command_status))
        self.dp.add_handler(CommandHandler("list", self.command_list))
        self.dp.add_handler(CommandHandler(
            "keyboard", self.command_keyboard, pass_args=True
        ))
        self.dp.add_handler(CommandHandler(
            "user", self.command_user, pass_args=True
        ))
        self.dp.add_handler(CallbackQueryHandler(self.handle_keyboard_response))
        self.dp.add_handler(CommandHandler(
            "on", self.command_on, pass_args=True
        ))
        self.dp.add_handler(CommandHandler(
            "off", self.command_off, pass_args=True
        ))
        self.dp.add_handler(CommandHandler(
            "d", self.command_device, pass_args=True
        ))

        self.add_conversations()

        self.dp.add_error_handler(self.error)

        self.updater.start_polling()
        logger.info("Telegram IOT Server Running...")
        self.start_time = datetime.now()

        self.updater.idle()

    def add_conversations(self):
        # TODO: make a map and initialize it?
        AddUserConversation(
            self, ["adduser"], ["canceladduser"]
        )

    def reload_rooms_and_devices(self):
        try:
            with open(self.devices_path) as f:
                try:
                    data: dict = json.load(f)

                    if len(data) == 0:
                        logger.warning(
                            "Please add your rooms and devices to %s",
                            self.devices_path
                        )
                        return
                except (ValueError, TypeError) as e:
                    logger.error("Decoding devices json file failed: %s", e)
                    return
                else:
                    for room_name, value in data.items():
                        if room_name not in self.rooms:
                            try:
                                rm_device = None
                                to_populate_bl_device = False

                                # Check for RM Device
                                if all(
                                    k in value for k in (
                                        "mac_address",
                                        "broadlink_type"
                                    )
                                ):
                                    rm_mac_address: str = value["mac_address"]
                                    rm_broadlink_type: str = value["broadlink_type"]
                                    rm_device = self.find_broadlink_device(
                                        rm_mac_address, rm_broadlink_type
                                    )

                                if 'broadlink_devices' in value.keys():
                                    to_populate_bl_device = True

                                if rm_device or to_populate_bl_device:
                                    r: Room = Room(room_name, rm_device)
                                    pop_device: List[BaseDevice] = r.populate_devices(
                                        value.get("devices", []))

                                    r.populate_broadlink_devices(
                                        value.get("broadlink_devices", [])
                                    )

                                    # Only populate room if there are any
                                    # normal / broadlink devices
                                    if (
                                        len(r.DEVICES) > 0 or
                                        len(r.BL_DEVICES) > 0
                                    ):
                                        self.rooms[room_name] = r

                                    for pd in pop_device:
                                        commands: Dict = self.get_commands(
                                            pd.device_type.value,
                                            pd.brand,
                                            pd.model
                                        )
                                        if commands:
                                            pd.populate_device_commands(commands)

                                        self.devices[pd.id] = pd

                            except Exception as e:
                                logger.error("Error While reloading rooms and devices: %s", e)
                                continue
        except FileNotFoundError as e:
            logger.error("Devices file not found %s", self.devices_path)
            raise e

    def find_broadlink_device(self, mac, bl_type):
            bl = self.broadlink_devices.get(mac)

            if bl and bl_type == bl.type:
                return bl

    def discover_broadlink_device(self):
        # Temp Code for when running server
        # with multiple network interface
        # TODO: Remove this code as it should be added to
        # https://github.com/mjg59/python-broadlink/blob/master/broadlink/__init__.py#L66
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

        try:
            s.connect(('10.255.255.255', 1))
            local_ip = s.getsockname()[0]
        except:
            local_ip = '127.0.0.1'
        finally:
            s.close()

        logger.info("Local IP: %s", local_ip)
        logger.info("Discovering broadlink devices...")
        bl_devices: list = broadlink.discover(
            timeout=5, local_ip_address=local_ip
        )

        for bl in bl_devices:
            bl.auth()
            mac = return_mac(bl.mac)
            self.broadlink_devices[mac] = bl
            logger.info("Discovered %s device with %s mac", bl.type, mac)

    def reload_commands(self):
        try:
            with open(self.commands_path) as f:
                try:
                    data: dict = json.load(f)
                except (ValueError, TypeError) as e:
                    logger.error("Decoding commands json file failed: %s", e)
                    return
                else:
                    self.commands = data
        except FileNotFoundError as e:
            logger.error("Commands file not found %s", self.commands_path)
            raise e

    def get_commands(self, device_type, brand, model) -> Dict[str, str]:
        # Always convert device_type to String,
        # as populated command dict key is in String
        device_type = str(device_type)

        try:
            return self.commands[device_type][brand][model]
        except KeyError:
            logger.error("Command not found for %s-%s-%s",
                device_type, brand, model)

            return {}

    def reload_users(self):
        try:
            with open(self.users_path) as f:
                try:
                    data: Dict[str, str] = json.load(f)

                    if len(data) == 0:
                        logger.warning(
                            "Please populate at least one user to %s",
                            self.users_path
                        )
                except (ValueError, TypeError) as e:
                    logger.error("Decoding users json file failed: %s", e)
                    return
                else:
                    self.approved_users = data
        except FileNotFoundError as e:
            logger.error("Users file not found %s", self.users_path)
            raise e

    def save_users(self):
        logger.info("Saving approved users to %s", self.users_path)

        with open(self.users_path, "w") as f:
            try:
                json.dump(self.approved_users, f, indent=4, sort_keys=True)
            except Exception as e:
                logger.error("Error while saving users to json file: %s", e)

    def error(self, update, context):
        """Log Errors caused by Updates."""
        logger.warning('Update "%s" caused error "%s"', update, context.error)

    @property
    def uptime(self):
        now = datetime.now()
        uptime = str(
            timedelta(
                seconds=(now-self.start_time).total_seconds()
            )
        )

        return uptime

    @property
    def broadlink_devices_info(self):
        all_bb_info = []

        for bb in self.broadlink_devices.values():
            bb_info = "Type: {}, IP: {}, Mac: {}".format(
                bb.type, bb.host[0],
                return_mac(bb.mac)
            )
            all_bb_info.append(bb_info)

        return '\n'.join(all_bb_info)

    @valid_user
    def command_start(self, update, context):
        """Send a message when the command `/start` is issued."""
        update.message.reply_markdown(
            constants.START_MESSAGE.format(self.bot_name)
        )

    def command_ping(self, update, context):
        """Sends message `pong` and user's id, name"""
        user = update.effective_user
        update.message.reply_markdown(constants.PONG_MESSAGE.format(
            user.username, user.id
        ))

    @valid_user
    def command_status(self, update, context):
        """Sends server status back"""
        server_info = constants.STATUS_MESSAGE.format(
            str(datetime.now()).split(".")[0],
            self.uptime,
            self.last_command_handled,
            self.broadlink_devices_info,
            len(self.rooms),
            len(self.devices),
            ", ".join(self.approved_users.values())
        )

        update.message.reply_text(server_info)

    @valid_user
    def command_list(self, update, context):
        """Sends list of broadlink devices, rooms and devices in room"""
        if len(self.rooms) == 0:
            update.message.reply_markdown(
                constants.NO_ROOM_MESSAGE.format(self.devices_path)
            )
            return

        rooms_info = [str(r.room_list_info()) for r in self.rooms.values()]

        update.message.reply_markdown(constants.LIST_MESSAGE.format(
            "\n".join(rooms_info)
        ))

    @valid_user
    @valid_device_or_room(compulsory=False)
    def command_keyboard(self, update, context, *args, **kwargs):
        """Sends Inline keyboard to access rooms and devices"""
        if len(self.rooms) == 0:
            update.message.reply_markdown(
                constants.NO_ROOM_MESSAGE.format(self.devices_path)
            )
            return

        handler = self.kb_handlers[KEYBOARD_HANDLER_NAME]

        # By default, reply markup will be rooms keyboard
        reply_markup = handler.build_rooms_keyboard()
        text = "Select room"

        room = kwargs.pop("room", None)
        device = kwargs.pop("device", None)

        # If room, device can be found, that will be the markup
        if room:
            reply_markup = handler.build_room_devices_keyboard(room.name)
            text = "Select {} device".format(room.name)
        elif device:
            reply_markup = handler.build_device_keyboard(device.id)
            text = "Select {} feature".format(device.id)

        update.message.reply_text(text, reply_markup=reply_markup)

    @valid_user
    def command_user(self, update, context, *args, **kwargs):
        """Sends inline keyboard to view approved users"""
        handler = self.kb_handlers[USER_HANDLER_NAME]

        reply_markup = handler.build_users_keyboard()

        update.message.reply_text(USER_TOP_MENU_TEXT,
            reply_markup=reply_markup)

    @valid_user
    def handle_keyboard_response(self, update, context):
        query = update.callback_query
        handler_name, internal_cb_data = query.data.split(" ", 1)

        if handler_name in self.kb_handlers.keys():
            self.kb_handlers[handler_name].process_query(
                update, context, internal_cb_data
            )
        else:
            logger.error(
                "Unable to find handler_name, %s in kb_handlers",
                handler_name
            )

    @valid_user
    @valid_device
    def command_on(self, update, context, device, *args, **kwargs):
        """Turn ON targeted device if device id can be found"""
        device.power_on()

    @valid_user
    @valid_device
    def command_off(self, update, context, device, *args, **kwargs):
        """Turn OFF targeted device if device id can be found"""
        device.power_off()

    @valid_user
    @valid_device_feature
    def command_device(self, update, context, device, feature,
            action=None, *args, **kwargs
        ):
        """
        Command device specific feature if device_id, feature and
        action can be found, passthrough function and call
        server's call_device method
        """
        self.call_device(update, context, device, feature,
            action=action, *args, **kwargs)

    def call_device(self, update, context, device, feature,
            action=None, handler_name=None, *args, **kwargs):
        """
        Call specified device's feature and action,
        if it can be found
        """
        def send_text(update, context, message):
            if update.callback_query:
                self.kb_handlers[handler_name].answer_query(
                    update.callback_query, context,
                    text=message
                )
            else:
                update.message.reply_markdown(message)

        func = getattr(device, feature)

        new_args = args

        if action:
            new_args = (action,)

        try:
            result = func(*new_args)
            text = "Sent {} with {}".format(device.id, feature)

            if isinstance(result, tuple):
                text = result[1]

            if update.callback_query:
                self.kb_handlers[handler_name].answer_query(
                    update.callback_query, context,
                    text=text
                )
        except (NotImplementedError, CommandNotFound):
            action = '' if not action else action

            send_text(update, context,
                constants.DEVICE_COMMAND_NOT_IMPLEMENTED.format(
                    device.id, feature, action)
            )
        except SendCommandError as e:
            send_text(update, context,
                constants.SEND_DEVICE_COMMAND_ERROR.format(
                    device.id, feature, e
                )
            )
        except (TypeError, InvalidArgument):
            send_text(update, context, constants.ARGS_ERROR)

    def stop_server(self, *args, **kwargs):
        logger.info("Telegram IOT Server stopping...")

        # Save approved users json
        # TODO: optimize this, save only on approved_users change
        self.save_users()


iot_server: TelegramIOTServer = TelegramIOTServer()
# Telegram Bot settings # Telegram # JSON files path # Broadlink and Devices # Keyboard query handlers # Others # Get the dispatcher to register handlers # TODO: make a map and initialize it? # Check for RM Device # Only populate room if there are any # normal / broadlink devices # Temp Code for when running server # with multiple network interface # TODO: Remove this code as it should be added to # https://github.com/mjg59/python-broadlink/blob/master/broadlink/__init__.py#L66 # Always convert device_type to String, # as populated command dict key is in String Log Errors caused by Updates. Send a message when the command `/start` is issued. Sends message `pong` and user's id, name Sends server status back Sends list of broadlink devices, rooms and devices in room Sends Inline keyboard to access rooms and devices # By default, reply markup will be rooms keyboard # If room, device can be found, that will be the markup Sends inline keyboard to view approved users Turn ON targeted device if device id can be found Turn OFF targeted device if device id can be found Command device specific feature if device_id, feature and         action can be found, passthrough function and call         server's call_device method Call specified device's feature and action,         if it can be found # Save approved users json # TODO: optimize this, save only on approved_users change