From 1112bc03d4c9ade195746a810be112c9e3b86441 Mon Sep 17 00:00:00 2001 From: s3lph Date: Sun, 16 Jan 2022 19:58:36 +0100 Subject: [PATCH] Initial commit --- .gitignore | 3 + LICENSE | 16 ++ README.md | 1 + me.s3lph.ultimaker/base-config.yaml | 37 +++++ me.s3lph.ultimaker/maubot.yaml | 10 ++ me.s3lph.ultimaker/ultimaker.py | 246 ++++++++++++++++++++++++++++ 6 files changed, 313 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 me.s3lph.ultimaker/base-config.yaml create mode 100644 me.s3lph.ultimaker/maubot.yaml create mode 100644 me.s3lph.ultimaker/ultimaker.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd3fe1f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.pyc +**/__pycache__/ +*.mbp diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..26997df --- /dev/null +++ b/LICENSE @@ -0,0 +1,16 @@ +Copyright 2022 s3lph + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT +OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..948295f --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Ultimaker Status Bot Plugin for Maubot diff --git a/me.s3lph.ultimaker/base-config.yaml b/me.s3lph.ultimaker/base-config.yaml new file mode 100644 index 0000000..c1e0f83 --- /dev/null +++ b/me.s3lph.ultimaker/base-config.yaml @@ -0,0 +1,37 @@ +webhook: + room: '#foo:example.org' + cache: 'ultimaker.cache.json' +ultimaker: + api: http://ultimaker.example.org/ + camera_id: 0 # Set to null to disable sending a photo + # camera dimensions + camera_width: 640 + camera_height: 480 +messages: + _camera_disabled: |- + Ultimaker camera is disabled + _unreachable: |- + Ultimaker is unreachable, probably powered off. +state_messages: + booting: |- + Ultimaker is booting. + idle: |- + Ultimaker is idle. + # In printing state, you can use {job_name}, {job_state} and {job_progress} + printing: |- + Ultimaker is printing "{job_name}", {job_progress}% done. + error: |- + Ultimaker has experienced an error. + maintenance: |- + Ultimaker requires maintenance. +job_messages: + # You can use the following fields: + # {job}: Name of the print job + # {state}: State of the print job + # {duration}: Duration of the print job + _error: |- + Ultimaker is idle or powered off. + wait_cleanup: |- + Ultimaker print job "{job}" has finished after {duration}! + wait_user_action: |- + Ultimaker print job "{job}" requires user interaction. diff --git a/me.s3lph.ultimaker/maubot.yaml b/me.s3lph.ultimaker/maubot.yaml new file mode 100644 index 0000000..b1ca844 --- /dev/null +++ b/me.s3lph.ultimaker/maubot.yaml @@ -0,0 +1,10 @@ +maubot: 0.18.0 +id: me.s3lph.ultimaker +version: 0.1.0 +license: MIT +modules: + - ultimaker +main_class: UltimakerBot +config: true +extra_files: + - base-config.yaml diff --git a/me.s3lph.ultimaker/ultimaker.py b/me.s3lph.ultimaker/ultimaker.py new file mode 100644 index 0000000..7009a66 --- /dev/null +++ b/me.s3lph.ultimaker/ultimaker.py @@ -0,0 +1,246 @@ +from typing import Callable, Tuple, Type + +import json +import os.path +import asyncio +import aiohttp +from datetime import timedelta + +from maubot import Plugin +from maubot.handlers import command + +from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper +from mautrix.crypto.attachments import encrypt_attachment +from mautrix.errors.request import MNotFound +from mautrix.types import MessageEvent, TextMessageEventContent, MediaMessageEventContent, ImageInfo, RoomID, RoomAlias, MessageType, EventType, RoomEncryptionStateEventContent + + +class Config(BaseProxyConfig): + + def do_update(self, helper: ConfigUpdateHelper) -> None: + helper.copy('ultimaker') + helper.copy('poll') + helper.copy('messages') + helper.copy('state_messages') + helper.copy('job_messages') + helper.copy('command_prefix') + + +class UltimakerBot(Plugin): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._room: RoomID = None + self._update_room = True + self._polling_task = None + + def get_command_prefix(self) -> str: + return self.config.get('command_prefix', '3dp') + + @classmethod + def get_config_class(cls) -> Type[BaseProxyConfig]: + return Config + + def on_external_config_update(self) -> None: + self.config.load_and_update() + self._update_room = True + + async def start(self) -> None: + self.on_external_config_update() + await self._get_room() + if self._polling_task: + self._polling_task.cancel() + self._polling_task = asyncio.create_task(self.poll()) + + async def stop(self) -> None: + if self._polling_task: + self._polling_task.cancel() + self._polling_task = None + + async def _get_room(self) -> RoomID: + if self._room is not None and not self._update_room: + return self._room + room = self.config['poll']['room'] + if room.startswith('#'): + if 'resolve_room_alias' in dir(self.client): + self._room = (await self.client.resolve_room_alias(RoomAlias(room))).room_id + else: + self._room = (await self.client.get_room_alias(RoomAlias(room))).room_id + else: + self._room = RoomID(room) + self._update_room = False + return self._room + + async def _cached_ultimaker_api(self, + http: aiohttp.ClientSession, + url: str, cache_key: str, + diff_key: Callable) -> Tuple[object, bool]: + try: + url = os.path.join(self.config["ultimaker"]["api"], url) + async with http.get(url) as resp: + if resp.status != 200: + raise RuntimeError() + rdata = await resp.text() + except BaseException: + return None, False + now = json.loads(rdata) + with open(self.config['poll'][cache_key], 'a+') as cache: + cache.seek(0) + try: + pdata = cache.read() + previous = json.loads(pdata) + except BaseException as e: + self.log.exception(e) + previous = json.loads(rdata) + cache.seek(0) + cache.truncate() + cache.write(rdata) + changed = diff_key(now) != diff_key(previous) + return now, changed + + async def update_ultimaker_state(self, http: aiohttp.ClientSession) -> Tuple[object, bool]: + return await self._cached_ultimaker_api(http, 'api/v1/printer', 'state_cache', lambda x: x['status']) + + async def update_ultimaker_job(self, http: aiohttp.ClientSession) -> Tuple[object, bool]: + return await self._cached_ultimaker_api(http, 'api/v1/print_job', 'job_cache', lambda x: x['state']) + + async def send_ultimaker_state(self, state: object, job: object, requester: MessageEvent = None) -> None: + if requester is not None: + room = requester.room_id + else: + room = await self._get_room() + if state is not None: + fmt = { + 'status': state['status'] + } + if state['status'] == 'printing': + fmt.update({ + 'job_name': job['name'], + 'job_progress': str(int(job['progress'] * 100)), + }) + state_msg = self.config['state_messages'].get(state['status']) + else: + state_msg = self.config['messages'].get('_unreachable') + fmt = {} + if state_msg is not None: + body = state_msg.format(**fmt) + msg = TextMessageEventContent(msgtype=MessageType.TEXT, body=body) + if requester is not None: + await requester.reply(msg) + else: + await self.client.send_message(room, msg) + + async def send_ultimaker_job_report(self, job: object, requester: MessageEvent = None) -> None: + if requester is not None: + room = requester.room_id + else: + room = await self._get_room() + if job is not None: + fmt = { + 'job': job['name'], + 'state': job['state'], + 'duration': timedelta(seconds=job['time_elapsed']) + } + job_msg = self.config['job_messages'].get(job['state']) + else: + job__msg = self.config['job_messages'].get('_error') + fmt = {} + if state_msg is not None: + body = job_msg.format(**fmt) + msg = TextMessageEventContent(msgtype=MessageType.TEXT, body=body) + if requester is not None: + await requester.reply(msg) + else: + await self.client.send_message(room, msg) + + async def is_room_encrypted(self, room: RoomID) -> bool: + try: + evt: RoomEncryptionStateEventContent = \ + await self.client.get_state_event(room, EventType.ROOM_ENCRYPTION) + except MNotFound: + return False + return True + + async def send_optionally_encrypted_image(self, info: ImageInfo, image: bytes, requester: MessageEvent): + if requester is not None: + room = requester.room_id + else: + room = await self._get_room() + mimetype = info.mimetype + content = MediaMessageEventContent( + msgtype=MessageType.IMAGE, + body='uploaded an image', + info=info + ) + if await self.is_room_encrypted(room): + image, content.file = encrypt_attachment(image) + mimetype = 'application/octet-stream' + content.url = await self.client.upload_media(image, mimetype) + # Update content structure for encrypted data + if content.file: + content.file.url = content.url + content.url = None + if requester is not None: + await requester.reply(content) + else: + await self.client.send_message(room, content) + + async def send_ultimaker_photo(self, http: aiohttp.ClientSession, requester: MessageEvent = None) -> None: + if self.config['ultimaker']['camera_id'] is None: + body = self.config['messages'].get('_camera_disabled') + if requester and body: + msg = TextMessageEventContent(msgtype=MessageType.TEXT, body=body) + await requester.reply(msg) + return + url = os.path.join( + self.config["ultimaker"]["api"], 'api/v1/camera', str(self.config["ultimaker"]["camera_id"]), 'snapshot' + ) + try: + async with http.get(url) as resp: + if resp.status != 200: + raise RuntimeError() + image = await resp.read() + content_type = resp.headers['Content-Type'] + except: + body = self.config['messages'].get('_unreachable') + if requester and body: + msg = TextMessageEventContent(msgtype=MessageType.TEXT, body=body) + await requester.reply(msg) + return + info = ImageInfo( + mimetype=content_type, + size=len(image), + height=self.config['ultimaker'].get('camera_height', 480), + width=self.config['ultimaker'].get('camera_width', 640) + ) + await self.send_optionally_encrypted_image(info, image, requester) + + @command.new(name=get_command_prefix) + async def cmd(self, evt: MessageEvent) -> None: + await self.state(evt) + await self.photo(evt) + + @cmd.subcommand(help='Get the printer state') + async def state(self, evt: MessageEvent) -> None: + async with aiohttp.ClientSession(read_timeout=15, conn_timeout=5) as http: + state, _ = await self.update_ultimaker_state(http) + job, _ = await self.update_ultimaker_job(http) + await self.send_ultimaker_state(state, job, evt) + + @cmd.subcommand(help='Get a photo from the printer\'s camera') + async def photo(self, evt: MessageEvent) -> None: + async with aiohttp.ClientSession(read_timeout=15, conn_timeout=5) as http: + await self.send_ultimaker_photo(http, evt) + + async def poll(self) -> None: + async with aiohttp.ClientSession(read_timeout=15, conn_timeout=5) as http: + while True: + await asyncio.sleep(self.config['webhook'].get('interval', 60)) + self.log.debug('Polling ultimaker print job state') + try: + job, changed = await self.update_ultimaker_job(http) + if job and changed: + await self.send_ultimaker_job_report(job) + await self.send_ultimaker_photo(http) + except BaseException as e: + self.log.exception(e)