commit d42dbf7b6dd3e756fa23b8b3ae8053c500b6336d Author: Mattia Mascarello Date: Mon Apr 17 02:49:07 2023 +0200 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..076f6c7 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# SyncThru Telegram Bot + + +![Example](example.png) + + + +Receive alerts on your ink, tray, and drum levels of SyncThru-enabled Samsung printers on Telegram, and check their status. + + +## Installation + +Simply clone the repository or download the files, the install requirements with + +``` +pip install -r requirements.txt +``` + +## Usage + +Create a bot with [@BotFather](https://t.me/BotFather) and copy its token into the config. + +You need to run `main.py` for information polling and level messages +and `telegram.py` for the checking feature + +A printer may be configured as a static-ip or a dynamic-ip printer, in which case the software will attempt to find its local ip given its serial number + +Do not edit the `store.json` file if you do not know what you are doing, or you may crash the whole thing. + +## Configuration + +Here is an explainer + +```json +{ + "printers": [ + { + "ip": "192.168.1.2", // initial (or static) iè + "serial_num": "08HRB8GJ5E01R8W", // serial number + "dynamic_ip": true, // whether the ip is dynamic + "toner": true, // enables toner alerts + "drum": true,// enables drum alerts + "tray": true, // enables tray alerts + "alert_levels": [50, 20, 5] // the critical levels to which the alerts must be dispatched + } + ], + "update_interval": 600000, // how many seconds between updates + "telegram_bot_token": "123443:AAFG0kwfF92fds32sp92d", // the telegram token + "telegram_user_id": 153325233 // your telegram id, you can get it at https://t.me/username_to_id_bot +} +``` diff --git a/alert.py b/alert.py new file mode 100644 index 0000000..048ce7a --- /dev/null +++ b/alert.py @@ -0,0 +1,6 @@ +class Alert: + def __init__(self, severity: int, code: str, desc: str, uptime: int): + self.severity = severity + self.code = code + self.desc = desc + self.uptime = uptime \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..a91c541 --- /dev/null +++ b/config.json @@ -0,0 +1,16 @@ +{ + "printers": [ + { + "ip": "192.168.1.2", + "serial_num": "08HRB8GJ5E01R8W", + "dynamic_ip": true, + "toner": true, + "drum": true, + "tray": true, + "alert_levels": [50, 20, 5] + } + ], + "update_interval": 600000, + "telegram_bot_token": "", + "telegram_user_id": 135 +} diff --git a/drum.py b/drum.py new file mode 100644 index 0000000..9366d68 --- /dev/null +++ b/drum.py @@ -0,0 +1,5 @@ +class Drum: + def __init__(self, color: str, remaining_percent: int): + self.color = color + self.remaining_percent = remaining_percent + self.used_percent = 100 - remaining_percent \ No newline at end of file diff --git a/example.png b/example.png new file mode 100644 index 0000000..c5ed4c0 Binary files /dev/null and b/example.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..889a485 --- /dev/null +++ b/main.py @@ -0,0 +1,71 @@ +import requests +import json +import demjson +import datetime +import telebot +from printer import Printer +import time + + +def store_update(store: list, data: dict): + for idx, printer in enumerate(store): + if printer["serial_num"] == data["serial_num"]: + printer[int(idx)] = data + return + store.append(data) + + +def store_get_printer_by_serial_num(store: list, serial_num: str): + for printer in store: + if printer["serial_num"] == serial_num: + return printer + return None + + +def store_get(): + try: + with open("store.json", "r") as store_file: + return json.loads(store_file.read())["data"] + except Exception as e: + return [] + + +def store_set(data: list): + with open("store.json", "w+") as store_file: + store_file.write(json.dumps( + {"data": data, "last_update": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")})) + + +if __name__ == "__main__": + with open("config.json", "r") as config_file: + config = json.loads(config_file.read()) + token = config["telegram_bot_token"] + bot = telebot.TeleBot(token, parse_mode="markdown") + while True: + store_data = store_get() + for printer in config["printers"]: + ip = printer["ip"] + # pull persistent data from store + p = store_get_printer_by_serial_num( + store_data, printer["serial_num"]) + reachedTonLevels = {} + reachedDrumLevels = {} + reachedTrayLevels = {} + if p is not None and printer["dynamic_ip"]: + if p["ip"] is not None: + ip = p["ip"] + if "reachedTonLevels" in p: + reachedTonLevels = p["reachedTonLevels"] + if "reachedDrumLevels" in p: + reachedDrumLevels = p["reachedDrumLevels"] + if "reachedTrayLevels" in p: + reachedTrayLevels = p["reachedTrayLevels"] + # build and update printer + p = Printer(config, + ip, printer["serial_num"], printer["dynamic_ip"], bot, + printer["toner"], printer["drum"], printer["tray"], + printer["alert_levels"], reachedTonLevels, reachedDrumLevels, reachedTrayLevels) + p.update() + store_update(store_data, p.to_dict()) # replace printer data in store + store_set(store_data) # persist store + time.sleep(config["update_interval"]) diff --git a/printer.py b/printer.py new file mode 100644 index 0000000..643d119 --- /dev/null +++ b/printer.py @@ -0,0 +1,188 @@ +import requests +import demjson +import ipaddress + +from toner import Toner +from drum import Drum +from tray import Tray +from alert import Alert + + +class SerialNumException(Exception): + pass + + +class Printer: + def __init__(self, config: dict, ip: str, serial_num: str, dynamic_ip: bool, bot, + toner_alerts: bool, drum_alerts: bool, tray_alerts: bool, alert_levels: list[int], + reachedTonLevels: dict, reachedDrumLevels: dict, reachedTrayLevels: dict): + self.config = config + self.ip = ip + self.serial_num = serial_num + self.dynamic_ip = dynamic_ip + self.model_name = "????" + self.toners = [] + self.drums = [] + self.trays = [] + self.alerts = [] + self.uptime = 0 + self.bot = bot + self.toner_alerts = toner_alerts + self.drum_alerts = drum_alerts + self.tray_alerts = tray_alerts + self.alert_levels = alert_levels + self.reachedTonLevels = reachedTonLevels + self.reachedDrumLevels = reachedDrumLevels + self.reachedTrayLevels = reachedTrayLevels + + def __update_data(self, custom_ip=None) -> bool: + # this is pretty much a reverse-engineered version of the web api + ip = self.ip + if custom_ip is not None: + ip = custom_ip + home_url = 'http://'+ip+'/sws/app/information/home/home.json' + try: + r = requests.get(home_url) + text = r.content.decode("utf8") + home_data = demjson.decode(text) + self.model_name = home_data["identity"]["model_name"] + if home_data["identity"]["serial_num"] != self.serial_num: + raise SerialNumException() + for component in home_data.keys(): + if "drum_" in component: + color = component.replace("drum_", "") + drum = home_data[component] + if not drum["opt"]: + continue + self.drums.append(Drum(color, drum["remaining"])) + if self.drum_alerts: # check if alerts are enabled + drumLevels = [] + for level in self.alert_levels: + if drum["remaining"] <= level: # check alert levels reached + drumLevels.append(level) + if color not in self.reachedDrumLevels.keys(): + self.reachedDrumLevels[color] = [] # add color key to dict if not present + if len(drumLevels) and min(drumLevels) not in self.reachedDrumLevels["color"]: # check if alert level reached is not already in dict + dyn = "" + if self.dynamic_ip: + dyn = "(dynamic)" + rem = drum["remaining"] + # send telegram message + self.bot.send_message( + self.config["telegram_user_id"], f"Drum {color} of printer {self.serial_num} @ {self.ip} {dyn} is at {rem}%") + # add alert level to dict + self.reachedDrumLevels["color"] = drumLevels + if "tray" in component: + tray_no = int(component.replace("tray", "")) + tray = home_data[component] + if not tray["opt"]: + continue + self.trays.append( + Tray(tray_no, tray["capa"], tray["paper_level"])) + if self.tray_alerts: + trayLevels = [] + for level in self.alert_levels: + if tray["paper_level"] <= level: + trayLevels.append(level) + tray_key = f"t{tray_no}" + if tray_key not in self.reachedTrayLevels.keys(): + self.reachedTrayLevels[tray_key] = [] + if len(trayLevels) and min(trayLevels) not in self.reachedTrayLevels[tray_key]: + dyn = "" + if self.dynamic_ip: + dyn = "(dynamic)" + lev = tray["paper_level"] + self.bot.send_message( + self.config["telegram_user_id"], f"Tray {tray_no} of printer {self.serial_num} @ {self.ip} {dyn} is at {lev} pages") + self.reachedTrayLevels[tray_key] = trayLevels + if "toner_" in component: + color = component.replace("toner_", "") + ton = home_data[component] + if not ton["opt"]: + continue + self.toners.append( + Toner(color, ton["cnt"], ton["remaining"])) + if color not in self.reachedTonLevels.keys(): + self.reachedTonLevels[color] = [] + if self.toner_alerts: + tonLevels = [] + for level in self.alert_levels: + if ton["remaining"] <= level: + tonLevels.append(level) + if len(tonLevels) and min(tonLevels) not in self.reachedTonLevels[color]: + dyn = "" + if self.dynamic_ip: + dyn = "(dynamic)" + rem = ton["remaining"] + self.bot.send_message( + self.config["telegram_user_id"], f"Toner {color} of printer {self.serial_num} @ {self.ip} {dyn} is at {rem}%") + self.reachedTonLevels[color] = tonLevels + + alert_url = 'http://'+ip+'/sws/app/information/activealert/activealert.json' + r = requests.get(alert_url) + text = r.content.decode("utf8") + alert_data = demjson.decode(text) + self.uptime = alert_data["sysuptime"] + for alert in alert_data["recordData"]: + self.alerts.append( + Alert(alert["severity"], alert["code"], alert["desc"], alert["sysuptime"])) + return True + except requests.exceptions.HTTPError as errh: + print("Http Error:", errh) + except requests.exceptions.ConnectionError as errc: + print("Error Connecting:", errc) + except requests.exceptions.Timeout as errt: + print("Timeout Error:", errt) + except requests.exceptions.RequestException as err: + print("OOps: Something Else", err) + except SerialNumException: + print("Serial Num mismatch") + except demjson.JSONError: + print("JSON error") + return False + + def ip_scan_lan(self): + # Scan the local network for the printer + if self.dynamic_ip: + network = ipaddress.ip_network('192.168.1.0/24') + for ip in network: + # Ignore e.g. 192.168.1.0 and 192.168.1.255 + if ip == network.broadcast_address or ip == network.network_address: + continue + print(ip) + if self.__update_data(str(ip)): + print("OK") + self.ip = str(ip) + return True + return False + + def to_dict(self) -> dict: + # Convert the printer object to a dict + di = {} + di["ip"] = self.ip + di["serial_num"] = self.serial_num + di["dynamic_ip"] = self.dynamic_ip + di["model_name"] = self.model_name + di["uptime"] = self.uptime + di["reachedDrumLevels"] = self.reachedDrumLevels + di["reachedTrayLevels"] = self.reachedTrayLevels + di["reachedTonLevels"] = self.reachedTonLevels + di["toners"] = [] + di["drums"] = [] + di["trays"] = [] + di["alerts"] = [] + for toner in self.toners: + di["toners"].append(dict(toner.__dict__)) + for drum in self.drums: + di["drums"].append(dict(drum.__dict__)) + for tray in self.trays: + di["trays"].append(dict(tray.__dict__)) + for alert in self.alerts: + di["alerts"].append(dict(alert.__dict__)) + return di + + def update(self): + if not self.__update_data(str(self.ip)): + return self.ip_scan_lan() + else: + return True diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..74032f8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +telebot==0.0.5 +demjson==2.2.4 diff --git a/telegram.py b/telegram.py new file mode 100644 index 0000000..8cbffce --- /dev/null +++ b/telegram.py @@ -0,0 +1,49 @@ +import telebot +import json +# load config +with open("config.json", "r") as jsonconf: + conf = json.loads(jsonconf.read()) + TOKEN = conf["telegram_bot_token"] + USER_ID = conf["telegram_user_id"] + + +# printer data in understandable form +def readout(store_data: dict): + text = "" + for printer in store_data["data"]: + text += "Printer *"+printer["model_name"]+"* _" + \ + printer["serial_num"]+"_ @ "+printer["ip"] + if printer["dynamic_ip"]: + text += " (dynamic)" + text += ":\n\nTONERS\n" + for toner in printer["toners"]: + text += "*"+toner["color"]+"* "+str(toner["remaining_percent"])+"% ("+str( + toner["pages_total"])+" pages printed so far)\n" + text += "\nDRUMS\n" + for drum in printer["drums"]: + text += "*"+drum["color"]+"* "+str(drum["remaining_percent"])+"% \n" + text += "\nTRAYS\n" + for tray in printer["trays"]: + text += "*"+str(tray["tray_no"])+"* "+str(("???" if tray["paper_level"] + == 0 else tray["paper_level"]))+"/"+str(tray["capacity"])+"\n" + if len(printer["alerts"]): + text += "\nALERTS\n" + for alert in printer["alerts"]: + text += "*"+alert["code"]+"* "+alert["desc"]+"\n" + text+="\n\n\n" + text += "Last update: "+store_data["last_update"]+"\n" + return text +bot = telebot.TeleBot(TOKEN, parse_mode="markdown") + + +# just an echo bot +@bot.message_handler(func=lambda m: True) +def echo_all(message): + if message.chat.id != USER_ID: + return + with open("store.json", "r") as store_file: + store_data = json.loads(store_file.read()) + t = readout(store_data) + bot.reply_to(message, t) + +bot.polling() \ No newline at end of file diff --git a/toner.py b/toner.py new file mode 100644 index 0000000..4f16af0 --- /dev/null +++ b/toner.py @@ -0,0 +1,6 @@ +class Toner: + def __init__(self, color: str, pages_total: int, remaining_percent: int) -> None: + self.color = color + self.pages_total = pages_total + self.remaining_percent = remaining_percent + self.used_percent = 100 - remaining_percent \ No newline at end of file diff --git a/tray.py b/tray.py new file mode 100644 index 0000000..ced02a3 --- /dev/null +++ b/tray.py @@ -0,0 +1,5 @@ +class Tray: + def __init__(self, tray_no: int, capacity: int, paper_level: int) -> None: + self.tray_no = tray_no + self.capacity = capacity + self.paper_level = paper_level \ No newline at end of file