From 1768c5055e357ecee2fe9f91321995d7ff09f698 Mon Sep 17 00:00:00 2001 From: NaitLee Date: Wed, 13 Apr 2022 02:37:53 +0800 Subject: [PATCH] Take IPP support back, update doc, few other thing --- README.md | 25 ++++- TODO | 12 +- additional/__init__.py | 0 i18n.py => additional/i18n.py | 4 +- additional/ipp.py | 100 +++++++++++++++++ printer.py | 104 ++++++++++++------ .../README.de_DE.md | 21 +++- .../README.zh_CN.md | 30 +++-- server.py | 40 +++++-- 9 files changed, 273 insertions(+), 63 deletions(-) create mode 100644 additional/__init__.py rename i18n.py => additional/i18n.py (94%) create mode 100644 additional/ipp.py rename README.de_DE.md => readme.i18n/README.de_DE.md (87%) rename README.zh_CN.md => readme.i18n/README.zh_CN.md (77%) diff --git a/README.md b/README.md index ddbc9f6..0f93e1b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -English | [Deutsch](./README.de_DE.md) | [简体中文](./README.zh_CN.md) +English | [Deutsch](./readme.i18n/README.de_DE.md) | [简体中文](./readme.i18n/README.zh_CN.md) # Cat-Printer @@ -16,22 +16,37 @@ Currently: ## Features +*Currently it's in development. More will be here soon!* + +| Available | Partial | Planned | +|-----------------|-----------|---------------| +| Web Interface | CUPS/IPP* | Visual Editor | +| Print a Picture | | Help/Manual | +| Command-line | | Text Printing | + + +\* In development code. Will be released in a short period. + +*Along with...* + - Simple! - Operate via a Web UI just in browser, - or get the Android release! -- ~~Feature-rich~~ - - Currently it's in development. More will be there soon! + - Friendly! - Language support! You can participate in translation! - Good user interface, adaptive to PC/mobile and light/dark theme! + - Cross platform! - Newer Windows 10 and above - GNU/Linux - MacOS *(Needs testing)* - and a lot of extra efforts for Android! + - Free, as in [freedom](https://www.gnu.org/philosophy/free-sw.html)! - Unlike the "original" proprietary app, this project is for everyone that concerns *open-mind and freedom*! + - and Fun! - Do whatever you like! @@ -99,7 +114,7 @@ See file `COPYING`, `LICENSE`, and detail of used JavaScript in file `www/jslice ## Development -You may interested in language support, anyway. See the translation files in directory `www/lang`! +You may interested in language support, anyway. See the translation files in directory `www/lang` and `readme.i18n`! Note: you can correct some mistakes in them, if there are any. Also feel free to make it (truly) better! Also interested in code development? See [development.md](development.md)! @@ -111,5 +126,5 @@ Also interested in code development? See [development.md](development.md)! - [roddeh-i18n](https://github.com/roddeh/i18njs), good work! - [python-for-android](https://python-for-android.readthedocs.io/en/latest/), though there are some painful troubles - [AdvancedWebView](https://github.com/delight-im/Android-AdvancedWebView) for saving my life from Java -- Stack Overflow & the whole Internet, you let me know Android `Activity` all from empty +- Stack Overflow & the whole Internet, you let me know Android `Activity` all from beginning - ... Everyone is Awesome! diff --git a/TODO b/TODO index 8004093..6ef2e8b 100644 --- a/TODO +++ b/TODO @@ -1,8 +1,16 @@ +Note: not ordered. do whatever I/you want + ++ Check GB03, again + Hacky text printing, typewriter-like, with PF2 font -+ Better MTU/freq moderation + ok I won't forget frontend, but it's different + Better CLI, e.g. invoke imagemagick if input is not PBM -+ (Re-)support CUPS & IPP ++ Consider better MTU adaption ++ Consider better BLE traffic regulation, with BLE notification ++ Consider the printer 'text' mode, it can make things faster ++ Consider more control to something like 'energy' ++ Consider keeping server backend (more) secure to be used by IPP ++ Clean up CUPS/IPP code + Make a build guide for android: Summary the hacks to p4a, bleak p4a recipe, p4a webview bootstrap, and AdvancedWebView + More frontend usability, more functions diff --git a/additional/__init__.py b/additional/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/i18n.py b/additional/i18n.py similarity index 94% rename from i18n.py rename to additional/i18n.py index 67dc7d6..06a4938 100644 --- a/i18n.py +++ b/additional/i18n.py @@ -26,10 +26,12 @@ class I18n(): self.load_file(os.path.join(search_path, name)) def load_file(self, name): + 'Load an i18n json file' with open(name, 'r', encoding='utf-8') as file: self.load_data(file.read()) def load_data(self, raw_json): + 'Load i18n json data (from str)' data = json.loads(raw_json) for key in data['values']: self.data['values'][key] = data['values'][key] @@ -60,5 +62,5 @@ class I18n(): if string is None: string = data for j in i: - string = string.replace('%%{%s}' % j, i[j]) + string = string.replace(f'%%{j}', i[j]) return string diff --git a/additional/ipp.py b/additional/ipp.py new file mode 100644 index 0000000..aab8d51 --- /dev/null +++ b/additional/ipp.py @@ -0,0 +1,100 @@ +''' Provide *very* basic CUPS/IPP support + Extracted from version 0.0.2, do more cleaning later... +''' + +import platform +import subprocess + +class IPP(): + 'https://datatracker.ietf.org/doc/html/rfc8010' + server = None + printer = None + def __init__(self, server, printer): + self.server = server + self.printer = printer + async def handle_ipp(self, data): + 'Handle an IPP protocol request' + server = self.server + # len_data = len(data) + # ipp_version_number = data[0:2] + # ipp_operation_id = data[2:4] + # ipp_request_id = data[4:8] + ipp_operation_attributes_tag = data[8] + attributes = {} + data_to_print = b'' + # this is silly. i want to use io.BytesIO + if ipp_operation_attributes_tag == 0x01: + pointer = 9 + next_name_length_at = 10 + next_value_length_at = 10 + name = b'' + value = b'' + while data[pointer] != 0x03: + tag = data[pointer:pointer + 1] + pointer += 1 + if tag[0] < 0x10: # delimiter-tag + continue + next_name_length_at = pointer + data[pointer] * 0x0100 + data[pointer + 1] + 2 + pointer += 2 + while pointer < next_name_length_at: + name = name + data[pointer:pointer + 1] + pointer += 1 + next_value_length_at = pointer + data[pointer] * 0x0100 + data[pointer + 1] + 2 + pointer += 2 + while pointer < next_value_length_at: + value = value + data[pointer:pointer + 1] + pointer += 1 + attributes[name] = (tag, value) + name = b'' + value = b'' + pointer += 1 + data_to_print = data[pointer:] + # there are hard coded minimal response. this "just works" on cups + if data_to_print == b'': + try: + server.send_response(200) + server.send_header('Content-Type', 'application/ipp') + server.end_headers() + server.wfile.write( + b'\x01\x01\x00\x00\x00\x00\x00\x01\x01\x03' + ) + except BrokenPipeError: + pass + return + platform_system = platform.system() + # https://ghostscript.com/doc/9.54.0/Use.htm#Output_device + if platform_system == 'Windows': + gsexe = 'gswin32c.exe' + elif platform_system == 'OS/2': + gsexe = 'gsos2' + else: + gsexe = 'gs' + gsproc = subprocess.Popen([ + gsexe, + '-q', '-sDEVICE=pbmraw', '-dNOPAUSE', '-dBATCH', '-dSAFER', + '-dFIXEDMEDIA', '-g384x543', '-r46.4441219158x46.4441219158', + '-dFitPage', '-dFitPage', + '-sOutputFile=-', '-' + ], executable=gsexe, stdin=subprocess.PIPE, stdout=subprocess.PIPE) + pbm_data, _ = gsproc.communicate(data_to_print) + try: + if gsproc.wait() == 0: + info = server.path[1:] + is_found = await server.printer.filter_device(info, server.settings.scan_time) + if not is_found: + ... # TODO: Make IPP can report some errors + raise Exception(f'No printer found with info: {info}') + await server.printer.print_data(pbm_data) + else: + raise Exception('Error on invoking Ghostscript') + server.send_response(200) + server.send_header('Content-Type', 'application/ipp') + server.end_headers() + server.wfile.write( + b'\x01\x01\x00\x00\x00\x00\x00\x01\x01\x03' + ) + except Exception as _: + server.send_response(500) + server.send_header('Content-Type', 'application/ipp') + server.end_headers() + server.wfile.write(b'') diff --git a/printer.py b/printer.py index e223a5f..6d04503 100644 --- a/printer.py +++ b/printer.py @@ -9,11 +9,14 @@ from bleak import BleakClient, BleakScanner from bleak.exc import BleakError, BleakDBusError try: - from i18n import I18n + from additional.i18n import I18n except ImportError: class I18n(): + 'Dummy i18n in case "full" version is missing' + def __init__(self, _search_path=None, _lang=None, _fallback=None): pass + def __getitem__(self, keys): if not isinstance(keys, tuple): keys = (keys, ) @@ -21,9 +24,11 @@ except ImportError: i18n = I18n('www/lang') + class PrinterError(Exception): 'Error of Printer driver' + models = ('GT01', 'GB01', 'GB02', 'GB03') crc8_table = [ @@ -75,7 +80,10 @@ def reverse_binary(value: int): return int(f"{bin(value)[2:]:0>8}"[::-1], 2) -def make_command(command: int, payload: Union[bytes, bytearray], *, prefix: List[int]=None) -> bytearray: +def make_command( + command: int, payload: Union[bytes, bytearray], *, + prefix: List[int] = None +) -> bytearray: 'Make a `bytearray` with command data, which can be sent to printer directly to operate' if len(payload) > 0x100: raise Exception('Too large payload') @@ -164,40 +172,48 @@ class PrinterDriver(): def _read_pbm(self, path: str = None, data: bytes = None): if path is not None and path != '-': file = open(path, 'rb') - elif data is not None: - file = io.BytesIO(data) - else: - file = sys.stdin.buffer - signature = file.readline() - if signature != b'P4\n': - raise Exception('Specified file is not a PBM image') - width, height = self.standard_width, 0 - while True: - # There can be comments. Skip them - line = file.readline()[0:-1] - if line[0:1] != b'#': - break - width, height = [int(x) for x in line.split(b' ')[0:2]] - if width != self.standard_width: - raise Exception('PBM image width is not 384px') - expected_data_size = self.pbm_data_per_line * height - data = file.read() - if path is not None and path != '-': + data = file.read() file.close() - data_size = len(data) - if data_size != expected_data_size: - raise Exception('Broken PBM file data') - if self.dry_run: - # Dry run: put blank data - data = b'\x00' * expected_data_size - return PBMData(width, height, data) + elif data is not None: + pass + else: + data = sys.stdin.buffer.read() + if data[0:3] != b'P4\n': + raise Exception('Specified file is not a PBM image') + # there can be several "chunks", by e.g. cat-ing several files, or ghostscript output + chunks = data.split(b'P4\n')[1:] + result = b'' + total_height = 0 + for chunk in chunks: + page = io.BytesIO(chunk) + while True: + # There can be comments. Skip them + line = page.readline()[0:-1] + if line[0:1] != b'#': + break + width, height = [int(x) for x in line.split(b' ')[0:2]] + if width != self.standard_width: + raise Exception('PBM image width is not 384px') + total_height += height + expected_data_size = self.pbm_data_per_line * height + raw_data = page.read() + data_size = len(raw_data) + if data_size != expected_data_size: + raise Exception('Broken PBM file data') + if self.dry_run: + # Dry run: put blank data + result += b'\x00' * expected_data_size + else: + result += raw_data + return PBMData(self.standard_width, total_height, result) def _pbm_data_to_raw(self, data: PBMData): buffer = bytearray() # new/old print command if self.name == 'GB03': buffer.append(0x12) - buffer += bytearray([0x51, 0x78, 0xa3, 0x00, 0x01, 0x00, 0x00, 0x00, 0xff]) + buffer += bytearray([0x51, 0x78, 0xa3, 0x00, + 0x01, 0x00, 0x00, 0x00, 0xff]) for key in data.args: buffer += make_command(key, data.args[key]) buffer += make_command( @@ -236,7 +252,7 @@ class PrinterDriver(): ) return buffer - async def send_buffer(self, buffer: bytearray, address: str=None): + async def send_buffer(self, buffer: bytearray, address: str = None): 'Send manipulation data (buffer) to the printer via bluetooth' address = address or self.address client = BleakClient(address, timeout=5.0) @@ -267,6 +283,7 @@ class PrinterDriver(): if device.name in models: result.append(device) return result + async def search_printer(self, timeout: int): 'Search for a printer, returns `None` if not found' timeout = timeout or 3 @@ -275,20 +292,40 @@ class PrinterDriver(): return devices[0] return None - async def print_file(self, path: str, address: str=None): + async def print_file(self, path: str, address: str = None): 'Method to print the specified PBM image at `path` with printer at specified MAC `address`' address = address or self.address pbm_data = self._read_pbm(path) buffer = self._pbm_data_to_raw(pbm_data) await self.send_buffer(buffer, address) - async def print_data(self, data: bytes, address: str=None): + async def print_data(self, data: bytes, address: str = None): 'Method to print the specified PBM image `data` with printer at specified MAC `address`' address = address or self.address pbm_data = self._read_pbm(None, data) buffer = self._pbm_data_to_raw(pbm_data) await self.send_buffer(buffer, address) + async def filter_device(self, info: str, timeout: float = 5.0) -> bool: + 'Find a suitable device with `info`: Bluetooth name or MAC address, or empty string' + devices = await self.search_all_printers(timeout) + if len(devices) == 0: + return False + if info in models: + for device in devices: + if device.name == info: + self.name, self.address = device.name, device.address + break + elif info[2::3] == ':::::': + for device in devices: + if device.address.lower() == info.lower(): + self.name, self.address = device.name, device.address + break + else: + device = devices[0] + self.name, self.address = device.name, device.address + return True + async def _main(): 'Main routine for direct command line execution' @@ -335,6 +372,7 @@ async def _main(): await printer.print_file(cmdargs.file) print(i18n['finished']) + async def main(): 'Run the `_main` routine while catching exceptions' try: @@ -344,7 +382,7 @@ async def main(): if ( 'not turned on' in error_message or (isinstance(e, BleakDBusError) and - getattr(e, 'dbus_error') == 'org.bluez.Error.NotReady') + getattr(e, 'dbus_error') == 'org.bluez.Error.NotReady') ): print(i18n['please-enable-bluetooth']) sys.exit(1) diff --git a/README.de_DE.md b/readme.i18n/README.de_DE.md similarity index 87% rename from README.de_DE.md rename to readme.i18n/README.de_DE.md index 0b5aa29..598cce2 100644 --- a/README.de_DE.md +++ b/readme.i18n/README.de_DE.md @@ -15,21 +15,36 @@ Gegenwärtig: ## Funktionen +*Derzeit befindet sich die Software im Alpha-Stadium. Mehr wird es bald geben!* + +| Available | Partial | Planned | +|-----------------|-----------|---------------| +| Web Interface | CUPS/IPP* | Visual Editor | +| Print a Picture | | Help/Manual | +| Command-line | | Text Printing | + + +\* In development code. Will be released in a short period. + +*Along with…* + - Simple! - Bedienung über eine Web-UI direkt im Browser, - oder besorgen Sie sich die Android-Version! -- ~~Umfangreiche Funktionen~~ - - Derzeit befindet sich die Software im Alpha-Stadium. Mehr wird es bald geben! + - Friendly! - Sprachunterstützung! Sie können sich an der Übersetzung beteiligen! - Gute Benutzeroberfläche, mit PC-/Mobil-/Licht-/Dunkelmodus-Varianten! (Systemkonfiguration adaptiv) + - Plattformübergreifend! - Neuere Windows 10 und darüber - GNU/Linux - MacOS *(muss getestet werden* - und eine Menge zusätzlicher Anstrengungen für Android! + - Frei, wie in [freedom](https://www.gnu.org/philosophy/free-sw.html)! - Anders als die "offizielle" proprietäre App, ist dieses Projekt für alle, denen *offener Geist und Freiheit* wichtig sind! + - und Fun! - Mach, was du willst! @@ -92,7 +107,7 @@ Siehe Datei `COPYING`, `LICENSE` und Details zum verwendeten JavaScript in der D ## Development -Vielleicht sind Sie ohnehin an der Sprachunterstützung interessiert. Siehe die Übersetzungsdateien im Verzeichnis `www/lang`! +Vielleicht sind Sie ohnehin an der Sprachunterstützung interessiert. Siehe die Übersetzungsdateien im Verzeichnis `www/lang` und `readme.i18n`! Interessieren Sie sich auch für Code-Entwicklung? Siehe [development.md](development.md)! diff --git a/README.zh_CN.md b/readme.i18n/README.zh_CN.md similarity index 77% rename from README.zh_CN.md rename to readme.i18n/README.zh_CN.md index 397ae2d..5321a98 100644 --- a/README.zh_CN.md +++ b/readme.i18n/README.zh_CN.md @@ -15,21 +15,36 @@ ## 特性 +*当前仍在继续开发。以后会有更多!* + +| 可用 | 部分 | 计划 | +|-----------|-----------|---------------| +| 网页界面 | CUPS/IPP* | 可视化编辑器 | +| 打印图片 | | 帮助/文档 | +| 命令行 | | 文本打印 | + + +\* 存在于开发代码中。将在短时间内发布。 + +*当然还有……* + - 简易! - 在网页界面进行操作, - 或者获取安卓应用! -- ~~功能丰富~~ - - 当前仍在继续开发。以后会有更多! + - 友好! - 语言支持!您可参与翻译! - 良好的用户界面,可适应桌面/移动端/明暗主题! + - 跨平台! - 较新的 Windows 10 及以上 - GNU/Linux - MacOS *(需要测试)* - 在安卓上也花了些功夫呢! + - 是[自由软件](https://www.gnu.org/philosophy/free-sw.html)! - 不像“原版”专有应用,此作品为在乎*开放思想与计算自由*的人而生! + - 有意思! - 做什么都可以! @@ -97,19 +112,20 @@ Copyright © 2022 NaitLee Soft. 保留一些权利。 ## 开发 -您可能对翻译工作感兴趣。可于目录 `www/lang` 中查看翻译文件! +您可能对翻译工作感兴趣。可于目录 `www/lang` 和 `readme.i18n` 中查看翻译文件! + 注: 1. 通常英语与简体中文同时更新。请考虑其他,如繁体中文(需注意在繁体中与简体的用字、技术术语差别)。 -2. 如果您有(真的)能力,您也可以纠正/改善某些翻译! +2. 如果(真的)有能力,您也可以纠正/改善某些翻译! 还想写代码?看看 [development.md](development.md)!(英文) ### 鸣谢 - 当然不能没有 Python 和 Web 技术! -- [Bleak](https://bleak.readthedocs.io/en/latest/) 蓝牙低功耗库,牛! -- [roddeh-i18n](https://github.com/roddeh/i18njs),好活! +- [Bleak](https://bleak.readthedocs.io/en/latest/) 跨平台蓝牙低功耗库,牛! +- [roddeh-i18n](https://github.com/roddeh/i18njs),很好! - [python-for-android](https://python-for-android.readthedocs.io/en/latest/),虽然有些麻烦的地方 - [AdvancedWebView](https://github.com/delight-im/Android-AdvancedWebView) 从 Java 拯救了我的生命 -- Stack Overflow 和互联网,你们让我无中生有地了解了安卓“活动” `Activity` +- Stack Overflow 和整个互联网,你们让我从零开始了解了安卓“活动” `Activity` - ……每个人都是好样的! diff --git a/server.py b/server.py index 2da760e..8ae2113 100644 --- a/server.py +++ b/server.py @@ -34,9 +34,6 @@ class PrinterServerError(Exception): if len_args > 1: self.details = args[1] -Printer = PrinterDriver() -server = None - def log(message): 'For logging a message' print(message) @@ -66,6 +63,8 @@ class PrinterServer(BaseHTTPRequestHandler): 'frequency': 0.8, 'dry_run': False }) + printer = PrinterDriver() + ipp = None def log_request(self, _code=200, _size=0): pass def log_error(self, *_args): @@ -126,22 +125,23 @@ class PrinterServer(BaseHTTPRequestHandler): 'Save config file' with open(self.settings.config_path, 'w', encoding='utf-8') as file: json.dump(self.settings, file, indent=4) + def update_printer(self): + 'Update `PrinterDriver` state/config' + self.printer.dry_run = self.settings.dry_run + self.printer.frequency = float(self.settings.frequency) + if self.settings.printer is not None: + self.printer.name, self.printer.address = self.settings.printer.split(',') def handle_api(self): 'Handle API request from POST' content_length = int(self.headers.get('Content-Length')) body = self.rfile.read(content_length) api = self.path[1:] if api == 'print': - if self.settings.printer is None: - # usually can't encounter, though - raise PrinterServerError('No printer address specified') - Printer.dry_run = self.settings.dry_run - Printer.frequency = float(self.settings.frequency) - Printer.name, Printer.address = self.settings.printer.split(',') + self.update_printer() loop = asyncio.new_event_loop() try: devices = loop.run_until_complete( - Printer.print_data(body) + self.printer.print_data(body) ) self.api_success() finally: @@ -152,7 +152,7 @@ class PrinterServer(BaseHTTPRequestHandler): loop = asyncio.new_event_loop() try: devices = loop.run_until_complete( - Printer.search_all_printers(float(self.settings.scan_time)) + self.printer.search_all_printers(float(self.settings.scan_time)) ) finally: loop.close() @@ -196,6 +196,23 @@ class PrinterServer(BaseHTTPRequestHandler): self.send_header('Content-Type', mime('txt')) self.end_headers() return + if self.headers.get('Content-Type') == 'application/ipp': + if self.ipp is None: + try: + from additional.ipp import IPP + self.load_config() + except ImportError: + # TODO: Better response? + return + self.ipp = IPP(self, self.printer) + self.update_printer() + body = self.rfile.read(content_length) + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(self.ipp.handle_ipp(body)) + finally: + loop.close() + return try: self.handle_api() return @@ -231,7 +248,6 @@ def serve(): if '-a' in sys.argv: print('Will listen on ALL addresses') listen_all = True - global server # Again, Don't use ThreadingHTTPServer if you're going to use pyjnius! server = HTTPServer(('' if listen_all else address, port), PrinterServer) service_url = f'http://{address}:{port}/'