├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml └── FUNDING.yml ├── imgs ├── nopreview.png └── nosignal.png ├── scripts ├── requirements.txt ├── requirements.dev.txt ├── base_install_template └── install.sh ├── bot ├── power_device.py ├── configuration.py ├── timelapse.py ├── notifications.py ├── camera.py ├── klippy.py └── main.py ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | .idea 3 | __pycache__ -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true -------------------------------------------------------------------------------- /imgs/nopreview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xyzroe/moonraker-telegram-bot/master/imgs/nopreview.png -------------------------------------------------------------------------------- /imgs/nosignal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xyzroe/moonraker-telegram-bot/master/imgs/nosignal.png -------------------------------------------------------------------------------- /scripts/requirements.txt: -------------------------------------------------------------------------------- 1 | tzlocal==2.1 2 | ujson==5.1.0 3 | PySocks==1.7.1 4 | python-telegram-bot==13.11 5 | wsaccel==0.6.3 6 | websocket-client==1.2.3 7 | Pillow==9.0.1 8 | emoji==1.6.3 9 | requests==2.27.1 -------------------------------------------------------------------------------- /scripts/requirements.dev.txt: -------------------------------------------------------------------------------- 1 | tzlocal==2.1 2 | python-telegram-bot==13.11 3 | PySocks==1.7.1 4 | ujson==5.1.0 5 | wsaccel==0.6.3 6 | websocket-client==1.2.3 7 | Pillow==9.0.1 8 | emoji==1.6.3 9 | requests==2.27.1 10 | opencv-python~=3.4.8.29 11 | numpy~=1.20.1 12 | memory_profiler==0.60.0 13 | -------------------------------------------------------------------------------- /scripts/base_install_template: -------------------------------------------------------------------------------- 1 | [bot] 2 | server: localhost 3 | chat_id: 123456789 4 | bot_token: AweSomeBotToken 5 | log_path: some_log_path 6 | 7 | [camera] 8 | host: http://localhost:8080/?action=stream 9 | 10 | [progress_notification] 11 | percent: 5 12 | height: 5 13 | time: 5 14 | 15 | [timelapse] 16 | cleanup: true 17 | height: 0.2 18 | time: 5 19 | target_fps: 30 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: You have a good idea for the bot? 3 | title: "A useful, descriptive title" 4 | labels: ["Feature Request"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thank you for taking the time to suggest a feature. 10 | - type: textarea 11 | id: Suggestion 12 | attributes: 13 | label: What would you like to see added? 14 | description: | 15 | What would you like to see implemented? 16 | placeholder: Don't be shy, tell us! 17 | validations: 18 | required: true -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: lefskiy 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /bot/power_device.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | 4 | import requests 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class PowerDevice(object): 10 | def __new__(cls, name: str, moonraker_host: str): 11 | if name: 12 | return super(PowerDevice, cls).__new__(cls) 13 | else: 14 | return None 15 | 16 | def __init__(self, name: str, moonraker_host: str): 17 | self.name: str = name 18 | self._moonraker_host = moonraker_host 19 | self._state_lock = threading.Lock() 20 | self._device_on: bool = False 21 | 22 | @property 23 | def device_state(self) -> bool: 24 | with self._state_lock: 25 | return self._device_on 26 | 27 | @device_state.setter 28 | def device_state(self, state: bool): 29 | with self._state_lock: 30 | self._device_on = state 31 | 32 | def toggle_device(self) -> bool: 33 | return self.switch_device(not self.device_state) 34 | 35 | # Fixme: add auth params 36 | # Todo: return exception? 37 | def switch_device(self, state: bool) -> bool: 38 | with self._state_lock: 39 | if state: 40 | res = requests.post(f"http://{self._moonraker_host}/machine/device_power/device?device={self.name}&action=on") 41 | if res.ok: 42 | self._device_on = True 43 | return True 44 | else: 45 | logger.error(f'Power device switch failed: {res.reason}') 46 | else: 47 | res = requests.post(f"http://{self._moonraker_host}/machine/device_power/device?device={self.name}&action=off") 48 | if res.ok: 49 | self._device_on = False 50 | return False 51 | else: 52 | logger.error(f'Power device switch failed: {res.reason}') 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # moonraker-telegram-bot 2 | 3 | ![image](https://user-images.githubusercontent.com/51682059/140623765-3b839b4b-40c2-4f87-8969-6cb609f2c5f1.png) 4 | 5 | 6 | The general idea of this project is to provide you with a way to control and monitor your printer without having to setup a vpn, opening your home network, or doing any sort of other network-related vodoo. 7 | In addition you get the benefits of push-style notifications always in your pocket, and a bandwidth-friendly way to check up on your print progress, when not near the printer. 8 | 9 | As always with solutions like these, we kindly remind you not to print unattended, and always to take all necessary precautions against fire hazards. 10 | 11 | 12 | ## Features and Installation: 13 | 14 | Please check out our [wiki](https://github.com/nlef/moonraker-telegram-bot/wiki) for installation instructions and detailed feature descriptions. 15 | 16 | ## Issues and bug reports 17 | 18 | We will be happy to assist you with any issues that you have, as long as you can form a coherent sentence and are polite in your requests. 19 | Please open an issue, and we will try our best to reproduce and fix it. 20 | Feature requests and ideas are also more than welcome. 21 | 22 | When writing issues/contacting for support please attach the 'telegram.log' as well as the output of `sudo journalctl -r -u moonraker-telegram-bot`. 23 | 24 | 25 | 26 | 27 | ### Happy Printing! 28 | 29 | 30 | 31 | 32 | 33 | --- 34 | 35 | **Klipper** by [KevinOConnor](https://github.com/KevinOConnor) : 36 | 37 | https://github.com/KevinOConnor/klipper 38 | 39 | --- 40 | **Moonraker** by [Arksine](https://github.com/Arksine) : 41 | 42 | https://github.com/Arksine/moonraker 43 | 44 | --- 45 | 46 | **Fluidd Webinterface** by [cadriel](https://github.com/cadriel) : 47 | 48 | https://github.com/cadriel/fluidd 49 | 50 | --- 51 | 52 | **KIAUH - Klipper Installation And Update Helper** by [th33xitus](https://github.com/th33xitus) : 53 | 54 | https://github.com/th33xitus/KIAUH 55 | 56 | --- -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Something not working as described? 3 | title: "[Bug]: A useful, descriptive title" 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thank you for taking the time to file a report. 10 | - type: textarea 11 | id: Problem 12 | attributes: 13 | label: What happened and in what context? 14 | description: | 15 | Describe the problem as best as you can: 16 | 1. What did you do? 17 | 2. What happened? 18 | 3. What did you expect to happen instead? It might be obvious to you, but do it anyway! 19 | 4. **Add your telegram.log as well as well as the output of** `sudo journalctl -r -u moonraker-telegram-bot`. 20 | 21 | 22 | - Write down the steps to reproduce the bug if you know them. 23 | - Does it happen always, once, or sometimes? 24 | placeholder: Don't be shy, tell us! 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: log 29 | attributes: 30 | label: If needed, paste the relevant bot log contents here. 31 | description: | 32 | If you think your issue is caused by a bug or software errors, please attach the bot log. 33 | placeholder: Paste telegram.log contents here. 34 | - type: textarea 35 | id: journalctl 36 | attributes: 37 | label: If needed, paste the relevant bot journal contents here. 38 | description: | 39 | If you think your issue is caused by a bug or software errors, please attach the journal content 40 | You can get it by entering `sudo journalctl -r -u moonraker-telegram-bot` into the console. 41 | placeholder: Paste journal contents here. 42 | - type: dropdown 43 | id: version 44 | attributes: 45 | label: What branch does this occur on? 46 | options: 47 | - development 48 | - master 49 | validations: 50 | required: true 51 | - type: checkboxes 52 | id: terms 53 | attributes: 54 | label: Reports only on latest master or development branches! 55 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://example.com) 56 | options: 57 | - label: I have updated to latest development/master version before submitting the bug 58 | required: true -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script installs Moonraker telegram bot 3 | set -eu 4 | 5 | SYSTEMDDIR="/etc/systemd/system" 6 | MOONRAKER_BOT_ENV="${HOME}/moonraker-telegram-bot-env" 7 | MOONRAKER_BOT_DIR="${HOME}/moonraker-telegram-bot" 8 | MOONRAKER_BOT_LOG="${HOME}/klipper_logs" 9 | KLIPPER_CONF_DIR="${HOME}/klipper_config" 10 | CURRENT_USER=${USER} 11 | 12 | # Helper functions 13 | report_status() { 14 | echo -e "\n\n###### $1" 15 | } 16 | 17 | # Main functions 18 | init_config_path() { 19 | if [ -z ${klipper_cfg_loc+x} ]; then 20 | report_status "Telegram bot configuration file location selection" 21 | echo -e "\n\n\n" 22 | echo "Enter the path for the configuration file location." 23 | echo "Its recommended to store it together with the klipper configuration for easier backup and usage." 24 | read -p "Enter desired path: " -e -i "${KLIPPER_CONF_DIR}" klip_conf_dir 25 | KLIPPER_CONF_DIR=${klip_conf_dir} 26 | else 27 | KLIPPER_CONF_DIR=${klipper_cfg_loc} 28 | fi 29 | report_status "Bot configuration file will be located in ${KLIPPER_CONF_DIR}" 30 | } 31 | 32 | create_initial_config() { 33 | # check in config exists! 34 | if [[ ! -f "${KLIPPER_CONF_DIR}"/telegram.conf ]]; then 35 | report_status "Telegram bot log file location selection" 36 | echo -e "\n\n\n" 37 | echo "Enter the path for the log file location." 38 | echo "Its recommended to store it together with the klipper log files for easier backup and usage." 39 | read -p "Enter desired path: " -e -i "${MOONRAKER_BOT_LOG}" bot_log_path 40 | MOONRAKER_BOT_LOG=${bot_log_path} 41 | report_status "Bot logs will be located in ${MOONRAKER_BOT_LOG}" 42 | 43 | report_status "Creating base config file" 44 | cp -n "${MOONRAKER_BOT_DIR}"/scripts/base_install_template "${KLIPPER_CONF_DIR}"/telegram.conf 45 | 46 | sed -i "s+some_log_path+${MOONRAKER_BOT_LOG}+g" "${KLIPPER_CONF_DIR}"/telegram.conf 47 | fi 48 | } 49 | 50 | stop_sevice() { 51 | serviceName="moonraker-telegram-bot" 52 | if sudo systemctl --all --type service --no-legend | grep "$serviceName" | grep -q running; then 53 | ## stop existing instance 54 | report_status "Stopping moonraker-telegram-bot instance ..." 55 | sudo systemctl stop moonraker-telegram-bot 56 | else 57 | report_status "$serviceName service does not exist or is not running." 58 | fi 59 | } 60 | 61 | install_packages() { 62 | PKGLIST="python3-virtualenv python3-dev python3-cryptography python3-gevent python3-opencv x264 libx264-dev libwebp-dev" 63 | 64 | report_status "Running apt-get update..." 65 | sudo apt-get update --allow-releaseinfo-change 66 | 67 | report_status "Installing packages..." 68 | sudo apt-get install --yes ${PKGLIST} 69 | } 70 | 71 | create_virtualenv() { 72 | report_status "Installing python virtual environment..." 73 | 74 | mkdir -p "${HOME}"/space 75 | virtualenv -p /usr/bin/python3 --system-site-packages "${MOONRAKER_BOT_ENV}" 76 | export TMPDIR=${HOME}/space 77 | "${MOONRAKER_BOT_ENV}"/bin/pip install --no-cache-dir -r "${MOONRAKER_BOT_DIR}"/scripts/requirements.txt 78 | } 79 | 80 | create_service() { 81 | ### create systemd service file 82 | sudo /bin/sh -c "cat > ${SYSTEMDDIR}/moonraker-telegram-bot.service" < str: 8 | if not config.has_section(section_name): 9 | return '' 10 | unknwn = list(map(lambda fil: f" {fil[0]}: {fil[1]}\n", filter(lambda el: el[0] not in known_items, config.items(section_name)))) 11 | if unknwn: 12 | return f"Unknown/bad items in [{section_name}] section:\n{''.join(unknwn)}\n" 13 | else: 14 | return '' 15 | 16 | 17 | class BotConfig: 18 | _SECTION = 'bot' 19 | _KNOWN_ITEMS = ['server', 'socks_proxy', 'bot_token', 'chat_id', 'debug', 'log_parser', 'log_path', 'power_device', 'light_device', 'user', 'password'] 20 | 21 | def __init__(self, config: configparser.ConfigParser): 22 | self.host: str = config.get(self._SECTION, 'server', fallback='localhost') 23 | self.socks_proxy: str = config.get(self._SECTION, 'socks_proxy', fallback='') 24 | self.token: str = config.get(self._SECTION, 'bot_token') 25 | self.api_url: str = config.get(self._SECTION, 'api_url', fallback='https://api.telegram.org/bot') 26 | self.chat_id: int = config.getint(self._SECTION, 'chat_id') 27 | self.debug: bool = config.getboolean(self._SECTION, 'debug', fallback=False) 28 | self.log_parser: bool = config.getboolean(self._SECTION, 'log_parser', fallback=False) 29 | self.log_path: str = config.get(self._SECTION, 'log_path', fallback='/tmp') 30 | self.poweroff_device_name: str = config.get(self._SECTION, 'power_device', fallback='') 31 | self.light_device_name: str = config.get(self._SECTION, 'light_device', fallback="") 32 | self.user: str = config.get(self._SECTION, 'user', fallback='') 33 | self.passwd: str = config.get(self._SECTION, 'password', fallback='') 34 | 35 | self.unknown_fields: str = _check_config(config, self._SECTION, self._KNOWN_ITEMS) 36 | 37 | 38 | class CameraConfig: 39 | _SECTION = 'camera' 40 | _KNOWN_ITEMS = ['host', 'threads', 'flip_vertically', 'flip_horizontally', 'rotate', 'fourcc', 'video_duration', 'video_buffer_size', 'fps', 'light_control_timeout', 'picture_quality'] 41 | 42 | def __init__(self, config: configparser.ConfigParser): 43 | self.enabled: bool = config.has_section(self._SECTION) 44 | self.host: str = config.get(self._SECTION, 'host', fallback='') 45 | self.threads: int = config.getint(self._SECTION, 'threads', fallback=int(os.cpu_count() / 2)) 46 | self.flip_vertically: bool = config.getboolean(self._SECTION, 'flip_vertically', fallback=False) 47 | self.flip_horizontally: bool = config.getboolean(self._SECTION, 'flip_horizontally', fallback=False) 48 | self.rotate: str = config.get(self._SECTION, 'rotate', fallback='') 49 | self.fourcc: str = config.get(self._SECTION, 'fourcc', fallback='x264') 50 | self.video_duration: int = config.getint(self._SECTION, 'video_duration', fallback=5) 51 | self.video_buffer_size: int = config.getint(self._SECTION, 'video_buffer_size', fallback=2) 52 | self.stream_fps: int = config.getint(self._SECTION, 'fps', fallback=0) 53 | self.light_timeout: int = config.getint(self._SECTION, 'light_control_timeout', fallback=0) 54 | self.picture_quality: str = config.get(self._SECTION, 'picture_quality', fallback='high') 55 | self.unknown_fields: str = _check_config(config, self._SECTION, self._KNOWN_ITEMS) 56 | 57 | 58 | class NotifierConfig: 59 | _SECTION = 'progress_notification' 60 | _KNOWN_ITEMS = ['percent', 'height', 'time', 'groups', 'group_only'] 61 | 62 | def __init__(self, config: configparser.ConfigParser): 63 | self.percent: int = config.getint(self._SECTION, 'percent', fallback=0) 64 | self.height: float = config.getfloat(self._SECTION, 'height', fallback=0) 65 | self.interval: int = config.getint(self._SECTION, 'time', fallback=0) 66 | self.notify_groups: List[int] = [int(el.strip()) for el in config.get(self._SECTION, 'groups').split(',')] if config.has_option(self._SECTION, 'groups') else [] 67 | self.group_only: bool = config.getboolean(self._SECTION, 'group_only', fallback=False) 68 | self.unknown_fields: str = _check_config(config, self._SECTION, self._KNOWN_ITEMS) 69 | 70 | 71 | class TimelapseConfig: 72 | _SECTION = 'timelapse' 73 | _KNOWN_ITEMS = ['basedir', 'copy_finished_timelapse_dir', 'cleanup', 'manual_mode', 'height', 'time', 'target_fps', 'min_lapse_duration', 'max_lapse_duration', 'last_frame_duration', 'after_lapse_gcode', 74 | 'send_finished_lapse'] 75 | 76 | def __init__(self, config: configparser.ConfigParser): 77 | self.enabled: bool = config.has_section(self._SECTION) 78 | self.base_dir: str = config.get(self._SECTION, 'basedir', fallback='/tmp/timelapse') # Fixme: relative path failed! ~/timelapse 79 | self.ready_dir: str = config.get(self._SECTION, 'copy_finished_timelapse_dir', fallback='') # Fixme: relative path failed! ~/timelapse 80 | self.cleanup: bool = config.getboolean(self._SECTION, 'cleanup', fallback=True) 81 | self.mode_manual: bool = config.getboolean(self._SECTION, 'manual_mode', fallback=False) 82 | self.height: float = config.getfloat(self._SECTION, 'height', fallback=0.0) 83 | self.interval: int = config.getint(self._SECTION, 'time', fallback=0) 84 | self.target_fps: int = config.getint(self._SECTION, 'target_fps', fallback=15) 85 | self.min_lapse_duration: int = config.getint(self._SECTION, 'min_lapse_duration', fallback=0) 86 | self.max_lapse_duration: int = config.getint(self._SECTION, 'max_lapse_duration', fallback=0) 87 | self.last_frame_duration: int = config.getint(self._SECTION, 'last_frame_duration', fallback=5) 88 | 89 | # Todo: add to runtime params section! 90 | self.after_lapse_gcode: str = config.get(self._SECTION, 'after_lapse_gcode', fallback='') 91 | self.send_finished_lapse: bool = config.getboolean(self._SECTION, 'send_finished_lapse', fallback=True) 92 | 93 | self.unknown_fields: str = _check_config(config, self._SECTION, self._KNOWN_ITEMS) 94 | 95 | 96 | class TelegramUIConfig: 97 | _SECTION = 'telegram_ui' 98 | _KNOWN_ITEMS = ['silent_progress', 'silent_commands', 'silent_status', 'status_single_message', 'pin_status_single_message', 'status_message_content', 'buttons', 'require_confirmation_macro', 99 | 'include_macros_in_command_list', 'disabled_macros', 'show_hidden_macros', 'eta_source', 'status_message_sensors', 'status_message_heaters', 'status_message_devices', 'status_message_temperature_fans'] 100 | _MESSAGE_CONTENT = ['progress', 'height', 'filament_length', 'filament_weight', 'print_duration', 'eta', 'finish_time', 'm117_status', 'tgnotify_status', 'last_update_time'] 101 | 102 | def __init__(self, config: configparser.ConfigParser): 103 | self.silent_progress: bool = config.getboolean(self._SECTION, 'silent_progress', fallback=False) 104 | self.silent_commands: bool = config.getboolean(self._SECTION, 'silent_commands', fallback=False) 105 | self.silent_status: bool = config.getboolean(self._SECTION, 'silent_status', fallback=False) 106 | self.status_single_message: bool = config.getboolean(self._SECTION, 'status_single_message', fallback=True) 107 | self.pin_status_single_message: bool = config.getboolean(self._SECTION, 'pin_status_single_message', fallback=False) # Todo: implement 108 | self.status_message_content: List[str] = [el.strip() for el in config.get(self._SECTION, 'status_message_content').split(',')] if config.has_option(self._SECTION, 109 | 'status_message_content') else self._MESSAGE_CONTENT 110 | 111 | buttons_string = config.get(self._SECTION, 'buttons') if config.has_option(self._SECTION, 'buttons') else '[status,pause,cancel,resume],[files,emergency,macros,shutdown]' 112 | self.buttons: List[List[str]] = list(map(lambda el: list(map(lambda iel: f'/{iel.strip()}', el.replace('[', '').replace(']', '').split(','))), re.findall(r'\[.[^\]]*\]', buttons_string))) 113 | self.buttons_default: bool = False if config.has_option(self._SECTION, 'buttons') else True 114 | self.require_confirmation_macro: bool = config.getboolean(self._SECTION, 'require_confirmation_macro', fallback=True) 115 | self.include_macros_in_command_list: bool = config.getboolean(self._SECTION, 'include_macros_in_command_list', fallback=True) 116 | self.disabled_macros: List[str] = [el.strip() for el in config.get(self._SECTION, 'disabled_macros').split(',')] if config.has_option(self._SECTION, 'disabled_macros') else [] 117 | self.show_hidden_macros: bool = config.getboolean(self._SECTION, 'show_hidden_macros', fallback=False) 118 | self.eta_source: str = config.get(self._SECTION, 'eta_source', fallback='slicer') 119 | self.status_message_sensors: List[str] = [el.strip() for el in config.get(self._SECTION, 'status_message_sensors').split(',')] if config.has_option(self._SECTION, 'status_message_sensors') else [] 120 | self.status_message_heaters: List[str] = [el.strip() for el in config.get(self._SECTION, 'status_message_heaters').split(',')] if config.has_option(self._SECTION, 'status_message_heaters') else [] 121 | self.status_message_temp_fans: List[str] = [el.strip() for el in config.get(self._SECTION, 'status_message_temperature_fans').split(',')] if config.has_option(self._SECTION, 'status_message_temperature_fans') else [] 122 | self.status_message_devices: List[str] = [el.strip() for el in config.get(self._SECTION, 'status_message_devices').split(',')] if config.has_option(self._SECTION, 'status_message_devices') else [] 123 | self.unknown_fields: str = _check_config(config, self._SECTION, self._KNOWN_ITEMS) 124 | 125 | 126 | class ConfigWrapper: 127 | def __init__(self, config: configparser.ConfigParser): 128 | self.bot = BotConfig(config) 129 | self.camera = CameraConfig(config) 130 | self.notifications = NotifierConfig(config) 131 | self.timelapse = TimelapseConfig(config) 132 | self.telegram_ui = TelegramUIConfig(config) 133 | self.unknown_fields = self.bot.unknown_fields + self.camera.unknown_fields + self.notifications.unknown_fields + self.timelapse.unknown_fields + self.telegram_ui.unknown_fields 134 | -------------------------------------------------------------------------------- /bot/timelapse.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from concurrent.futures import ThreadPoolExecutor 4 | 5 | from apscheduler.schedulers.base import BaseScheduler 6 | from telegram import ChatAction, Message, Bot 7 | 8 | from configuration import ConfigWrapper 9 | from camera import Camera 10 | from klippy import Klippy 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Timelapse: 16 | def __init__(self, config: ConfigWrapper, klippy: Klippy, camera: Camera, scheduler: BaseScheduler, bot: Bot, logging_handler: logging.Handler = None): 17 | self._enabled: bool = config.timelapse.enabled and camera.enabled 18 | self._mode_manual: bool = config.timelapse.mode_manual 19 | self._height: float = config.timelapse.height 20 | self._interval: int = config.timelapse.interval 21 | self._target_fps: int = config.timelapse.target_fps 22 | self._min_lapse_duration: int = config.timelapse.min_lapse_duration 23 | self._max_lapse_duration: int = config.timelapse.max_lapse_duration 24 | self._last_frame_duration: int = config.timelapse.last_frame_duration 25 | 26 | # Todo: add to runtime params section! 27 | self._after_lapse_gcode: str = config.timelapse.after_lapse_gcode 28 | self._send_finished_lapse: bool = config.timelapse.send_finished_lapse 29 | 30 | self._silent_progress: bool = config.telegram_ui.silent_progress 31 | 32 | self._klippy = klippy 33 | self._camera = camera 34 | 35 | # push params to cameras instances 36 | self._camera.target_fps = self._target_fps 37 | self._camera.min_lapse_duration = self._min_lapse_duration 38 | self._camera.max_lapse_duration = self._max_lapse_duration 39 | self._camera.last_frame_duration = self._last_frame_duration 40 | 41 | self._sched = scheduler 42 | self._chat_id: int = config.bot.chat_id 43 | self._bot: Bot = bot 44 | 45 | self._running: bool = False 46 | self._paused: bool = False 47 | self._last_height: float = 0.0 48 | 49 | self._executors_pool: ThreadPoolExecutor = ThreadPoolExecutor(2) 50 | 51 | if logging_handler: 52 | logger.addHandler(logging_handler) 53 | if config.bot.debug: 54 | logger.setLevel(logging.DEBUG) 55 | 56 | @property 57 | def enabled(self) -> bool: 58 | return self._enabled 59 | 60 | @enabled.setter 61 | def enabled(self, new_value: bool): 62 | self._enabled = new_value 63 | 64 | @property 65 | def manual_mode(self) -> bool: 66 | return self._mode_manual 67 | 68 | @manual_mode.setter 69 | def manual_mode(self, new_value: bool): 70 | self._mode_manual = new_value 71 | 72 | @property 73 | def interval(self) -> int: 74 | return self._interval 75 | 76 | @interval.setter 77 | def interval(self, new_value: int): 78 | if new_value == 0: 79 | self._interval = new_value 80 | self._remove_timelapse_timer() 81 | elif new_value > 0: 82 | self._interval = new_value 83 | self._reschedule_timelapse_timer() 84 | 85 | @property 86 | def height(self) -> float: 87 | return self._height 88 | 89 | @height.setter 90 | def height(self, new_value: float): 91 | if new_value >= 0: 92 | self._height = new_value 93 | 94 | @property 95 | def target_fps(self) -> int: 96 | return self._target_fps 97 | 98 | @target_fps.setter 99 | def target_fps(self, new_value: int): 100 | if new_value >= 1: 101 | self._target_fps = new_value 102 | self._camera.target_fps = new_value 103 | 104 | @property 105 | def min_lapse_duration(self) -> int: 106 | return self._min_lapse_duration 107 | 108 | @min_lapse_duration.setter 109 | def min_lapse_duration(self, new_value: int): 110 | if new_value >= 0: 111 | if new_value <= self._max_lapse_duration and not new_value == 0: 112 | logger.warning(f"Min lapse duration {new_value} is lower than max lapse duration {self._max_lapse_duration}") 113 | self._min_lapse_duration = new_value 114 | self._camera.min_lapse_duration = new_value 115 | 116 | @property 117 | def max_lapse_duration(self) -> int: 118 | return self._max_lapse_duration 119 | 120 | @max_lapse_duration.setter 121 | def max_lapse_duration(self, new_value: int): 122 | if new_value >= 0: 123 | if new_value <= self._min_lapse_duration and not new_value == 0: 124 | logger.warning(f"Max lapse duration {new_value} is lower than min lapse duration {self._min_lapse_duration}") 125 | self._max_lapse_duration = new_value 126 | self._camera.max_lapse_duration = new_value 127 | 128 | @property 129 | def last_frame_duration(self) -> int: 130 | return self._last_frame_duration 131 | 132 | @last_frame_duration.setter 133 | def last_frame_duration(self, new_value: int): 134 | if new_value >= 0: 135 | self._last_frame_duration = new_value 136 | self._camera.last_frame_duration = new_value 137 | 138 | @property 139 | def running(self) -> bool: 140 | return self._running 141 | 142 | @running.setter 143 | def running(self, new_val: bool): 144 | self._running = new_val 145 | self._paused = False 146 | if new_val: 147 | self._add_timelapse_timer() 148 | else: 149 | self._remove_timelapse_timer() 150 | 151 | @property 152 | def paused(self) -> bool: 153 | return self._paused 154 | 155 | @paused.setter 156 | def paused(self, new_val: bool): 157 | self._paused = new_val 158 | if new_val: 159 | self._remove_timelapse_timer() 160 | elif self._running: 161 | self._add_timelapse_timer() 162 | 163 | def take_lapse_photo(self, position_z: float = -1001, manually: bool = False): 164 | if not self._enabled: 165 | logger.debug(f"lapse is disabled") 166 | return 167 | elif not self._klippy.printing_filename: 168 | logger.debug(f"lapse is inactive for file undefined") 169 | return 170 | elif not self._running: 171 | logger.debug(f"lapse is not running at the moment") 172 | return 173 | elif self._paused and not manually: 174 | logger.debug(f"lapse is paused at the moment") 175 | return 176 | elif not self._mode_manual and self._klippy.printing_duration <= 0.0: 177 | logger.debug(f"lapse must not run with auto mode and zero print duration") 178 | return 179 | 180 | if 0.0 < position_z < self._last_height - self._height: 181 | self._last_height = position_z 182 | 183 | if self._height > 0.0 and round(position_z * 100) % round(self._height * 100) == 0 and position_z > self._last_height: 184 | self._executors_pool.submit(self._camera.take_lapse_photo) 185 | self._last_height = position_z 186 | elif position_z < -1000: 187 | self._executors_pool.submit(self._camera.take_lapse_photo) 188 | 189 | def take_test_lapse_photo(self): 190 | self._executors_pool.submit(self._camera.take_lapse_photo) 191 | 192 | def clean(self): 193 | self._camera.clean() 194 | 195 | def _add_timelapse_timer(self): 196 | if self._interval > 0 and not self._sched.get_job('timelapse_timer'): 197 | self._sched.add_job(self.take_lapse_photo, 'interval', seconds=self._interval, id='timelapse_timer') 198 | 199 | def _remove_timelapse_timer(self): 200 | if self._sched.get_job('timelapse_timer'): 201 | self._sched.remove_job('timelapse_timer') 202 | 203 | def _reschedule_timelapse_timer(self): 204 | if self._interval > 0 and self._sched.get_job('timelapse_timer'): 205 | self._sched.add_job(self.take_lapse_photo, 'interval', seconds=self._interval, id='timelapse_timer', replace_existing=True) 206 | 207 | def _send_lapse(self): 208 | if not self._enabled or not self._klippy.printing_filename: 209 | logger.debug(f"lapse is inactive for enabled {self.enabled} or file undefined") 210 | else: 211 | lapse_filename = self._klippy.printing_filename_with_time 212 | gcode_name = self._klippy.printing_filename 213 | 214 | info_mess: Message = self._bot.send_message(chat_id=self._chat_id, text=f"Starting time-lapse assembly for {gcode_name}", disable_notification=self._silent_progress) 215 | 216 | if self._executors_pool._work_queue.qsize() > 0: 217 | info_mess.edit_text(text="Waiting for the completion of tasks for photographing") 218 | 219 | time.sleep(5) 220 | while self._executors_pool._work_queue.qsize() > 0: 221 | time.sleep(1) 222 | 223 | self._bot.send_chat_action(chat_id=self._chat_id, action=ChatAction.RECORD_VIDEO) 224 | (video_bio, thumb_bio, width, height, video_path, gcode_name) = self._camera.create_timelapse(lapse_filename, gcode_name, info_mess) 225 | 226 | if self._send_finished_lapse: 227 | info_mess.edit_text(text="Uploading time-lapse") 228 | 229 | if video_bio.getbuffer().nbytes > 52428800: 230 | info_mess.edit_text(text=f'Telegram bots have a 50mb filesize restriction, please retrieve the timelapse from the configured folder\n{video_path}') 231 | else: 232 | self._bot.send_video(self._chat_id, video=video_bio, thumb=thumb_bio, width=width, height=height, caption=f'time-lapse of {gcode_name}', timeout=120, disable_notification=self._silent_progress) 233 | self._bot.delete_message(self._chat_id, message_id=info_mess.message_id) 234 | else: 235 | info_mess.edit_text(text="Time-lapse creation finished") 236 | 237 | video_bio.close() 238 | thumb_bio.close() 239 | 240 | if self._after_lapse_gcode: 241 | # Todo: add exception handling 242 | self._klippy.save_data_to_marco(video_bio.getbuffer().nbytes, video_path, f'{gcode_name}.mp4') 243 | self._klippy.execute_command(self._after_lapse_gcode.strip()) 244 | 245 | def send_timelapse(self): 246 | self._sched.add_job(self._send_lapse, misfire_grace_time=None, coalesce=False, max_instances=1, replace_existing=False) 247 | 248 | def stop_all(self): 249 | self._remove_timelapse_timer() 250 | self._running = False 251 | self._paused = False 252 | self._last_height = 0.0 253 | 254 | def parse_timelapse_params(self, message: str): 255 | mass_parts = message.split(sep=" ") 256 | mass_parts.pop(0) 257 | response = '' 258 | for part in mass_parts: 259 | try: 260 | if 'enabled' in part: 261 | self.enabled = bool(int(part.split(sep="=").pop())) 262 | response += f"enabled={self.enabled} " 263 | elif 'manual_mode' in part: 264 | self.manual_mode = bool(int(part.split(sep="=").pop())) 265 | response += f"manual_mode={self.manual_mode} " 266 | elif 'height' in part: 267 | self.height = float(part.split(sep="=").pop()) 268 | response += f"height={self.height} " 269 | elif 'time' in part: 270 | self.interval = int(part.split(sep="=").pop()) 271 | response += f"time={self.interval} " 272 | elif 'target_fps' in part: 273 | self.target_fps = int(part.split(sep="=").pop()) 274 | response += f"target_fps={self.target_fps} " 275 | elif 'last_frame_duration' in part: 276 | self.last_frame_duration = int(part.split(sep="=").pop()) 277 | response += f"last_frame_duration={self.last_frame_duration} " 278 | elif 'min_lapse_duration' in part: 279 | self.min_lapse_duration = int(part.split(sep="=").pop()) 280 | response += f"min_lapse_duration={self.min_lapse_duration} " 281 | elif 'max_lapse_duration' in part: 282 | self.max_lapse_duration = int(part.split(sep="=").pop()) 283 | response += f"max_lapse_duration={self.max_lapse_duration} " 284 | else: 285 | self._klippy.execute_command(f'RESPOND PREFIX="Timelapse params error" MSG="unknown param `{part}`"') 286 | except Exception as ex: 287 | self._klippy.execute_command(f'RESPOND PREFIX="Timelapse params error" MSG="Failed parsing `{part}`. {ex}"') 288 | if response: 289 | full_conf = f"enabled={self.enabled} " \ 290 | f"manual_mode={self.manual_mode} " \ 291 | f"height={self.height} " \ 292 | f"time={self.interval} " \ 293 | f"target_fps={self.target_fps} " \ 294 | f"last_frame_duration={self.last_frame_duration} " \ 295 | f"min_lapse_duration={self.min_lapse_duration} " \ 296 | f"max_lapse_duration={self.max_lapse_duration} " 297 | self._klippy.execute_command(f'RESPOND PREFIX="Timelapse params" MSG="Changed timelapse params: {response}"') 298 | self._klippy.execute_command(f'RESPOND PREFIX="Timelapse params" MSG="Full timelapse config: {full_conf}"') 299 | -------------------------------------------------------------------------------- /bot/notifications.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from typing import List, Dict 4 | 5 | import telegram 6 | from apscheduler.schedulers.base import BaseScheduler 7 | from telegram import ChatAction, Bot, Message, InputMediaPhoto 8 | from telegram.constants import PARSEMODE_MARKDOWN_V2 9 | from telegram.utils.helpers import escape_markdown 10 | 11 | from configuration import ConfigWrapper 12 | from camera import Camera 13 | from klippy import Klippy 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class Notifier: 19 | def __init__(self, config: ConfigWrapper, bot: Bot, klippy: Klippy, camera_wrapper: Camera, scheduler: BaseScheduler, logging_handler: logging.Handler = None): 20 | self._bot: Bot = bot 21 | self._chat_id: int = config.bot.chat_id 22 | self._cam_wrap: Camera = camera_wrapper 23 | self._sched = scheduler 24 | self._klippy: Klippy = klippy 25 | 26 | self._percent: int = config.notifications.percent 27 | self._height: float = config.notifications.height 28 | self._interval: int = config.notifications.interval 29 | self._notify_groups: List[int] = config.notifications.notify_groups 30 | self._group_only: bool = config.notifications.group_only 31 | 32 | self._silent_progress: bool = config.telegram_ui.silent_progress 33 | self._silent_commands: bool = config.telegram_ui.silent_commands 34 | self._silent_status: bool = config.telegram_ui.silent_status 35 | self._status_single_message: bool = config.telegram_ui.status_single_message 36 | self._pin_status_single_message: bool = config.telegram_ui.pin_status_single_message # Todo: implement 37 | self._message_parts: List[str] = config.telegram_ui.status_message_content 38 | 39 | self._last_height: int = 0 40 | self._last_percent: int = 0 41 | self._last_m117_status: str = '' 42 | self._last_tgnotify_status: str = '' 43 | 44 | self._status_message: Message = None 45 | self._groups_status_mesages: Dict[int, Message] = {} 46 | 47 | if logging_handler: 48 | logger.addHandler(logging_handler) 49 | if config.bot.debug: 50 | logger.setLevel(logging.DEBUG) 51 | 52 | @property 53 | def silent_commands(self) -> bool: 54 | return self._silent_commands 55 | 56 | @property 57 | def silent_status(self) -> bool: 58 | return self._silent_status 59 | 60 | @property 61 | def m117_status(self) -> str: 62 | return self._last_m117_status 63 | 64 | @m117_status.setter 65 | def m117_status(self, new_value: str): 66 | self._last_m117_status = new_value 67 | if self._klippy.printing: 68 | self._schedule_notification() 69 | 70 | @property 71 | def tgnotify_status(self) -> str: 72 | return self._last_tgnotify_status 73 | 74 | @tgnotify_status.setter 75 | def tgnotify_status(self, new_value: str): 76 | self._last_tgnotify_status = new_value 77 | if self._klippy.printing: 78 | self._schedule_notification() 79 | 80 | @property 81 | def percent(self) -> int: 82 | return self._percent 83 | 84 | @percent.setter 85 | def percent(self, new_value: int): 86 | if new_value >= 0: 87 | self._percent = new_value 88 | 89 | @property 90 | def height(self) -> float: 91 | return self._height 92 | 93 | @height.setter 94 | def height(self, new_value: float): 95 | if new_value >= 0: 96 | self._height = new_value 97 | 98 | @property 99 | def interval(self) -> int: 100 | return self._interval 101 | 102 | @interval.setter 103 | def interval(self, new_value: int): 104 | if new_value == 0: 105 | self._interval = new_value 106 | self.remove_notifier_timer() 107 | elif new_value > 0: 108 | self._interval = new_value 109 | self._reschedule_notifier_timer() 110 | 111 | def _send_message(self, message: str, silent: bool, group_only: bool = False, manual: bool = False): 112 | if not group_only: 113 | self._bot.send_chat_action(chat_id=self._chat_id, action=ChatAction.TYPING) 114 | if self._status_single_message and not manual: 115 | if not self._status_message: 116 | self._status_message = self._bot.send_message(self._chat_id, text=message, parse_mode=PARSEMODE_MARKDOWN_V2, disable_notification=silent) 117 | else: 118 | if self._status_message.caption: 119 | self._status_message.edit_caption(caption=message, parse_mode=PARSEMODE_MARKDOWN_V2) 120 | else: 121 | self._status_message.edit_text(text=message, parse_mode=PARSEMODE_MARKDOWN_V2) 122 | else: 123 | self._bot.send_message(self._chat_id, text=message, parse_mode=PARSEMODE_MARKDOWN_V2, disable_notification=silent) 124 | for group in self._notify_groups: 125 | self._bot.send_chat_action(chat_id=group, action=ChatAction.TYPING) 126 | if self._status_single_message and not manual: 127 | if not group in self._groups_status_mesages: 128 | self._groups_status_mesages[group] = self._bot.send_message(group, text=message, parse_mode=PARSEMODE_MARKDOWN_V2, disable_notification=silent) 129 | else: 130 | mess = self._groups_status_mesages[group] 131 | if mess.caption: 132 | mess.edit_caption(caption=message, parse_mode=PARSEMODE_MARKDOWN_V2) 133 | else: 134 | mess.edit_text(text=message, parse_mode=PARSEMODE_MARKDOWN_V2) 135 | else: 136 | self._bot.send_message(group, text=message, parse_mode=PARSEMODE_MARKDOWN_V2, disable_notification=silent) 137 | 138 | def _notify(self, message: str, silent: bool, group_only: bool = False, manual: bool = False): 139 | if self._cam_wrap.enabled: 140 | with self._cam_wrap.take_photo() as photo: 141 | if not group_only: 142 | self._bot.send_chat_action(chat_id=self._chat_id, action=ChatAction.UPLOAD_PHOTO) 143 | if self._status_single_message and not manual: 144 | if not self._status_message: 145 | self._status_message = self._bot.send_photo(self._chat_id, photo=photo, caption=message, parse_mode=PARSEMODE_MARKDOWN_V2, disable_notification=silent) 146 | else: 147 | # Fixme: check if media in message! 148 | self._status_message.edit_media(media=InputMediaPhoto(photo)) 149 | self._status_message.edit_caption(caption=message, parse_mode=PARSEMODE_MARKDOWN_V2) 150 | else: 151 | self._bot.send_photo(self._chat_id, photo=photo, caption=message, parse_mode=PARSEMODE_MARKDOWN_V2, disable_notification=silent) 152 | for group_ in self._notify_groups: 153 | photo.seek(0) 154 | self._bot.send_chat_action(chat_id=group_, action=ChatAction.UPLOAD_PHOTO) 155 | if self._status_single_message and not manual: 156 | if not group_ in self._groups_status_mesages: 157 | self._groups_status_mesages[group_] = self._bot.send_photo(group_, text=message, parse_mode=PARSEMODE_MARKDOWN_V2, disable_notification=silent) 158 | else: 159 | mess = self._groups_status_mesages[group_] 160 | mess.edit_media(media=InputMediaPhoto(photo)) 161 | mess.edit_caption(caption=message, parse_mode=PARSEMODE_MARKDOWN_V2) 162 | else: 163 | self._bot.send_photo(group_, photo=photo, caption=message, parse_mode=PARSEMODE_MARKDOWN_V2, disable_notification=silent) 164 | photo.close() 165 | else: 166 | self._send_message(message, silent, manual) 167 | 168 | # manual notification methods 169 | def send_error(self, message: str): 170 | self._sched.add_job(self._send_message, kwargs={'message': escape_markdown(message, version=2), 'silent': False, 'manual': True}, misfire_grace_time=None, coalesce=False, max_instances=6, replace_existing=False) 171 | 172 | def send_error_with_photo(self, message: str): 173 | self._sched.add_job(self._notify, kwargs={'message': escape_markdown(message, version=2), 'silent': False, 'manual': True}, misfire_grace_time=None, coalesce=False, max_instances=6, replace_existing=False) 174 | 175 | def send_notification(self, message: str): 176 | self._sched.add_job(self._send_message, kwargs={'message': escape_markdown(message, version=2), 'silent': self._silent_status, 'manual': True}, misfire_grace_time=None, coalesce=False, max_instances=6, 177 | replace_existing=False) 178 | 179 | def send_notification_with_photo(self, message: str): 180 | self._sched.add_job(self._notify, kwargs={'message': escape_markdown(message, version=2), 'silent': self._silent_status, 'manual': True}, misfire_grace_time=None, coalesce=False, max_instances=6, 181 | replace_existing=False) 182 | 183 | def reset_notifications(self) -> None: 184 | self._last_percent = 0 185 | self._last_height = 0 186 | self._klippy.printing_duration = 0 187 | self._last_m117_status = '' 188 | self._last_tgnotify_status = '' 189 | self._status_message = None 190 | self._groups_status_mesages = {} 191 | 192 | def _schedule_notification(self, message: str = '', schedule: bool = False): 193 | mess = escape_markdown(self._klippy.get_print_stats(message), version=2) 194 | if self._last_m117_status and 'm117_status' in self._message_parts: 195 | mess += f"{escape_markdown(self._last_m117_status, version=2)}\n" 196 | if self._last_tgnotify_status and 'tgnotify_status' in self._message_parts: 197 | mess += f"{escape_markdown(self._last_tgnotify_status, version=2)}\n" 198 | if 'last_update_time' in self._message_parts: 199 | mess += f"_Last update at {datetime.now():%H:%M:%S}_" 200 | if schedule: 201 | self._sched.add_job(self._notify, kwargs={'message': mess, 'silent': self._silent_progress, 'group_only': self._group_only}, misfire_grace_time=None, coalesce=False, max_instances=6, replace_existing=False) 202 | else: 203 | self._notify(mess, self._silent_progress, self._group_only) 204 | 205 | def schedule_notification(self, progress: int = 0, position_z: int = 0): 206 | if not self._klippy.printing or self._klippy.printing_duration <= 0.0 or (self._height == 0 and self._percent == 0): 207 | return 208 | 209 | notify = False 210 | if progress != 0 and self._percent != 0: 211 | if progress < self._last_percent - self._percent: 212 | self._last_percent = progress 213 | if progress % self._percent == 0 and progress > self._last_percent: 214 | self._last_percent = progress 215 | notify = True 216 | 217 | if position_z != 0 and self._height != 0: 218 | if position_z < self._last_height - self._height: 219 | self._last_height = position_z 220 | if position_z % self._height == 0 and position_z > self._last_height: 221 | self._last_height = position_z 222 | notify = True 223 | 224 | if notify: 225 | self._schedule_notification(schedule=True) 226 | 227 | def _notify_by_time(self): 228 | if not self._klippy.printing or self._klippy.printing_duration <= 0.0: 229 | return 230 | self._schedule_notification() 231 | 232 | def add_notifier_timer(self): 233 | if self._interval > 0: 234 | # Todo: maybe check if job exists? 235 | self._sched.add_job(self._notify_by_time, 'interval', seconds=self._interval, id='notifier_timer', replace_existing=True) 236 | 237 | def remove_notifier_timer(self): 238 | if self._sched.get_job('notifier_timer'): 239 | self._sched.remove_job('notifier_timer') 240 | 241 | def _reschedule_notifier_timer(self): 242 | if self._interval > 0 and self._sched.get_job('notifier_timer'): 243 | self._sched.add_job(self._notify_by_time, 'interval', seconds=self._interval, id='notifier_timer', replace_existing=True) 244 | 245 | def stop_all(self): 246 | self.reset_notifications() 247 | self.remove_notifier_timer() 248 | 249 | def _send_print_start_info(self): 250 | message, bio = self._klippy.get_file_info('Printer started printing') 251 | if bio is not None: 252 | status_message = self._bot.send_photo(self._chat_id, photo=bio, caption=message, disable_notification=self.silent_status) 253 | for group_ in self._notify_groups: 254 | bio.seek(0) 255 | self._groups_status_mesages[group_] = self._bot.send_photo(group_, photo=bio, caption=message, disable_notification=self.silent_status) 256 | bio.close() 257 | else: 258 | status_message = self._bot.send_message(self._chat_id, message, disable_notification=self.silent_status) 259 | for group_ in self._notify_groups: 260 | self._groups_status_mesages[group_] = self._bot.send_message(group_, message, disable_notification=self.silent_status) 261 | if self._status_single_message: 262 | self._status_message = status_message 263 | 264 | def send_print_start_info(self): 265 | self._sched.add_job(self._send_print_start_info, misfire_grace_time=None, coalesce=False, max_instances=1, replace_existing=True) 266 | # Todo: reset something? 267 | 268 | def _send_print_finish(self): 269 | self._schedule_notification(message='Finished printing') 270 | self.reset_notifications() 271 | 272 | def send_print_finish(self): 273 | self._sched.add_job(self._send_print_finish, misfire_grace_time=None, coalesce=False, max_instances=1, replace_existing=True) 274 | 275 | def update_status(self): 276 | self._schedule_notification() 277 | 278 | def parse_notification_params(self, message: str): 279 | mass_parts = message.split(sep=" ") 280 | mass_parts.pop(0) 281 | response = '' 282 | for part in mass_parts: 283 | try: 284 | if 'percent' in part: 285 | self.percent = int(part.split(sep="=").pop()) 286 | response += f"percent={self.percent} " 287 | elif 'height' in part: 288 | self.height = float(part.split(sep="=").pop()) 289 | response += f"height={self.height} " 290 | elif 'time' in part: 291 | self.interval = int(part.split(sep="=").pop()) 292 | response += f"time={self.interval} " 293 | else: 294 | self._klippy.execute_command(f'RESPOND PREFIX="Notification params error" MSG="unknown param `{part}`"') 295 | except Exception as ex: 296 | self._klippy.execute_command(f'RESPOND PREFIX="Notification params error" MSG="Failed parsing `{part}`. {ex}"') 297 | if response: 298 | full_conf = f"percent={self.percent} height={self.height} time={self.interval} " 299 | self._klippy.execute_command(f'RESPOND PREFIX="Notification params" MSG="Changed Notification params: {response}"') 300 | self._klippy.execute_command(f'RESPOND PREFIX="Notification params" MSG="Full Notification config: {full_conf}"') 301 | -------------------------------------------------------------------------------- /bot/camera.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import logging 3 | import math 4 | import os 5 | import pathlib 6 | import threading 7 | import time 8 | import glob 9 | from contextlib import contextmanager 10 | from functools import wraps 11 | from io import BytesIO 12 | from pathlib import Path 13 | from queue import Queue 14 | from typing import List 15 | 16 | import cv2 17 | from PIL import Image, _webp 18 | from telegram import Message 19 | 20 | from configuration import ConfigWrapper 21 | from klippy import Klippy 22 | from power_device import PowerDevice 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | def cam_light_toggle(func): 28 | @wraps(func) 29 | def wrapper(self, *args, **kwargs): 30 | self.use_light() 31 | 32 | if self.light_timeout > 0 and self.light_device and not self.light_device.device_state and not self.light_lock.locked(): 33 | self.light_timer_event.clear() 34 | self.light_lock.acquire() 35 | self.light_need_off = True 36 | self.light_device.switch_device(True) 37 | time.sleep(self.light_timeout) 38 | self.light_timer_event.set() 39 | 40 | self.light_timer_event.wait() 41 | 42 | # Todo: maybe add try block? 43 | result = func(self, *args, **kwargs) 44 | 45 | self.free_light() 46 | 47 | def delayed_light_off(): 48 | if self.light_requests == 0: 49 | if self.light_lock.locked(): 50 | self.light_lock.release() 51 | self.light_need_off = False 52 | self.light_device.switch_device(False) 53 | else: 54 | logger.debug(f"light requests count: {self.light_requests}") 55 | 56 | if self.light_need_off and self.light_requests == 0: 57 | threading.Timer(self.light_timeout, delayed_light_off).start() 58 | 59 | return result 60 | 61 | return wrapper 62 | 63 | 64 | class Camera: 65 | def __init__(self, config: ConfigWrapper, klippy: Klippy, light_device: PowerDevice, logging_handler: logging.Handler = None): 66 | self.enabled: bool = True if config.camera.enabled and config.camera.host else False 67 | self._host = int(config.camera.host) if str.isdigit(config.camera.host) else config.camera.host 68 | self._threads: int = config.camera.threads 69 | self._flip_vertically: bool = config.camera.flip_vertically 70 | self._flip_horizontally: bool = config.camera.flip_horizontally 71 | self._fourcc: str = config.camera.fourcc 72 | self._video_duration: int = config.camera.video_duration 73 | self._video_buffer_size: int = config.camera.video_buffer_size 74 | self._stream_fps: int = config.camera.stream_fps 75 | self._klippy: Klippy = klippy 76 | 77 | # Todo: refactor into timelapse class 78 | self._base_dir: str = config.timelapse.base_dir 79 | self._ready_dir: str = config.timelapse.ready_dir 80 | self._cleanup: bool = config.timelapse.cleanup 81 | 82 | self._target_fps: int = 15 83 | self._min_lapse_duration: int = 0 84 | self._max_lapse_duration: int = 0 85 | self._last_frame_duration: int = 5 86 | 87 | self._light_need_off: bool = False 88 | self._light_need_off_lock = threading.Lock() 89 | 90 | self.light_timeout: int = config.camera.light_timeout 91 | self.light_device: PowerDevice = light_device 92 | self._camera_lock = threading.Lock() 93 | self.light_lock = threading.Lock() 94 | self.light_timer_event = threading.Event() 95 | self.light_timer_event.set() 96 | 97 | self._hw_accel: bool = False 98 | 99 | if config.camera.picture_quality == 'low': 100 | self._img_extension: str = 'jpeg' 101 | elif config.camera.picture_quality == 'high': 102 | self._img_extension: str = 'webp' 103 | else: 104 | self._img_extension: str = config.camera.picture_quality 105 | 106 | self._light_requests: int = 0 107 | self._light_request_lock = threading.Lock() 108 | 109 | if self._flip_vertically and self._flip_horizontally: 110 | self._flip = -1 111 | elif self._flip_horizontally: 112 | self._flip = 1 113 | elif self._flip_vertically: 114 | self._flip = 0 115 | 116 | if config.camera.rotate == '90_cw': 117 | self._rotate_code: int = cv2.ROTATE_90_CLOCKWISE 118 | elif config.camera.rotate == '90_ccw': 119 | self._rotate_code: int = cv2.ROTATE_90_COUNTERCLOCKWISE 120 | elif config.camera.rotate == '180': 121 | self._rotate_code: int = cv2.ROTATE_180 122 | else: 123 | self._rotate_code: int = -10 124 | 125 | if logging_handler: 126 | logger.addHandler(logging_handler) 127 | if config.bot.debug: 128 | logger.setLevel(logging.DEBUG) 129 | logger.debug(cv2.getBuildInformation()) 130 | os.environ["OPENCV_VIDEOIO_DEBUG"] = "1" 131 | # Fixme: deprecated! use T-API https://learnopencv.com/opencv-transparent-api/ 132 | if cv2.ocl.haveOpenCL(): 133 | logger.debug('OpenCL is available') 134 | cv2.ocl.setUseOpenCL(True) 135 | logger.debug(f'OpenCL in OpenCV is enabled: {cv2.ocl.useOpenCL()}') 136 | 137 | cv2.setNumThreads(self._threads) 138 | self.cam_cam = cv2.VideoCapture() 139 | self.cam_cam.set(cv2.CAP_PROP_BUFFERSIZE, 1) 140 | 141 | @property 142 | def light_need_off(self) -> bool: 143 | with self._light_need_off_lock: 144 | return self._light_need_off 145 | 146 | @light_need_off.setter 147 | def light_need_off(self, new_value: bool): 148 | with self._light_need_off_lock: 149 | self._light_need_off = new_value 150 | 151 | @property 152 | def lapse_dir(self) -> str: 153 | return f'{self._base_dir}/{self._klippy.printing_filename_with_time}' 154 | 155 | @property 156 | def light_requests(self) -> int: 157 | with self._light_request_lock: 158 | return self._light_requests 159 | 160 | def use_light(self): 161 | with self._light_request_lock: 162 | self._light_requests += 1 163 | 164 | def free_light(self): 165 | with self._light_request_lock: 166 | self._light_requests -= 1 167 | 168 | @property 169 | def target_fps(self) -> int: 170 | return self._target_fps 171 | 172 | @target_fps.setter 173 | def target_fps(self, new_value: int): 174 | self._target_fps = new_value 175 | 176 | @property 177 | def min_lapse_duration(self) -> int: 178 | return self._min_lapse_duration 179 | 180 | @min_lapse_duration.setter 181 | def min_lapse_duration(self, new_value: int): 182 | if new_value >= 0: 183 | self._min_lapse_duration = new_value 184 | 185 | @property 186 | def max_lapse_duration(self) -> int: 187 | return self._max_lapse_duration 188 | 189 | @max_lapse_duration.setter 190 | def max_lapse_duration(self, new_value: int): 191 | if new_value >= 0: 192 | self._max_lapse_duration = new_value 193 | 194 | @property 195 | def last_frame_duration(self) -> int: 196 | return self._last_frame_duration 197 | 198 | @last_frame_duration.setter 199 | def last_frame_duration(self, new_value: int): 200 | if new_value >= 0: 201 | self._last_frame_duration = new_value 202 | 203 | @staticmethod 204 | def _create_thumb(image) -> BytesIO: 205 | # cv2.cvtColor cause segfaults! 206 | img = Image.fromarray(image[:, :, [2, 1, 0]]) 207 | bio = BytesIO() 208 | bio.name = 'thumbnail.jpeg' 209 | img.thumbnail((320, 320)) 210 | img.save(bio, 'JPEG', quality=100, optimize=True) 211 | bio.seek(0) 212 | img.close() 213 | del img 214 | return bio 215 | 216 | @cam_light_toggle 217 | def take_photo(self) -> BytesIO: 218 | with self._camera_lock: 219 | self.cam_cam.open(self._host) 220 | self.cam_cam.set(cv2.CAP_PROP_BUFFERSIZE, 1) 221 | success, image = self.cam_cam.read() 222 | self.cam_cam.release() 223 | 224 | if not success: 225 | logger.debug("failed to get camera frame for photo") 226 | # Todo: resize to cam resolution! 227 | img = Image.open('../imgs/nosignal.png') 228 | else: 229 | if self._hw_accel: 230 | image_um = cv2.UMat(image) 231 | if self._flip_vertically or self._flip_horizontally: 232 | image_um = cv2.flip(image_um, self._flip) 233 | img = Image.fromarray(cv2.UMat.get(cv2.cvtColor(image_um, cv2.COLOR_BGR2RGB))) 234 | image_um = None 235 | del image_um 236 | else: 237 | if self._flip_vertically or self._flip_horizontally: 238 | image = cv2.flip(image, self._flip) 239 | # Todo: check memory leaks 240 | if self._rotate_code > -10: 241 | image = cv2.rotate(image, rotateCode=self._rotate_code) 242 | # # cv2.cvtColor cause segfaults! 243 | # rgb = image[:, :, ::-1] 244 | rgb = image[:, :, [2, 1, 0]] 245 | img = Image.fromarray(rgb) 246 | rgb = None 247 | del rgb 248 | 249 | image = None 250 | del image, success 251 | 252 | bio = BytesIO() 253 | bio.name = f'status.{self._img_extension}' 254 | if self._img_extension in ['jpg', 'jpeg']: 255 | img.save(bio, 'JPEG', quality=80, subsampling=0) 256 | elif self._img_extension == 'webp': 257 | # https://github.com/python-pillow/Pillow/issues/4364 258 | _webp.HAVE_WEBPANIM = False 259 | img.save(bio, 'WebP', quality=0, lossless=True) 260 | elif self._img_extension == 'png': 261 | img.save(bio, 'PNG') 262 | bio.seek(0) 263 | 264 | img.close() 265 | del img 266 | return bio 267 | 268 | @contextmanager 269 | def take_video_generator(self): 270 | (video_bio, thumb_bio, width, height) = self.take_video() 271 | try: 272 | yield video_bio, thumb_bio, width, height 273 | finally: 274 | video_bio.close() 275 | thumb_bio.close() 276 | 277 | @cam_light_toggle 278 | def take_video(self) -> (BytesIO, BytesIO, int, int): 279 | def process_video_frame(frame_local): 280 | if self._flip_vertically or self._flip_horizontally: 281 | if self._hw_accel: 282 | frame_loc_ = cv2.UMat(frame_local) 283 | frame_loc_ = cv2.flip(frame_loc_, self._flip) 284 | frame_local = cv2.UMat.get(frame_loc_) 285 | del frame_loc_ 286 | else: 287 | frame_local = cv2.flip(frame_local, self._flip) 288 | # Todo: check memory leaks 289 | if self._rotate_code > -10: 290 | frame_local = cv2.rotate(frame_local, rotateCode=self._rotate_code) 291 | return frame_local 292 | 293 | def write_video(): 294 | cv2.setNumThreads(self._threads) 295 | out = cv2.VideoWriter(filepath, fourcc=cv2.VideoWriter_fourcc(*self._fourcc), fps=fps_cam, frameSize=(width, height)) 296 | while video_lock.locked(): 297 | try: 298 | frame_local = frame_queue.get(block=False) 299 | except Exception as ex: 300 | logger.warning(f'Reading video frames queue exception {ex.with_traceback}') 301 | frame_local = frame_queue.get() 302 | 303 | out.write(process_video_frame(frame_local)) 304 | # frame_local = None 305 | # del frame_local 306 | 307 | while not frame_queue.empty(): 308 | frame_local = frame_queue.get() 309 | out.write(process_video_frame(frame_local)) 310 | # frame_local = None 311 | # del frame_local 312 | 313 | out.release() 314 | video_written_event.set() 315 | 316 | with self._camera_lock: 317 | cv2.setNumThreads(self._threads) # TOdo: check self set and remove! 318 | self.cam_cam.open(self._host) 319 | self.cam_cam.set(cv2.CAP_PROP_BUFFERSIZE, 1) 320 | success, frame = self.cam_cam.read() 321 | 322 | if not success: 323 | logger.debug("failed to get camera frame for video") 324 | # Todo: get picture from imgs? 325 | 326 | frame = process_video_frame(frame) 327 | height, width, channels = frame.shape 328 | thumb_bio = self._create_thumb(frame) 329 | del frame, channels 330 | fps_cam = self.cam_cam.get(cv2.CAP_PROP_FPS) if self._stream_fps == 0 else self._stream_fps 331 | 332 | filepath = os.path.join('/tmp/', 'video.mp4') 333 | frame_queue = Queue(fps_cam * self._video_buffer_size) 334 | video_lock = threading.Lock() 335 | video_written_event = threading.Event() 336 | video_written_event.clear() 337 | video_lock.acquire() 338 | threading.Thread(target=write_video, args=()).start() 339 | t_end = time.time() + self._video_duration 340 | while success and time.time() <= t_end: 341 | success, frame_loc = self.cam_cam.read() 342 | try: 343 | frame_queue.put(frame_loc, block=False) 344 | except Exception as ex: 345 | logger.warning(f'Writing video frames queue exception {ex.with_traceback}') 346 | frame_queue.put(frame_loc) 347 | # frame_loc = None 348 | # del frame_loc 349 | 350 | video_lock.release() 351 | video_written_event.wait() 352 | 353 | self.cam_cam.release() 354 | video_bio = BytesIO() 355 | video_bio.name = 'video.mp4' 356 | with open(filepath, 'rb') as fh: 357 | video_bio.write(fh.read()) 358 | os.remove(filepath) 359 | video_bio.seek(0) 360 | return video_bio, thumb_bio, width, height 361 | 362 | def take_lapse_photo(self) -> None: 363 | # Todo: check for space available? 364 | Path(self.lapse_dir).mkdir(parents=True, exist_ok=True) 365 | # never add self in params there! 366 | with self.take_photo() as photo: 367 | filename = f'{self.lapse_dir}/{time.time()}.{self._img_extension}' 368 | with open(filename, "wb") as outfile: 369 | outfile.write(photo.getvalue()) 370 | photo.close() 371 | 372 | def create_timelapse(self, printing_filename: str, gcode_name: str, info_mess: Message) -> (BytesIO, BytesIO, int, int, str, str): 373 | return self._create_timelapse(printing_filename, gcode_name, info_mess) 374 | 375 | def create_timelapse_for_file(self, filename: str, info_mess: Message) -> (BytesIO, BytesIO, int, int, str, str): 376 | return self._create_timelapse(filename, filename, info_mess) 377 | 378 | def _calculate_fps(self, frames_count: int) -> int: 379 | actual_duration = frames_count / self._target_fps 380 | 381 | # Todo: check _max_lapse_duration > _min_lapse_duration 382 | if (self._min_lapse_duration == 0 and self._max_lapse_duration == 0) or (self._min_lapse_duration <= actual_duration <= self._max_lapse_duration and self._max_lapse_duration > 0) or ( 383 | actual_duration > self._min_lapse_duration and self._max_lapse_duration == 0): 384 | return self._target_fps 385 | elif actual_duration < self._min_lapse_duration and self._min_lapse_duration > 0: 386 | fps = math.ceil(frames_count / self._min_lapse_duration) 387 | return fps if fps >= 1 else 1 388 | elif actual_duration > self._max_lapse_duration > 0: 389 | return math.ceil(frames_count / self._max_lapse_duration) 390 | else: 391 | logger.error(f"Unknown fps calculation state for durations min:{self._min_lapse_duration} and max:{self._max_lapse_duration} and actual:{actual_duration}") 392 | return self._target_fps 393 | 394 | def _create_timelapse(self, printing_filename: str, gcode_name: str, info_mess: Message) -> (BytesIO, BytesIO, int, int, str, str): 395 | if not printing_filename: 396 | raise ValueError(f'Gcode file name is empty') 397 | 398 | while self.light_need_off: 399 | time.sleep(1) 400 | 401 | lapse_dir = f'{self._base_dir}/{printing_filename}' 402 | 403 | if not Path(f'{lapse_dir}/lapse.lock').is_file(): 404 | open(f'{lapse_dir}/lapse.lock', mode='a').close() 405 | 406 | # Todo: check for nonempty photos! 407 | photos = glob.glob(f'{glob.escape(lapse_dir)}/*.{self._img_extension}') 408 | photos.sort(key=os.path.getmtime) 409 | photo_count = len(photos) 410 | 411 | if photo_count == 0: 412 | raise ValueError(f"Empty photos list for {printing_filename} in lapse path {lapse_dir}") 413 | 414 | info_mess.edit_text(text=f"Creating thumbnail") 415 | last_photo = photos[-1] 416 | img = cv2.imread(last_photo) 417 | height, width, layers = img.shape 418 | thumb_bio = self._create_thumb(img) 419 | 420 | video_filepath = f'{lapse_dir}/lapse.mp4' 421 | if Path(video_filepath).is_file(): 422 | os.remove(video_filepath) 423 | 424 | lapse_fps = self._calculate_fps(photo_count) 425 | 426 | with self._camera_lock: 427 | cv2.setNumThreads(self._threads) # TOdo: check self set and remove! 428 | out = cv2.VideoWriter(video_filepath, fourcc=cv2.VideoWriter_fourcc(*self._fourcc), fps=lapse_fps, frameSize=(width, height)) 429 | 430 | info_mess.edit_text(text=f"Images recoding") 431 | last_update_time = time.time() 432 | for fnum, filename in enumerate(photos): 433 | if time.time() >= last_update_time + 3: 434 | info_mess.edit_text(text=f"Images recoded {fnum}/{photo_count}") 435 | last_update_time = time.time() 436 | 437 | out.write(cv2.imread(filename)) 438 | 439 | info_mess.edit_text(text=f"Repeating last image for {self._last_frame_duration} seconds") 440 | for _ in range(lapse_fps * self._last_frame_duration): 441 | out.write(img) 442 | 443 | out.release() 444 | cv2.destroyAllWindows() 445 | del out 446 | 447 | del photos, img, layers 448 | 449 | # Todo: some error handling? 450 | 451 | video_bio = BytesIO() 452 | video_bio.name = f'{printing_filename}.mp4' 453 | target_video_file = f'{self._ready_dir}/{printing_filename}.mp4' 454 | with open(video_filepath, 'rb') as fh: 455 | video_bio.write(fh.read()) 456 | if self._ready_dir and os.path.isdir(self._ready_dir): 457 | info_mess.edit_text(text=f"Copy lapse to target ditectory") 458 | Path(target_video_file).parent.mkdir(parents=True, exist_ok=True) 459 | with open(f"{target_video_file}", 'wb') as cpf: 460 | cpf.write(video_bio.getvalue()) 461 | video_bio.seek(0) 462 | 463 | os.remove(f'{lapse_dir}/lapse.lock') 464 | 465 | if self._cleanup: 466 | info_mess.edit_text(text=f"Performing cleanups") 467 | for filename in glob.glob(f'{glob.escape(lapse_dir)}/*.{self._img_extension}'): 468 | os.remove(filename) 469 | if video_bio.getbuffer().nbytes < 52428800: 470 | for filename in glob.glob(f'{glob.escape(lapse_dir)}/*'): 471 | os.remove(filename) 472 | Path(lapse_dir).rmdir() 473 | 474 | return video_bio, thumb_bio, width, height, video_filepath, gcode_name 475 | 476 | def clean(self) -> None: 477 | if self._cleanup and self._klippy.printing_filename and os.path.isdir(self.lapse_dir): 478 | for filename in glob.glob(f'{glob.escape(self.lapse_dir)}/*'): 479 | os.remove(filename) 480 | 481 | # Todo: refactor into timelapse class 482 | # Todo: check for 64 symbols length in lapse names 483 | def detect_unfinished_lapses(self) -> List[str]: 484 | # Todo: detect unstarted timelapse builds? folder with pics and no mp4 files 485 | return list(map(lambda el: pathlib.PurePath(el).parent.name, glob.glob(f'{self._base_dir}/*/*.lock'))) 486 | -------------------------------------------------------------------------------- /bot/klippy.py: -------------------------------------------------------------------------------- 1 | # Todo: class for printer states! 2 | import logging 3 | import re 4 | import time 5 | from typing import List 6 | 7 | import emoji 8 | import requests 9 | import urllib 10 | from datetime import datetime, timedelta 11 | from PIL import Image 12 | from io import BytesIO 13 | 14 | from configuration import ConfigWrapper 15 | from power_device import PowerDevice 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class Klippy: 21 | _DATA_MACRO = 'bot_data' 22 | 23 | def __init__(self, config: ConfigWrapper, light_device: PowerDevice, psu_device: PowerDevice, logging_handler: logging.Handler = None): 24 | self._host: str = config.bot.host 25 | self._disabled_macros: List[str] = config.telegram_ui.disabled_macros + [self._DATA_MACRO] 26 | self.show_hidden_macros: List[str] = config.telegram_ui.show_hidden_macros 27 | self._message_parts: List[str] = config.telegram_ui.status_message_content 28 | self._eta_source: str = config.telegram_ui.eta_source 29 | self._light_device = light_device 30 | self._psu_device = psu_device 31 | self._sensors_list: List[str] = config.telegram_ui.status_message_sensors 32 | self._heates_list: List[str] = config.telegram_ui.status_message_heaters 33 | self._temp_fans_list: List[str] = config.telegram_ui.status_message_temp_fans 34 | self._devices_list: List[str] = config.telegram_ui.status_message_devices 35 | self._user: str = config.bot.user 36 | self._passwd: str = config.bot.passwd 37 | 38 | self._dbname = 'telegram-bot' 39 | 40 | self._connected: bool = False 41 | self.printing: bool = False 42 | self.paused: bool = False 43 | self.state: str = '' 44 | self.state_message: str = '' 45 | 46 | self.printing_duration: float = 0.0 47 | self.printing_progress: float = 0.0 48 | self.printing_height: float = 0.0 49 | self._printing_filename: str = '' 50 | self.file_estimated_time: float = 0.0 51 | self.file_print_start_time: float = 0.0 52 | self.vsd_progress: float = 0.0 53 | 54 | self.filament_used: float = 0.0 55 | self.filament_total: float = 0.0 56 | self.filament_weight: float = 0.0 57 | self._thumbnail_path = '' 58 | 59 | self._jwt_token: str = '' 60 | 61 | # Todo: create sensors class!! 62 | self.sensors_dict: dict = dict() 63 | 64 | if logging_handler: 65 | logger.addHandler(logging_handler) 66 | if config.bot.debug: 67 | logger.setLevel(logging.DEBUG) 68 | 69 | self._auth_moonraker() 70 | 71 | def prepare_sens_dict_subscribe(self): 72 | self.sensors_dict = {} 73 | sens_dict = {} 74 | for heat in self._heates_list: 75 | if heat in ['extruder', 'heater_bed']: 76 | sens_dict[heat] = None 77 | else: 78 | sens_dict[f"heater_generic {heat}"] = None 79 | 80 | for sens in self._sensors_list: 81 | sens_dict[f"temperature_sensor {sens}"] = None 82 | 83 | for sens in self._temp_fans_list: 84 | sens_dict[f"temperature_fan {sens}"] = None 85 | return sens_dict 86 | 87 | def _filament_weight_used(self) -> float: 88 | return self.filament_weight * (self.filament_used / self.filament_total) 89 | 90 | @property 91 | def connected(self) -> bool: 92 | return self._connected 93 | 94 | @connected.setter 95 | def connected(self, new_value: bool): 96 | self._connected = new_value 97 | self.printing = False 98 | self.paused = False 99 | self._reset_file_info() 100 | 101 | # Todo: save macros list until klippy restart 102 | @property 103 | def macros(self) -> List[str]: 104 | return self._get_marco_list() 105 | 106 | @property 107 | def macros_all(self) -> List[str]: 108 | return self._get_full_marco_list() 109 | 110 | @property 111 | def moonraker_host(self) -> str: 112 | return self._host 113 | 114 | @property 115 | def _headers(self): 116 | heads = {} 117 | if self._jwt_token: 118 | heads = {'Authorization': f"Bearer {self._jwt_token}"} 119 | 120 | return heads 121 | 122 | @property 123 | def one_shot_token(self) -> str: 124 | if not self._user and not self._jwt_token: 125 | return '' 126 | 127 | resp = requests.get(f'http://{self._host}/access/oneshot_token', headers=self._headers) 128 | if resp.ok: 129 | res = f"?token={resp.json()['result']}" 130 | else: 131 | logger.error(resp.reason) 132 | res = '' 133 | return res 134 | 135 | def _reset_file_info(self) -> None: 136 | self.printing_duration: float = 0.0 137 | self.printing_progress: float = 0.0 138 | self.printing_height: float = 0.0 139 | self._printing_filename: str = '' 140 | self.file_estimated_time: float = 0.0 141 | self.file_print_start_time: float = 0.0 142 | self.vsd_progress: float = 0.0 143 | 144 | self.filament_used: float = 0.0 145 | self.filament_total: float = 0.0 146 | self.filament_weight: float = 0.0 147 | self._thumbnail_path = '' 148 | 149 | @property 150 | def printing_filename(self) -> str: 151 | return self._printing_filename 152 | 153 | @property 154 | def printing_filename_with_time(self) -> str: 155 | return f"{self._printing_filename}_{datetime.fromtimestamp(self.file_print_start_time):%Y-%m-%d_%H-%M}" 156 | 157 | @printing_filename.setter 158 | def printing_filename(self, new_value: str): 159 | if not new_value: 160 | self._reset_file_info() 161 | return 162 | 163 | response = requests.get(f"http://{self._host}/server/files/metadata?filename={urllib.parse.quote(new_value)}", headers=self._headers) 164 | # Todo: add response status check! 165 | resp = response.json()['result'] 166 | self._printing_filename = new_value 167 | self.file_estimated_time = resp['estimated_time'] 168 | self.file_print_start_time = resp['print_start_time'] if resp['print_start_time'] else time.time() 169 | self.filament_total = resp['filament_total'] if 'filament_total' in resp else 0.0 170 | self.filament_weight = resp['filament_weight_total'] if 'filament_weight_total' in resp else 0.0 171 | 172 | if 'thumbnails' in resp and 'filename' in resp: 173 | thumb = max(resp['thumbnails'], key=lambda el: el['size']) 174 | file_dir = resp['filename'].rpartition('/')[0] 175 | if file_dir: 176 | self._thumbnail_path = file_dir + '/' 177 | self._thumbnail_path += thumb['relative_path'] 178 | 179 | def _get_full_marco_list(self) -> List[str]: 180 | resp = requests.get(f'http://{self._host}/printer/objects/list', headers=self._headers) 181 | if not resp.ok: 182 | return [] 183 | macro_lines = list(filter(lambda it: 'gcode_macro' in it, resp.json()['result']['objects'])) 184 | loaded_macros = list(map(lambda el: el.split(' ')[1], macro_lines)) 185 | return loaded_macros 186 | 187 | def _get_marco_list(self) -> List[str]: 188 | return [key for key in self._get_full_marco_list() if key not in self._disabled_macros and (True if self.show_hidden_macros else not key.startswith("_"))] 189 | 190 | def _auth_moonraker(self) -> None: 191 | if not self._user or not self._passwd: 192 | return 193 | # TOdo: add try catch 194 | res = requests.post(f"http://{self._host}/access/login", json={'username': self._user, 'password': self._passwd}) 195 | if res.ok: 196 | # Todo: check if token refresh needed 197 | self._jwt_token = res.json()['result']['token'] 198 | else: 199 | logger.error(res.reason) 200 | 201 | def check_connection(self) -> str: 202 | try: 203 | response = requests.get(f"http://{self._host}/printer/info", headers=self._headers, timeout=2) 204 | return '' if response.ok else f"Connection failed. {response.reason}" 205 | except Exception as ex: 206 | logger.error(ex, exc_info=True) 207 | return f"Connection failed." 208 | 209 | def update_sensror(self, name: str, value) -> None: 210 | if name in self.sensors_dict: 211 | if 'temperature' in value: 212 | self.sensors_dict.get(name)['temperature'] = value['temperature'] 213 | if 'target' in value: 214 | self.sensors_dict.get(name)['target'] = value['target'] 215 | if 'power' in value: 216 | self.sensors_dict.get(name)['power'] = value['power'] 217 | if 'speed' in value: 218 | self.sensors_dict.get(name)['speed'] = value['speed'] 219 | else: 220 | self.sensors_dict[name] = value 221 | 222 | @staticmethod 223 | def sensor_message(name: str, value) -> str: 224 | sens_name = re.sub(r"([A-Z]|\d|_)", r" \1", name).replace('_', '') 225 | if 'power' in value: 226 | message = emoji.emojize(' :hotsprings: ', use_aliases=True) + f"{sens_name.title()}: {round(value['temperature'])}" 227 | if 'target' in value and value['target'] > 0.0 and abs(value['target'] - value['temperature']) > 2: 228 | message += emoji.emojize(' :arrow_right: ', use_aliases=True) + f"{round(value['target'])}" 229 | if value['power'] > 0.0: 230 | message += emoji.emojize(' :fire: ', use_aliases=True) 231 | elif 'speed' in value: 232 | message = emoji.emojize(' :tornado: ', use_aliases=True) + f"{sens_name.title()}: {round(value['temperature'])}" 233 | if 'target' in value and value['target'] > 0.0 and abs(value['target'] - value['temperature']) > 2: 234 | message += emoji.emojize(' :arrow_right: ', use_aliases=True) + f"{round(value['target'])}" 235 | else: 236 | message = emoji.emojize(' :thermometer: ', use_aliases=True) + f"{sens_name.title()}: {round(value['temperature'])}" 237 | message += '\n' 238 | return message 239 | 240 | def _get_sensors_message(self) -> str: 241 | message = '' 242 | for name, value in self.sensors_dict.items(): 243 | message += self.sensor_message(name, value) 244 | return message 245 | 246 | def _get_power_devices_mess(self) -> str: 247 | message = '' 248 | if self._light_device and self._light_device.name in self._devices_list: 249 | message += emoji.emojize(' :flashlight: Light: ', use_aliases=True) + f"{'on' if self._light_device.device_state else 'off'}\n" 250 | if self._psu_device and self._psu_device.name in self._devices_list: 251 | message += emoji.emojize(' :electric_plug: PSU: ', use_aliases=True) + f"{'on' if self._psu_device.device_state else 'off'}\n" 252 | return message 253 | 254 | def execute_command(self, *command) -> None: 255 | data = {'commands': list(map(lambda el: f'{el}', command))} 256 | res = requests.post(f"http://{self._host}/api/printer/command", json=data, headers=self._headers) 257 | if not res.ok: 258 | logger.error(res.reason) 259 | 260 | def _get_eta(self) -> timedelta: 261 | if self._eta_source == 'slicer': 262 | eta = int(self.file_estimated_time - self.printing_duration) 263 | else: # eta by file 264 | eta = int(self.printing_duration / self.vsd_progress - self.printing_duration) 265 | if eta < 0: 266 | eta = 0 267 | return timedelta(seconds=eta) 268 | 269 | def _populate_with_thumb(self, thumb_path: str, message: str): 270 | if not thumb_path: 271 | # Todo: resize? 272 | img = Image.open('../imgs/nopreview.png').convert('RGB') 273 | else: 274 | response = requests.get(f"http://{self._host}/server/files/gcodes/{urllib.parse.quote(thumb_path)}", stream=True, headers=self._headers) 275 | if response.ok: 276 | response.raw.decode_content = True 277 | img = Image.open(response.raw).convert('RGB') 278 | else: 279 | logger.error(f"Thumbnail download failed for {thumb_path} \n\n{response.reason}") 280 | # Todo: resize? 281 | img = Image.open('../imgs/nopreview.png').convert('RGB') 282 | 283 | bio = BytesIO() 284 | bio.name = f'{self.printing_filename}.webp' 285 | img.save(bio, 'WebP', quality=0, lossless=True) 286 | bio.seek(0) 287 | img.close() 288 | return message, bio 289 | 290 | def get_file_info(self, message: str = '') -> (str, BytesIO): 291 | message = self.get_print_stats(message) 292 | return self._populate_with_thumb(self._thumbnail_path, message) 293 | 294 | def _get_printing_file_info(self, message_pre: str = '') -> str: 295 | message = f'Printing: {self.printing_filename} \n' if not message_pre else f'{message_pre}: {self.printing_filename} \n' 296 | if 'progress' in self._message_parts: 297 | message += f'Progress {round(self.printing_progress * 100, 0)}%' 298 | if 'height' in self._message_parts: 299 | message += f', height: {round(self.printing_height, 2)}mm\n' if self.printing_height > 0.0 else "\n" 300 | if self.filament_total > 0.0: 301 | if 'filament_length' in self._message_parts: 302 | message += f'Filament: {round(self.filament_used / 1000, 2)}m / {round(self.filament_total / 1000, 2)}m' 303 | if self.filament_weight > 0.0 and 'filament_weight' in self._message_parts: 304 | message += f', weight: {round(self._filament_weight_used(), 2)}/{self.filament_weight}g' 305 | message += '\n' 306 | if 'print_duration' in self._message_parts: 307 | message += f'Printing for {timedelta(seconds=round(self.printing_duration))}\n' 308 | 309 | eta = self._get_eta() 310 | if 'eta' in self._message_parts: 311 | message += f"Estimated time left: {eta}\n" 312 | if 'finish_time' in self._message_parts: 313 | message += f"Finish at {datetime.now() + eta:%Y-%m-%d %H:%M}\n" 314 | 315 | return message 316 | 317 | def get_print_stats(self, message_pre: str = '') -> str: 318 | message = self._get_printing_file_info(message_pre) + self._get_sensors_message() 319 | if 'power_devices' in self._message_parts: 320 | message += self._get_power_devices_mess() 321 | return message 322 | 323 | def get_status(self) -> str: 324 | response = requests.get(f"http://{self._host}/printer/objects/query?webhooks&print_stats&display_status", headers=self._headers) 325 | resp = response.json()['result']['status'] 326 | print_stats = resp['print_stats'] 327 | # webhook = resp['webhooks'] 328 | # message = emoji.emojize(':robot: Klipper status: ', use_aliases=True) + f"{webhook['state']}\n" 329 | message = "" 330 | 331 | # if 'display_status' in resp and 'message' in resp['display_status']: 332 | # msg = resp['display_status']['message'] 333 | # if msg and msg is not None: 334 | # message += f"{msg}\n" 335 | # if 'state_message' in webhook: 336 | # message += f"State message: {webhook['state_message']}\n" 337 | 338 | # message += emoji.emojize(':mechanical_arm: Printing process status: ', use_aliases=True) + f"{print_stats['state']} \n" 339 | 340 | if print_stats['state'] == 'printing': 341 | if not self.printing_filename: 342 | self.printing_filename = print_stats['filename'] 343 | elif print_stats['state'] == 'paused': 344 | message += f"Printing paused\n" 345 | elif print_stats['state'] == 'complete': 346 | message += f"Printing complete\n" 347 | elif print_stats['state'] == 'standby': 348 | message += f"Printer standby\n" 349 | elif print_stats['state'] == 'error': 350 | message += f"Printing error\n" 351 | if 'message' in print_stats and print_stats['message']: 352 | message += f"{print_stats['message']}\n" 353 | 354 | message += '\n' 355 | if self.printing_filename: 356 | message += self._get_printing_file_info() 357 | 358 | message += self._get_sensors_message() 359 | message += self._get_power_devices_mess() 360 | 361 | return message 362 | 363 | def get_file_info_by_name(self, filename: str, message: str): 364 | response = requests.get(f"http://{self._host}/server/files/metadata?filename={urllib.parse.quote(filename)}", headers=self._headers) 365 | # Todo: add response status check! 366 | resp = response.json()['result'] 367 | message += '\n' 368 | if 'filament_total' in resp and resp['filament_total'] > 0.0: 369 | message += f"Filament: {round(resp['filament_total'] / 1000, 2)}m" 370 | if 'filament_weight_total' in resp and resp['filament_weight_total'] > 0.0: 371 | message += f", weight: {resp['filament_weight_total']}g" 372 | if 'estimated_time' in resp and resp['estimated_time'] > 0.0: 373 | message += f"\nEstimated printing time: {timedelta(seconds=resp['estimated_time'])}" 374 | 375 | thumb_path = '' 376 | if 'thumbnails' in resp: 377 | thumb = max(resp['thumbnails'], key=lambda el: el['size']) 378 | if 'relative_path' in resp and 'filename' in resp: 379 | file_dir = resp['filename'].rpartition('/')[0] 380 | if file_dir: 381 | thumb_path = file_dir + '/' 382 | thumb_path += thumb['relative_path'] 383 | else: 384 | logger.error(f"Thumbnail relative_path and filename not found in {resp}") 385 | 386 | return self._populate_with_thumb(thumb_path, message) 387 | 388 | # TOdo: add scrolling 389 | def get_gcode_files(self): 390 | response = requests.get(f"http://{self._host}/server/files/list?root=gcodes", headers=self._headers) 391 | resp = response.json() 392 | files = sorted(resp['result'], key=lambda item: item['modified'], reverse=True)[:10] 393 | return files 394 | 395 | def upload_file(self, file: BytesIO) -> bool: 396 | response = requests.post(f"http://{self._host}/server/files/upload", files={'file': file}, headers=self._headers) 397 | return response.ok 398 | 399 | def start_printing_file(self, filename: str) -> bool: 400 | response = requests.post(f"http://{self._host}/printer/print/start?filename={urllib.parse.quote(filename)}", headers=self._headers) 401 | return response.ok 402 | 403 | def stop_all(self): 404 | self._reset_file_info() 405 | 406 | # moonraker databse section 407 | def get_param_from_db(self, param_name: str): 408 | res = requests.get(f"http://{self._host}/server/database/item?namespace={self._dbname}&key={param_name}", headers=self._headers) 409 | if res.ok: 410 | return res.json()['result']['value'] 411 | else: 412 | logger.error(f"Failed getting {param_name} from {self._dbname} \n\n{res.reason}") 413 | # Fixme: return default value? check for 404! 414 | return None 415 | 416 | def save_param_to_db(self, param_name: str, value) -> None: 417 | data = { 418 | "namespace": self._dbname, 419 | "key": param_name, 420 | "value": value 421 | } 422 | res = requests.post(f"http://{self._host}/server/database/item", json=data, headers=self._headers) 423 | if not res.ok: 424 | logger.error(f"Failed saving {param_name} to {self._dbname} \n\n{res.reason}") 425 | 426 | def delete_param_from_db(self, param_name: str) -> None: 427 | res = requests.delete(f"http://{self._host}/server/database/item?namespace={self._dbname}&key={param_name}", headers=self._headers) 428 | if not res.ok: 429 | logger.error(f"Failed getting {param_name} from {self._dbname} \n\n{res.reason}") 430 | 431 | # macro data section 432 | def save_data_to_marco(self, lapse_size: int, filename: str, path: str) -> None: 433 | full_macro_list = self._get_full_marco_list() 434 | if self._DATA_MACRO in full_macro_list: 435 | self.execute_command(f'SET_GCODE_VARIABLE MACRO=bot_data VARIABLE=lapse_video_size VALUE={lapse_size}', 436 | f'SET_GCODE_VARIABLE MACRO=bot_data VARIABLE=lapse_filename VALUE=\'"{filename}"\'', 437 | f'SET_GCODE_VARIABLE MACRO=bot_data VARIABLE=lapse_path VALUE=\'"{path}"\'') 438 | 439 | else: 440 | logger.error(f'Marco "{self._DATA_MACRO}" not defined') 441 | -------------------------------------------------------------------------------- /bot/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import configparser 3 | import faulthandler 4 | import hashlib 5 | import itertools 6 | import logging 7 | from logging.handlers import RotatingFileHandler 8 | import os 9 | import sys 10 | from pathlib import Path 11 | from zipfile import ZipFile 12 | 13 | import ujson 14 | from apscheduler.events import EVENT_JOB_ERROR 15 | from numpy import random 16 | from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, ChatAction, ReplyKeyboardMarkup, Message, MessageEntity, InputMediaDocument 17 | from telegram.constants import PARSEMODE_MARKDOWN_V2 18 | from telegram.error import BadRequest 19 | from telegram.ext import Updater, CommandHandler, MessageHandler, Filters, CallbackContext, CallbackQueryHandler 20 | import websocket 21 | from telegram.utils.helpers import escape_markdown 22 | 23 | from configuration import ConfigWrapper 24 | from camera import Camera 25 | from klippy import Klippy 26 | from notifications import Notifier 27 | from power_device import PowerDevice 28 | from timelapse import Timelapse 29 | 30 | try: 31 | import thread 32 | except ImportError: 33 | import _thread as thread 34 | 35 | from io import BytesIO 36 | import emoji 37 | from apscheduler.schedulers.background import BackgroundScheduler 38 | 39 | logging.basicConfig( 40 | handlers=[ 41 | logging.StreamHandler(sys.stdout) 42 | ], 43 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 44 | level=logging.INFO 45 | ) 46 | 47 | logger = logging.getLogger(__name__) 48 | 49 | 50 | def handle_exception(exc_type, exc_value, exc_traceback): 51 | if issubclass(exc_type, KeyboardInterrupt): 52 | sys.__excepthook__(exc_type, exc_value, exc_traceback) 53 | return 54 | 55 | logger.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback), stack_info=True) 56 | 57 | 58 | sys.excepthook = handle_exception 59 | 60 | 61 | # some global params 62 | def errors_listener(event): 63 | exception_info = f'Job {event.job_id} raised' 64 | if event.exception.message: 65 | exception_info += f'{event.exception.message}\n' 66 | else: 67 | exception_info += f'{event.exception}\n' 68 | logger.error(exception_info, exc_info=True, stack_info=True) 69 | 70 | 71 | scheduler = BackgroundScheduler({ 72 | 'apscheduler.executors.default': { 73 | 'class': 'apscheduler.executors.pool:ThreadPoolExecutor', 74 | 'max_workers': '10' 75 | }, 76 | 'apscheduler.job_defaults.coalesce': 'false', 77 | 'apscheduler.job_defaults.max_instances': '1', 78 | }, daemon=True) 79 | scheduler.add_listener(errors_listener, EVENT_JOB_ERROR) 80 | 81 | bot_updater: Updater 82 | configWrap: ConfigWrapper = None 83 | myId = random.randint(300000) 84 | cameraWrap: Camera 85 | timelapse: Timelapse 86 | notifier: Notifier 87 | ws: websocket.WebSocketApp = None 88 | klippy: Klippy 89 | light_power_device: PowerDevice 90 | psu_power_device: PowerDevice 91 | 92 | 93 | def echo_unknown(update: Update, _: CallbackContext) -> None: 94 | update.message.reply_text(f"unknown command: {update.message.text}", quote=True) 95 | 96 | 97 | def unknown_chat(update: Update, _: CallbackContext) -> None: 98 | if update.effective_chat.id in configWrap.notifications.notify_groups: 99 | return 100 | if update.effective_chat.id >= 0: 101 | mess = f"Unauthorized access detected with chat_id: {update.effective_chat.id}.\n||This incident will be reported.||" 102 | update.effective_message.reply_text(escape_markdown(mess, version=2), parse_mode=PARSEMODE_MARKDOWN_V2, quote=True) 103 | logger.error(f"Unauthorized access detected from `{update.effective_chat.username}` with chat_id `{update.effective_chat.id}`. Message: {update.effective_message.to_json()}") 104 | 105 | 106 | def status(update: Update, _: CallbackContext) -> None: 107 | message_to_reply = update.message if update.message else update.effective_message 108 | if klippy.printing and not configWrap.notifications.group_only: 109 | notifier.update_status() 110 | import time 111 | time.sleep(configWrap.camera.light_timeout + 1.5) 112 | message_to_reply.delete() 113 | else: 114 | mess = escape_markdown(klippy.get_status(), version=2) 115 | if cameraWrap.enabled: 116 | with cameraWrap.take_photo() as bio: 117 | message_to_reply.bot.send_chat_action(chat_id=configWrap.bot.chat_id, action=ChatAction.UPLOAD_PHOTO) 118 | message_to_reply.reply_photo(photo=bio, caption=mess, parse_mode=PARSEMODE_MARKDOWN_V2, disable_notification=notifier.silent_commands) 119 | bio.close() 120 | else: 121 | message_to_reply.bot.send_chat_action(chat_id=configWrap.bot.chat_id, action=ChatAction.TYPING) 122 | message_to_reply.reply_text(mess, parse_mode=PARSEMODE_MARKDOWN_V2, disable_notification=notifier.silent_commands, quote=True) 123 | 124 | 125 | def check_unfinished_lapses(): 126 | files = cameraWrap.detect_unfinished_lapses() 127 | if not files: 128 | return 129 | bot_updater.bot.send_chat_action(chat_id=configWrap.bot.chat_id, action=ChatAction.TYPING) 130 | files_keys = list(map(list, zip(map(lambda el: InlineKeyboardButton(text=el, callback_data=f'lapse:{hashlib.md5(el.encode()).hexdigest()}'), files)))) 131 | files_keys.append([InlineKeyboardButton(emoji.emojize(':no_entry_sign: ', use_aliases=True), callback_data='do_nothing')]) 132 | reply_markup = InlineKeyboardMarkup(files_keys) 133 | bot_updater.bot.send_message(configWrap.bot.chat_id, text='Unfinished timelapses found\nBuild unfinished timelapse?', reply_markup=reply_markup, disable_notification=notifier.silent_status) 134 | 135 | 136 | def get_video(update: Update, _: CallbackContext) -> None: 137 | message_to_reply = update.message if update.message else update.effective_message 138 | if not cameraWrap.enabled: 139 | message_to_reply.reply_text("camera is disabled", quote=True) 140 | else: 141 | info_reply: Message = message_to_reply.reply_text(text=f"Starting video recording", disable_notification=notifier.silent_commands, quote=True) 142 | message_to_reply.bot.send_chat_action(chat_id=configWrap.bot.chat_id, action=ChatAction.RECORD_VIDEO) 143 | with cameraWrap.take_video_generator() as (video_bio, thumb_bio, width, height): 144 | info_reply.edit_text(text="Uploading video") 145 | if video_bio.getbuffer().nbytes > 52428800: 146 | info_reply.edit_text(text='Telegram has a 50mb restriction...') 147 | else: 148 | message_to_reply.reply_video(video=video_bio, thumb=thumb_bio, width=width, height=height, caption='', timeout=120, disable_notification=notifier.silent_commands, quote=True) 149 | message_to_reply.bot.delete_message(chat_id=configWrap.bot.chat_id, message_id=info_reply.message_id) 150 | 151 | video_bio.close() 152 | thumb_bio.close() 153 | 154 | 155 | def manage_printing(command: str) -> None: 156 | ws.send(ujson.dumps({"jsonrpc": "2.0", "method": f"printer.print.{command}", "id": myId})) 157 | 158 | 159 | def emergency_stop_printer(): 160 | ws.send(ujson.dumps({"jsonrpc": "2.0", "method": f"printer.emergency_stop", "id": myId})) 161 | 162 | 163 | def shutdown_pi_host(): 164 | ws.send(ujson.dumps({"jsonrpc": "2.0", "method": f"machine.shutdown", "id": myId})) 165 | 166 | 167 | def confirm_keyboard(callback_mess: str) -> InlineKeyboardMarkup: 168 | keyboard = [ 169 | [ 170 | InlineKeyboardButton(emoji.emojize(':white_check_mark: ', use_aliases=True), callback_data=callback_mess), 171 | InlineKeyboardButton(emoji.emojize(':no_entry_sign: ', use_aliases=True), callback_data='do_nothing'), 172 | ] 173 | ] 174 | return InlineKeyboardMarkup(keyboard) 175 | 176 | 177 | def pause_printing(update: Update, __: CallbackContext) -> None: 178 | update.message.bot.send_chat_action(chat_id=configWrap.bot.chat_id, action=ChatAction.TYPING) 179 | update.message.reply_text('Pause printing?', reply_markup=confirm_keyboard('pause_printing'), disable_notification=notifier.silent_commands, quote=True) 180 | 181 | 182 | def resume_printing(update: Update, __: CallbackContext) -> None: 183 | update.message.bot.send_chat_action(chat_id=configWrap.bot.chat_id, action=ChatAction.TYPING) 184 | update.message.reply_text('Resume printing?', reply_markup=confirm_keyboard('resume_printing'), disable_notification=notifier.silent_commands, quote=True) 185 | 186 | 187 | def cancel_printing(update: Update, __: CallbackContext) -> None: 188 | update.message.bot.send_chat_action(chat_id=configWrap.bot.chat_id, action=ChatAction.TYPING) 189 | update.message.reply_text('Cancel printing?', reply_markup=confirm_keyboard('cancel_printing'), disable_notification=notifier.silent_commands, quote=True) 190 | 191 | 192 | def emergency_stop(update: Update, _: CallbackContext) -> None: 193 | update.message.bot.send_chat_action(chat_id=configWrap.bot.chat_id, action=ChatAction.TYPING) 194 | update.message.reply_text('Execute emergency stop?', reply_markup=confirm_keyboard('emergency_stop'), disable_notification=notifier.silent_commands, quote=True) 195 | 196 | 197 | def shutdown_host(update: Update, _: CallbackContext) -> None: 198 | update.message.bot.send_chat_action(chat_id=configWrap.bot.chat_id, action=ChatAction.TYPING) 199 | update.message.reply_text('Shutdown host?', reply_markup=confirm_keyboard('shutdown_host'), disable_notification=notifier.silent_commands, quote=True) 200 | 201 | 202 | def bot_restart(update: Update, _: CallbackContext) -> None: 203 | update.message.bot.send_chat_action(chat_id=configWrap.bot.chat_id, action=ChatAction.TYPING) 204 | update.message.reply_text('Restart bot?', reply_markup=confirm_keyboard('bot_restart'), disable_notification=notifier.silent_commands, quote=True) 205 | 206 | 207 | def send_logs(update: Update, _: CallbackContext) -> None: 208 | update.effective_message.bot.send_chat_action(chat_id=configWrap.bot.chat_id, action=ChatAction.UPLOAD_DOCUMENT) 209 | logs_list = [] 210 | if Path(f'{configWrap.bot.log_path}/telegram.log').exists(): 211 | with open(f'{configWrap.bot.log_path}/telegram.log', 'rb') as fh: 212 | logs_list.append(InputMediaDocument(fh.read(), filename='telegram.log')) 213 | if Path(f'{configWrap.bot.log_path}/klippy.log').exists(): 214 | with open(f'{configWrap.bot.log_path}/klippy.log', 'rb') as fh: 215 | logs_list.append(InputMediaDocument(fh.read(), filename='klippy.log')) 216 | if Path(f'{configWrap.bot.log_path}/moonraker.log').exists(): 217 | with open(f'{configWrap.bot.log_path}/moonraker.log', 'rb') as fh: 218 | logs_list.append(InputMediaDocument(fh.read(), filename='moonraker.log')) 219 | if logs_list: 220 | update.effective_message.reply_media_group(logs_list, disable_notification=notifier.silent_commands, quote=True) 221 | else: 222 | update.effective_message.reply_text(text='No logs found in log_path', disable_notification=notifier.silent_commands, quote=True) 223 | 224 | 225 | def restart_bot() -> None: 226 | if ws: 227 | ws.close() 228 | os._exit(1) 229 | 230 | 231 | def power(update: Update, _: CallbackContext) -> None: 232 | message_to_reply = update.message if update.message else update.effective_message 233 | message_to_reply.bot.send_chat_action(chat_id=configWrap.bot.chat_id, action=ChatAction.TYPING) 234 | if psu_power_device: 235 | if psu_power_device.device_state: 236 | message_to_reply.reply_text('Power Off printer?', reply_markup=confirm_keyboard('power_off_printer'), disable_notification=notifier.silent_commands, quote=True) 237 | else: 238 | message_to_reply.reply_text('Power On printer?', reply_markup=confirm_keyboard('power_on_printer'), disable_notification=notifier.silent_commands, quote=True) 239 | else: 240 | message_to_reply.reply_text("No power device in config!", disable_notification=notifier.silent_commands, quote=True) 241 | 242 | 243 | def light_toggle(update: Update, _: CallbackContext) -> None: 244 | if light_power_device: 245 | mess = f'Device `{light_power_device.name}` toggled ' + ('on' if light_power_device.toggle_device() else 'off') 246 | update.effective_message.reply_text(mess, parse_mode=PARSEMODE_MARKDOWN_V2, disable_notification=notifier.silent_commands, quote=True) 247 | else: 248 | update.effective_message.reply_text("No light device in config!", disable_notification=notifier.silent_commands, quote=True) 249 | 250 | 251 | def button_handler(update: Update, context: CallbackContext) -> None: 252 | context.bot.send_chat_action(chat_id=configWrap.bot.chat_id, action=ChatAction.TYPING) 253 | query = update.callback_query 254 | query.answer() 255 | # Todo: maybe regex check? 256 | if query.data == 'do_nothing': 257 | if update.effective_message.reply_to_message: 258 | context.bot.delete_message(update.effective_message.chat_id, update.effective_message.reply_to_message.message_id) 259 | query.delete_message() 260 | elif query.data == 'emergency_stop': 261 | emergency_stop_printer() 262 | query.delete_message() 263 | elif query.data == 'shutdown_host': 264 | update.effective_message.reply_to_message.reply_text("Shutting down host", quote=True) 265 | query.delete_message() 266 | shutdown_pi_host() 267 | elif query.data == 'bot_restart': 268 | update.effective_message.reply_to_message.reply_text("Restarting bot", quote=True) 269 | query.delete_message() 270 | restart_bot() 271 | elif query.data == 'cancel_printing': 272 | manage_printing('cancel') 273 | query.delete_message() 274 | elif query.data == 'pause_printing': 275 | manage_printing('pause') 276 | query.delete_message() 277 | elif query.data == 'resume_printing': 278 | manage_printing('resume') 279 | query.delete_message() 280 | elif query.data == 'power_off_printer': 281 | psu_power_device.switch_device(False) 282 | update.effective_message.reply_to_message.reply_text(f"Device `{psu_power_device.name}` toggled off", parse_mode=PARSEMODE_MARKDOWN_V2, quote=True) 283 | query.delete_message() 284 | elif query.data == 'power_on_printer': 285 | psu_power_device.switch_device(True) 286 | update.effective_message.reply_to_message.reply_text(f"Device `{psu_power_device.name}` toggled on", parse_mode=PARSEMODE_MARKDOWN_V2, quote=True) 287 | query.delete_message() 288 | elif 'macro:' in query.data: 289 | command = query.data.replace('macro:', '') 290 | update.effective_message.reply_to_message.reply_text(f"Running macro: {command}", disable_notification=notifier.silent_commands, quote=True) 291 | query.delete_message() 292 | klippy.execute_command(command) 293 | elif 'macroc:' in query.data: 294 | command = query.data.replace('macroc:', '') 295 | query.edit_message_text(text=f"Execute marco {command}?", reply_markup=confirm_keyboard(f'macro:{command}')) 296 | elif '.gcode' in query.data and ':' not in query.data: 297 | keyboard_keys = dict((x['callback_data'], x['text']) for x in itertools.chain.from_iterable(query.message.reply_markup.to_dict()['inline_keyboard'])) 298 | filename = keyboard_keys[query.data] 299 | keyboard = [ 300 | [ 301 | InlineKeyboardButton(emoji.emojize(':robot: print file', use_aliases=True), callback_data=f'print_file:{query.data}'), 302 | InlineKeyboardButton(emoji.emojize(':cross_mark: cancel', use_aliases=True), callback_data='cancel_file'), 303 | ] 304 | ] 305 | reply_markup = InlineKeyboardMarkup(keyboard) 306 | start_pre_mess = 'Start printing file:' 307 | message, bio = klippy.get_file_info_by_name(filename, f"{start_pre_mess}{filename}?") 308 | if bio is not None: 309 | update.effective_message.reply_to_message.reply_photo(photo=bio, caption=message, reply_markup=reply_markup, disable_notification=notifier.silent_commands, quote=True, 310 | caption_entities=[MessageEntity(type='bold', offset=len(start_pre_mess), length=len(filename))]) 311 | bio.close() 312 | context.bot.delete_message(update.effective_message.chat_id, update.effective_message.message_id) 313 | else: 314 | query.edit_message_text(text=message, reply_markup=reply_markup, entities=[MessageEntity(type='bold', offset=len(start_pre_mess), length=len(filename))]) 315 | elif 'print_file' in query.data: 316 | if query.message.caption: 317 | filename = query.message.parse_caption_entity(query.message.caption_entities[0]).strip() 318 | else: 319 | filename = query.message.parse_entity(query.message.entities[0]).strip() 320 | if klippy.start_printing_file(filename): 321 | query.delete_message() 322 | else: 323 | if query.message.text: 324 | query.edit_message_text(text=f"Failed start printing file {filename}") 325 | elif query.message.caption: 326 | query.message.edit_caption(caption=f"Failed start printing file {filename}") 327 | elif 'lapse:' in query.data: 328 | lapse_name = next(filter(lambda el: el[0].callback_data == query.data, query.message.reply_markup.inline_keyboard))[0].text 329 | info_mess: Message = query.bot.send_message(chat_id=configWrap.bot.chat_id, text=f"Starting time-lapse assembly for {lapse_name}", disable_notification=notifier.silent_commands) 330 | query.bot.send_chat_action(chat_id=configWrap.bot.chat_id, action=ChatAction.RECORD_VIDEO) 331 | # Todo: refactor all timelapse cals 332 | (video_bio, thumb_bio, width, height, video_path, gcode_name) = cameraWrap.create_timelapse_for_file(lapse_name, info_mess) 333 | info_mess.edit_text(text="Uploading time-lapse") 334 | if video_bio.getbuffer().nbytes > 52428800: 335 | info_mess.edit_text(text=f'Telegram bots have a 50mb filesize restriction, please retrieve the timelapse from the configured folder\n{video_path}') 336 | else: 337 | query.bot.send_video(configWrap.bot.chat_id, video=video_bio, thumb=thumb_bio, width=width, height=height, caption=f'time-lapse of {lapse_name}', timeout=120, 338 | disable_notification=notifier.silent_commands) 339 | query.bot.delete_message(chat_id=configWrap.bot.chat_id, message_id=info_mess.message_id) 340 | 341 | video_bio.close() 342 | thumb_bio.close() 343 | query.delete_message() 344 | check_unfinished_lapses() 345 | else: 346 | logger.debug(f"unknown message from inline keyboard query: {query.data}") 347 | query.delete_message() 348 | 349 | 350 | def get_gcode_files(update: Update, _: CallbackContext) -> None: 351 | def create_file_button(element) -> InlineKeyboardButton: 352 | filename = element['path'] if 'path' in element else element['filename'] 353 | return InlineKeyboardButton(filename, callback_data=hashlib.md5(filename.encode()).hexdigest() + '.gcode') 354 | 355 | update.message.bot.send_chat_action(chat_id=configWrap.bot.chat_id, action=ChatAction.TYPING) 356 | files_keys = list(map(list, zip(map(create_file_button, klippy.get_gcode_files())))) 357 | reply_markup = InlineKeyboardMarkup(files_keys) 358 | 359 | update.message.reply_text('Gcode files to print:', reply_markup=reply_markup, disable_notification=notifier.silent_commands, quote=True) 360 | 361 | 362 | def exec_gcode(update: Update, _: CallbackContext) -> None: 363 | # maybe use context.args 364 | message = update.message if update.message else update.effective_message 365 | if not message.text == '/gcode': 366 | command = message.text.replace('/gcode ', '') 367 | klippy.execute_command(command) 368 | else: 369 | message.reply_text('No command provided', quote=True) 370 | 371 | 372 | def get_macros(update: Update, _: CallbackContext) -> None: 373 | update.effective_message.bot.send_chat_action(chat_id=configWrap.bot.chat_id, action=ChatAction.TYPING) 374 | files_keys = list(map(list, zip(map(lambda el: InlineKeyboardButton(el, callback_data=f'macroc:{el}' if configWrap.telegram_ui.require_confirmation_macro else f'macro:{el}'), klippy.macros)))) 375 | reply_markup = InlineKeyboardMarkup(files_keys) 376 | 377 | update.effective_message.reply_text('Gcode macros:', reply_markup=reply_markup, disable_notification=notifier.silent_commands, quote=True) 378 | 379 | 380 | def macros_handler(update: Update, _: CallbackContext) -> None: 381 | command = update.effective_message.text.replace('/', '').upper() 382 | if command in klippy.macros_all: 383 | if configWrap.telegram_ui.require_confirmation_macro: 384 | update.effective_message.reply_text(f"Execute marco {command}?", reply_markup=confirm_keyboard(f'macro:{command}'), disable_notification=notifier.silent_commands, quote=True) 385 | else: 386 | klippy.execute_command(command) 387 | update.effective_message.reply_text(f"Running macro: {command}", disable_notification=notifier.silent_commands, quote=True) 388 | else: 389 | echo_unknown(update, _) 390 | 391 | 392 | def upload_file(update: Update, _: CallbackContext) -> None: 393 | update.message.bot.send_chat_action(chat_id=configWrap.bot.chat_id, action=ChatAction.UPLOAD_DOCUMENT) 394 | doc = update.message.document 395 | if not doc.file_name.endswith(('.gcode', '.zip')): 396 | update.message.reply_text(f"unknown filetype in {doc.file_name}", disable_notification=notifier.silent_commands, quote=True) 397 | return 398 | 399 | try: 400 | file_byte_array = doc.get_file().download_as_bytearray() 401 | except BadRequest as badreq: 402 | update.message.reply_text(f"Bad request: {badreq.message}", disable_notification=notifier.silent_commands, quote=True) 403 | return 404 | 405 | # Todo: add context managment! 406 | uploaded_bio = BytesIO() 407 | uploaded_bio.name = doc.file_name 408 | uploaded_bio.write(file_byte_array) 409 | uploaded_bio.seek(0) 410 | 411 | sending_bio = BytesIO() 412 | if doc.file_name.endswith('.gcode'): 413 | sending_bio = uploaded_bio 414 | elif doc.file_name.endswith('.zip'): 415 | with ZipFile(uploaded_bio) as my_zip_file: 416 | if len(my_zip_file.namelist()) > 1: 417 | update.message.reply_text(f"Multiple files in archive {doc.file_name}", disable_notification=notifier.silent_commands, quote=True) 418 | return 419 | 420 | contained_file = my_zip_file.open(my_zip_file.namelist()[0]) 421 | sending_bio.name = contained_file.name 422 | sending_bio.write(contained_file.read()) 423 | sending_bio.seek(0) 424 | 425 | if klippy.upload_file(sending_bio): 426 | filehash = hashlib.md5(doc.file_name.encode()).hexdigest() + '.gcode' 427 | keyboard = [ 428 | [ 429 | InlineKeyboardButton(emoji.emojize(':robot: print file', use_aliases=True), callback_data=f'print_file:{filehash}'), 430 | InlineKeyboardButton(emoji.emojize(':cross_mark: do nothing', use_aliases=True), callback_data='do_nothing'), 431 | ] 432 | ] 433 | reply_markup = InlineKeyboardMarkup(keyboard) 434 | update.message.reply_text(f"Successfully uploaded file: {sending_bio.name}", reply_markup=reply_markup, disable_notification=notifier.silent_commands, quote=True) 435 | else: 436 | update.message.reply_text(f"Failed uploading file: {sending_bio.name}", disable_notification=notifier.silent_commands, quote=True) 437 | 438 | uploaded_bio.close() 439 | sending_bio.close() 440 | 441 | 442 | def bot_error_handler(_: object, context: CallbackContext) -> None: 443 | logger.error(msg="Exception while handling an update:", exc_info=context.error) 444 | 445 | 446 | def create_keyboard(): 447 | if not configWrap.telegram_ui.buttons_default: 448 | return configWrap.telegram_ui.buttons 449 | 450 | custom_keyboard = [] 451 | if cameraWrap.enabled: 452 | custom_keyboard.append('/video') 453 | if psu_power_device: 454 | custom_keyboard.append('/power') 455 | if light_power_device: 456 | custom_keyboard.append('/light') 457 | 458 | keyboard = configWrap.telegram_ui.buttons 459 | if len(custom_keyboard) > 0: 460 | keyboard.append(custom_keyboard) 461 | return keyboard 462 | 463 | 464 | def help_command(update: Update, _: CallbackContext) -> None: 465 | update.message.reply_text('The following commands are known:\n\n' 466 | '/status - send klipper status\n' 467 | '/pause - pause printing\n' 468 | '/resume - resume printing\n' 469 | '/cancel - cancel printing\n' 470 | '/files - list last 5 files(you can start printing one from menu)\n' 471 | '/logs - get klipper, moonraker, bot logs\n' 472 | '/macros - list all visible macros from klipper\n' 473 | '/gcode - run any gcode command, spaces are supported (/gcode G28 Z)\n' 474 | '/video - will take mp4 video from camera\n' 475 | '/power - toggle moonraker power device from config\n' 476 | '/light - toggle light\n' 477 | '/emergency - emergency stop printing\n' 478 | '/bot_restart - restarts the bot service, useful for config updates\n' 479 | '/shutdown - shutdown Pi gracefully', 480 | quote=True) 481 | 482 | 483 | def greeting_message(): 484 | response = klippy.check_connection() 485 | mess = f'Bot online, no moonraker connection!\n {response} \nFailing...' if response else 'Printer online' 486 | if configWrap.unknown_fields: 487 | mess += f"\n{configWrap.unknown_fields}" 488 | reply_markup = ReplyKeyboardMarkup(create_keyboard(), resize_keyboard=True) 489 | bot_updater.bot.send_message(configWrap.bot.chat_id, text=mess, reply_markup=reply_markup, disable_notification=notifier.silent_status) 490 | commands = [ 491 | ('help', 'list bot commands'), 492 | ('status', 'send klipper status'), 493 | ('pause', 'pause printing'), 494 | ('resume', 'resume printing'), 495 | ('cancel', 'cancel printing'), 496 | ('files', "list last 5 files. you can start printing one from menu"), 497 | ('logs', "get klipper, moonraker, bot logs"), 498 | ('macros', 'list all visible macros from klipper'), 499 | ('gcode', 'run any gcode command, spaces are supported. "gcode G28 Z"'), 500 | ('video', 'will take mp4 video from camera'), 501 | ('power', 'toggle moonraker power device from config'), 502 | ('light', 'toggle light'), 503 | ('emergency', 'emergency stop printing'), 504 | ('bot_restart', 'restarts the bot service, useful for config updates'), 505 | ('shutdown', 'shutdown Pi gracefully') 506 | ] 507 | if configWrap.telegram_ui.include_macros_in_command_list: 508 | commands += list(map(lambda el: (el.lower(), el), klippy.macros)) 509 | if len(commands) >= 100: 510 | logger.warning("Commands list too large!") 511 | commands = commands[0:99] 512 | bot_updater.bot.set_my_commands(commands=commands) 513 | check_unfinished_lapses() 514 | 515 | 516 | def start_bot(bot_token, socks): 517 | request_kwargs = { 518 | 'read_timeout': 15, 519 | } 520 | 521 | if socks: 522 | request_kwargs['proxy_url'] = f'socks5://{socks}' 523 | 524 | updater = Updater(token=bot_token, base_url=configWrap.bot.api_url, workers=4, request_kwargs=request_kwargs) 525 | 526 | dispatcher = updater.dispatcher 527 | 528 | dispatcher.add_handler(MessageHandler(~Filters.chat(configWrap.bot.chat_id), unknown_chat)) 529 | 530 | dispatcher.add_handler(CallbackQueryHandler(button_handler)) 531 | dispatcher.add_handler(CommandHandler("help", help_command, run_async=True)) 532 | dispatcher.add_handler(CommandHandler("status", status, run_async=True)) 533 | dispatcher.add_handler(CommandHandler("video", get_video)) 534 | dispatcher.add_handler(CommandHandler("pause", pause_printing)) 535 | dispatcher.add_handler(CommandHandler("resume", resume_printing)) 536 | dispatcher.add_handler(CommandHandler("cancel", cancel_printing)) 537 | dispatcher.add_handler(CommandHandler("power", power)) 538 | dispatcher.add_handler(CommandHandler("light", light_toggle)) 539 | dispatcher.add_handler(CommandHandler("emergency", emergency_stop)) 540 | dispatcher.add_handler(CommandHandler("shutdown", shutdown_host)) 541 | dispatcher.add_handler(CommandHandler("bot_restart", bot_restart)) 542 | dispatcher.add_handler(CommandHandler("files", get_gcode_files, run_async=True)) 543 | dispatcher.add_handler(CommandHandler("macros", get_macros, run_async=True)) 544 | dispatcher.add_handler(CommandHandler("gcode", exec_gcode, run_async=True)) 545 | dispatcher.add_handler(CommandHandler("logs", send_logs, run_async=True)) 546 | 547 | dispatcher.add_handler(MessageHandler(Filters.command, macros_handler, run_async=True)) 548 | 549 | dispatcher.add_handler(MessageHandler(Filters.document & ~Filters.command, upload_file, run_async=True)) 550 | 551 | dispatcher.add_handler(MessageHandler(Filters.text & ~Filters.command, echo_unknown)) 552 | 553 | dispatcher.add_error_handler(bot_error_handler) 554 | 555 | updater.start_polling() 556 | 557 | return updater 558 | 559 | 560 | def on_close(_, close_status_code, close_msg): 561 | logger.info("WebSocket closed") 562 | if close_status_code or close_msg: 563 | logger.error("WebSocket close status code: " + str(close_status_code)) 564 | logger.error("WebSocket close message: " + str(close_msg)) 565 | 566 | 567 | def on_error(_, error): 568 | logger.error(error) 569 | 570 | 571 | def subscribe(websock): 572 | subscribe_objects = { 573 | 'print_stats': None, 574 | 'display_status': None, 575 | 'toolhead': ['position'], 576 | 'gcode_move': ['position', 'gcode_position'], 577 | 'virtual_sdcard': ['progress'] 578 | } 579 | 580 | sensors = klippy.prepare_sens_dict_subscribe() 581 | if sensors: 582 | subscribe_objects.update(sensors) 583 | 584 | websock.send( 585 | ujson.dumps({'jsonrpc': '2.0', 586 | 'method': 'printer.objects.subscribe', 587 | 'params': { 588 | 'objects': subscribe_objects 589 | }, 590 | 'id': myId})) 591 | 592 | 593 | def on_open(websock): 594 | websock.send( 595 | ujson.dumps({'jsonrpc': '2.0', 596 | 'method': 'printer.info', 597 | 'id': myId})) 598 | websock.send( 599 | ujson.dumps({'jsonrpc': '2.0', 600 | 'method': 'machine.device_power.devices', 601 | 'id': myId})) 602 | 603 | 604 | def reshedule(): 605 | if not klippy.connected and ws.keep_running: 606 | on_open(ws) 607 | 608 | 609 | def stop_all(): 610 | klippy.stop_all() 611 | notifier.stop_all() 612 | timelapse.stop_all() 613 | 614 | 615 | def status_response(status_resp): 616 | if 'print_stats' in status_resp: 617 | print_stats = status_resp['print_stats'] 618 | if print_stats['state'] in ['printing', 'paused']: 619 | klippy.printing = True 620 | klippy.printing_filename = print_stats['filename'] 621 | klippy.printing_duration = print_stats['print_duration'] 622 | klippy.filament_used = print_stats['filament_used'] 623 | # Todo: maybe get print start time and set start interval for job? 624 | notifier.add_notifier_timer() 625 | if not timelapse.manual_mode: 626 | timelapse.running = True 627 | # TOdo: manual timelapse start check? 628 | 629 | # Fixme: some logic error with states for klippy.paused and printing 630 | if print_stats['state'] == "printing": 631 | klippy.paused = False 632 | if not timelapse.manual_mode: 633 | timelapse.paused = False 634 | if print_stats['state'] == "paused": 635 | klippy.paused = True 636 | if not timelapse.manual_mode: 637 | timelapse.paused = True 638 | if 'display_status' in status_resp: 639 | notifier.m117_status = status_resp['display_status']['message'] 640 | klippy.printing_progress = status_resp['display_status']['progress'] 641 | if 'virtual_sdcard' in status_resp: 642 | klippy.vsd_progress = status_resp['virtual_sdcard']['progress'] 643 | 644 | # Todo: add sensors & heaters parsing 645 | for sens in [key for key in status_resp if 'temperature_sensor' in key]: 646 | if status_resp[sens]: 647 | klippy.update_sensror(sens.replace('temperature_sensor ', ''), status_resp[sens]) 648 | 649 | for sens in [key for key in status_resp if 'temperature_fan' in key]: 650 | if status_resp[sens]: 651 | klippy.update_sensror(sens.replace('temperature_fan ', ''), status_resp[sens]) 652 | 653 | for heater in [key for key in status_resp if 'extruder' in key or 'heater_bed' in key or 'heater_generic' in key]: 654 | if status_resp[heater]: 655 | klippy.update_sensror(heater.replace('extruder ', '').replace('heater_bed ', '').replace('heater_generic ', ''), status_resp[heater]) 656 | 657 | 658 | # Todo: add command for setting status! 659 | def notify_gcode_reponse(message_params): 660 | if timelapse.manual_mode: 661 | if 'timelapse start' in message_params: 662 | if not klippy.printing_filename: 663 | klippy.get_status() 664 | timelapse.clean() 665 | timelapse.running = True 666 | 667 | if 'timelapse stop' in message_params: 668 | timelapse.running = False 669 | if 'timelapse pause' in message_params: 670 | timelapse.paused = True 671 | if 'timelapse resume' in message_params: 672 | timelapse.paused = False 673 | if 'timelapse create' in message_params: 674 | timelapse.send_timelapse() 675 | if 'timelapse photo' in message_params: 676 | timelapse.take_lapse_photo(manually=True) 677 | if message_params[0].startswith('tgnotify '): 678 | notifier.send_notification(message_params[0][9:]) 679 | if message_params[0].startswith('tgnotify_photo '): 680 | notifier.send_notification_with_photo(message_params[0][15:]) 681 | if message_params[0].startswith('tgalarm '): 682 | notifier.send_error(message_params[0][8:]) 683 | if message_params[0].startswith('tgalarm_photo '): 684 | notifier.send_error_with_photo(message_params[0][14:]) 685 | if message_params[0].startswith('tgnotify_status '): 686 | notifier.tgnotify_status = message_params[0][16:] 687 | if message_params[0].startswith('set_timelapse_params '): 688 | timelapse.parse_timelapse_params(message_params[0]) 689 | if message_params[0].startswith('set_notify_params '): 690 | notifier.parse_notification_params(message_params[0]) 691 | 692 | 693 | def notify_status_update(message_params): 694 | if 'display_status' in message_params[0]: 695 | if 'message' in message_params[0]['display_status']: 696 | notifier.m117_status = message_params[0]['display_status']['message'] 697 | if 'progress' in message_params[0]['display_status']: 698 | klippy.printing_progress = message_params[0]['display_status']['progress'] 699 | notifier.schedule_notification(progress=int(message_params[0]['display_status']['progress'] * 100)) 700 | 701 | if 'toolhead' in message_params[0] and 'position' in message_params[0]['toolhead']: 702 | # position_z = json_message["params"][0]['toolhead']['position'][2] 703 | pass 704 | if 'gcode_move' in message_params[0] and 'position' in message_params[0]['gcode_move']: 705 | position_z = message_params[0]['gcode_move']['gcode_position'][2] 706 | klippy.printing_height = position_z 707 | notifier.schedule_notification(position_z=int(position_z)) 708 | timelapse.take_lapse_photo(position_z) 709 | 710 | if 'virtual_sdcard' in message_params[0] and 'progress' in message_params[0]['virtual_sdcard']: 711 | klippy.vsd_progress = message_params[0]['virtual_sdcard']['progress'] 712 | 713 | if 'print_stats' in message_params[0]: 714 | parse_print_stats(message_params) 715 | 716 | for sens in [key for key in message_params[0] if 'temperature_sensor' in key]: 717 | klippy.update_sensror(sens.replace('temperature_sensor ', ''), message_params[0][sens]) 718 | 719 | for heater in [key for key in message_params[0] if 'extruder' in key or 'heater_bed' in key or 'heater_generic' in key]: 720 | klippy.update_sensror(heater.replace('extruder ', '').replace('heater_bed ', '').replace('heater_generic ', ''), message_params[0][heater]) 721 | 722 | 723 | def parse_print_stats(message_params): 724 | state = "" 725 | # Fixme: maybe do not parse without state? history data may not be avaliable 726 | # Message with filename will be sent before printing is started 727 | if 'filename' in message_params[0]['print_stats']: 728 | klippy.printing_filename = message_params[0]['print_stats']['filename'] 729 | if 'filament_used' in message_params[0]['print_stats']: 730 | klippy.filament_used = message_params[0]['print_stats']['filament_used'] 731 | if 'state' in message_params[0]['print_stats']: 732 | state = message_params[0]['print_stats']['state'] 733 | # Fixme: reset notify percent & height on finish/cancel/start 734 | if 'print_duration' in message_params[0]['print_stats']: 735 | klippy.printing_duration = message_params[0]['print_stats']['print_duration'] 736 | if state == 'printing': 737 | klippy.paused = False 738 | if not klippy.printing: 739 | klippy.printing = True 740 | notifier.reset_notifications() 741 | notifier.add_notifier_timer() 742 | if not klippy.printing_filename: 743 | klippy.get_status() 744 | if not timelapse.manual_mode: 745 | timelapse.clean() 746 | timelapse.running = True 747 | notifier.send_print_start_info() 748 | 749 | if not timelapse.manual_mode: 750 | timelapse.paused = False 751 | elif state == 'paused': 752 | klippy.paused = True 753 | if not timelapse.manual_mode: 754 | timelapse.paused = True 755 | # Todo: cleanup timelapse dir on cancel print! 756 | elif state == 'complete': 757 | klippy.printing = False 758 | notifier.remove_notifier_timer() 759 | if not timelapse.manual_mode: 760 | timelapse.running = False 761 | timelapse.send_timelapse() 762 | # Fixme: add finish printing method in notifier 763 | notifier.send_print_finish() 764 | elif state == 'error': 765 | klippy.printing = False 766 | timelapse.running = False 767 | notifier.remove_notifier_timer() 768 | error_mess = f"Printer state change error: {message_params[0]['print_stats']['state']}\n" 769 | if 'message' in message_params[0]['print_stats'] and message_params[0]['print_stats']['message']: 770 | error_mess += f"{message_params[0]['print_stats']['message']}\n" 771 | notifier.send_error(error_mess) 772 | elif state == 'standby': 773 | klippy.printing = False 774 | notifier.remove_notifier_timer() 775 | # Fixme: check manual mode 776 | timelapse.running = False 777 | notifier.send_notification(f"Printer state change: {message_params[0]['print_stats']['state']} \n") 778 | elif state: 779 | logger.error(f"Unknown state: {state}") 780 | 781 | 782 | def power_device_state(device): 783 | device_name = device["device"] 784 | device_state = True if device["status"] == 'on' else False 785 | if psu_power_device and psu_power_device.name == device_name: 786 | psu_power_device.device_state = device_state 787 | if light_power_device and light_power_device.name == device_name: 788 | light_power_device.device_state = device_state 789 | 790 | 791 | def websocket_to_message(ws_loc, ws_message): 792 | json_message = ujson.loads(ws_message) 793 | logger.debug(ws_message) 794 | 795 | if 'error' in json_message: 796 | return 797 | 798 | if 'id' in json_message: 799 | if 'result' in json_message: 800 | message_result = json_message['result'] 801 | 802 | if 'status' in message_result: 803 | status_response(message_result['status']) 804 | return 805 | 806 | if 'state' in message_result: 807 | klippy_state = message_result['state'] 808 | klippy.state = klippy_state 809 | if klippy_state == 'ready': 810 | if ws_loc.keep_running: 811 | klippy.connected = True 812 | if klippy.state_message: 813 | notifier.send_error(f"Klippy changed state to {klippy.state}") 814 | klippy.state_message = '' 815 | subscribe(ws_loc) 816 | if scheduler.get_job('ws_reschedule'): 817 | scheduler.remove_job('ws_reschedule') 818 | elif klippy_state in ['error', 'shutdown', 'startup']: 819 | klippy.connected = False 820 | scheduler.add_job(reshedule, 'interval', seconds=2, id='ws_reschedule', replace_existing=True) 821 | state_message = message_result['state_message'] 822 | if not klippy.state_message == state_message and not klippy_state == 'startup': 823 | klippy.state_message = state_message 824 | notifier.send_error(f"Klippy changed state to {klippy.state}\n{klippy.state_message}") 825 | else: 826 | logger.error(f"UnKnown klippy state: {klippy_state}") 827 | klippy.connected = False 828 | scheduler.add_job(reshedule, 'interval', seconds=2, id='ws_reschedule', replace_existing=True) 829 | return 830 | 831 | if 'devices' in message_result: 832 | for device in message_result['devices']: 833 | power_device_state(device) 834 | return 835 | 836 | # if debug: 837 | # bot_updater.bot.send_message(chatId, text=f"{message_result}") 838 | 839 | if 'error' in json_message: 840 | notifier.send_error(f"{json_message['error']['message']}") 841 | 842 | else: 843 | message_method = json_message['method'] 844 | if message_method in ["notify_klippy_shutdown", "notify_klippy_disconnected"]: 845 | logger.warning(f"klippy disconnect detected with message: {json_message['method']}") 846 | stop_all() 847 | klippy.connected = False 848 | scheduler.add_job(reshedule, 'interval', seconds=2, id='ws_reschedule', replace_existing=True) 849 | 850 | if 'params' not in json_message: 851 | return 852 | 853 | message_params = json_message['params'] 854 | 855 | if message_method == 'notify_gcode_response': 856 | notify_gcode_reponse(message_params) 857 | 858 | if message_method == 'notify_power_changed': 859 | for device in message_params: 860 | power_device_state(device) 861 | 862 | if message_method == 'notify_status_update': 863 | notify_status_update(message_params) 864 | 865 | 866 | def parselog(): 867 | with open('../telegram.log') as f: 868 | lines = f.readlines() 869 | 870 | wslines = list(filter(lambda it: ' - {' in it, lines)) 871 | tt = list(map(lambda el: el.split(' - ')[-1].replace('\n', ''), wslines)) 872 | 873 | for mes in tt: 874 | websocket_to_message(ws, mes) 875 | import time 876 | time.sleep(0.01) 877 | print('lalal') 878 | 879 | 880 | if __name__ == '__main__': 881 | parser = argparse.ArgumentParser(description="Moonraker Telegram Bot") 882 | parser.add_argument( 883 | "-c", "--configfile", default="./telegram.conf", 884 | metavar='', 885 | help="Location of moonraker telegram bot configuration file") 886 | system_args = parser.parse_args() 887 | conf = configparser.ConfigParser(allow_no_value=True, inline_comment_prefixes=(';', '#')) 888 | 889 | # Todo: os.chdir(Path(sys.path[0]).parent.absolute()) 890 | os.chdir(sys.path[0]) 891 | 892 | conf.read(system_args.configfile) 893 | configWrap = ConfigWrapper(conf) 894 | 895 | if not configWrap.bot.log_path == '/tmp': 896 | Path(configWrap.bot.log_path).mkdir(parents=True, exist_ok=True) 897 | 898 | rotatingHandler = RotatingFileHandler(os.path.join(f'{configWrap.bot.log_path}/', 'telegram.log'), maxBytes=26214400, backupCount=3) 899 | rotatingHandler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) 900 | logger.addHandler(rotatingHandler) 901 | 902 | if configWrap.bot.debug: 903 | faulthandler.enable() 904 | logger.setLevel(logging.DEBUG) 905 | logging.getLogger('apscheduler').addHandler(rotatingHandler) 906 | logging.getLogger('apscheduler').setLevel(logging.DEBUG) 907 | 908 | light_power_device = PowerDevice(configWrap.bot.light_device_name, configWrap.bot.host) 909 | psu_power_device = PowerDevice(configWrap.bot.poweroff_device_name, configWrap.bot.host) 910 | 911 | klippy = Klippy(configWrap, light_power_device, psu_power_device, rotatingHandler) 912 | cameraWrap = Camera(configWrap, klippy, light_power_device, rotatingHandler) 913 | bot_updater = start_bot(configWrap.bot.token, configWrap.bot.socks_proxy) 914 | timelapse = Timelapse(configWrap, klippy, cameraWrap, scheduler, bot_updater.bot, rotatingHandler) 915 | notifier = Notifier(configWrap, bot_updater.bot, klippy, cameraWrap, scheduler, rotatingHandler) 916 | 917 | scheduler.start() 918 | 919 | greeting_message() 920 | 921 | ws = websocket.WebSocketApp(f"ws://{configWrap.bot.host}/websocket{klippy.one_shot_token}", on_message=websocket_to_message, on_open=on_open, on_error=on_error, on_close=on_close) 922 | 923 | # debug reasons only 924 | if configWrap.bot.log_parser: 925 | parselog() 926 | 927 | scheduler.add_job(reshedule, 'interval', seconds=2, id='ws_reschedule', replace_existing=True) 928 | 929 | ws.run_forever(skip_utf8_validation=True) 930 | logger.info("Exiting! Moonraker connection lost!") 931 | 932 | bot_updater.stop() 933 | --------------------------------------------------------------------------------