Initial commit
This commit is contained in:
		
						commit
						735e5de328
					
				
					 25 changed files with 894 additions and 0 deletions
				
			
		
							
								
								
									
										9
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,9 @@
 | 
				
			||||||
 | 
					venv
 | 
				
			||||||
 | 
					.lingma
 | 
				
			||||||
 | 
					dist/
 | 
				
			||||||
 | 
					*.egg-info/
 | 
				
			||||||
 | 
					build/
 | 
				
			||||||
 | 
					__pycache__/
 | 
				
			||||||
 | 
					*.pyc
 | 
				
			||||||
 | 
					.env
 | 
				
			||||||
 | 
					.DS_Store
 | 
				
			||||||
							
								
								
									
										8
									
								
								.idea/.gitignore
									
										
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								.idea/.gitignore
									
										
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,8 @@
 | 
				
			||||||
 | 
					# Default ignored files
 | 
				
			||||||
 | 
					/shelf/
 | 
				
			||||||
 | 
					/workspace.xml
 | 
				
			||||||
 | 
					# Editor-based HTTP Client requests
 | 
				
			||||||
 | 
					/httpRequests/
 | 
				
			||||||
 | 
					# Datasource local storage ignored files
 | 
				
			||||||
 | 
					/dataSources/
 | 
				
			||||||
 | 
					/dataSources.local.xml
 | 
				
			||||||
							
								
								
									
										12
									
								
								.idea/gicisky.iml
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								.idea/gicisky.iml
									
										
									
										generated
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,12 @@
 | 
				
			||||||
 | 
					<?xml version="1.0" encoding="UTF-8"?>
 | 
				
			||||||
 | 
					<module type="PYTHON_MODULE" version="4">
 | 
				
			||||||
 | 
					  <component name="NewModuleRootManager">
 | 
				
			||||||
 | 
					    <content url="file://$MODULE_DIR$" />
 | 
				
			||||||
 | 
					    <orderEntry type="jdk" jdkName="Python 3.10 (gicisky)" jdkType="Python SDK" />
 | 
				
			||||||
 | 
					    <orderEntry type="sourceFolder" forTests="false" />
 | 
				
			||||||
 | 
					  </component>
 | 
				
			||||||
 | 
					  <component name="PyDocumentationSettings">
 | 
				
			||||||
 | 
					    <option name="format" value="PLAIN" />
 | 
				
			||||||
 | 
					    <option name="myDocStringFormat" value="Plain" />
 | 
				
			||||||
 | 
					  </component>
 | 
				
			||||||
 | 
					</module>
 | 
				
			||||||
							
								
								
									
										16
									
								
								.idea/inspectionProfiles/Project_Default.xml
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								.idea/inspectionProfiles/Project_Default.xml
									
										
									
										generated
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,16 @@
 | 
				
			||||||
 | 
					<component name="InspectionProjectProfileManager">
 | 
				
			||||||
 | 
					  <profile version="1.0">
 | 
				
			||||||
 | 
					    <option name="myName" value="Project Default" />
 | 
				
			||||||
 | 
					    <inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
 | 
				
			||||||
 | 
					      <option name="ignoredPackages">
 | 
				
			||||||
 | 
					        <value>
 | 
				
			||||||
 | 
					          <list size="3">
 | 
				
			||||||
 | 
					            <item index="0" class="java.lang.String" itemvalue="bleak" />
 | 
				
			||||||
 | 
					            <item index="1" class="java.lang.String" itemvalue="opencv-python" />
 | 
				
			||||||
 | 
					            <item index="2" class="java.lang.String" itemvalue="numpy" />
 | 
				
			||||||
 | 
					          </list>
 | 
				
			||||||
 | 
					        </value>
 | 
				
			||||||
 | 
					      </option>
 | 
				
			||||||
 | 
					    </inspection_tool>
 | 
				
			||||||
 | 
					  </profile>
 | 
				
			||||||
 | 
					</component>
 | 
				
			||||||
							
								
								
									
										6
									
								
								.idea/inspectionProfiles/profiles_settings.xml
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.idea/inspectionProfiles/profiles_settings.xml
									
										
									
										generated
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,6 @@
 | 
				
			||||||
 | 
					<component name="InspectionProjectProfileManager">
 | 
				
			||||||
 | 
					  <settings>
 | 
				
			||||||
 | 
					    <option name="USE_PROJECT_PROFILE" value="false" />
 | 
				
			||||||
 | 
					    <version value="1.0" />
 | 
				
			||||||
 | 
					  </settings>
 | 
				
			||||||
 | 
					</component>
 | 
				
			||||||
							
								
								
									
										10
									
								
								.idea/misc.xml
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								.idea/misc.xml
									
										
									
										generated
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,10 @@
 | 
				
			||||||
 | 
					<?xml version="1.0" encoding="UTF-8"?>
 | 
				
			||||||
 | 
					<project version="4">
 | 
				
			||||||
 | 
					  <component name="Black">
 | 
				
			||||||
 | 
					    <option name="sdkName" value="Python 3.11" />
 | 
				
			||||||
 | 
					  </component>
 | 
				
			||||||
 | 
					  <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (gicisky)" project-jdk-type="Python SDK" />
 | 
				
			||||||
 | 
					  <component name="PythonCompatibilityInspectionAdvertiser">
 | 
				
			||||||
 | 
					    <option name="version" value="3" />
 | 
				
			||||||
 | 
					  </component>
 | 
				
			||||||
 | 
					</project>
 | 
				
			||||||
							
								
								
									
										8
									
								
								.idea/modules.xml
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								.idea/modules.xml
									
										
									
										generated
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,8 @@
 | 
				
			||||||
 | 
					<?xml version="1.0" encoding="UTF-8"?>
 | 
				
			||||||
 | 
					<project version="4">
 | 
				
			||||||
 | 
					  <component name="ProjectModuleManager">
 | 
				
			||||||
 | 
					    <modules>
 | 
				
			||||||
 | 
					      <module fileurl="file://$PROJECT_DIR$/.idea/gicisky.iml" filepath="$PROJECT_DIR$/.idea/gicisky.iml" />
 | 
				
			||||||
 | 
					    </modules>
 | 
				
			||||||
 | 
					  </component>
 | 
				
			||||||
 | 
					</project>
 | 
				
			||||||
							
								
								
									
										21
									
								
								LICENSE
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								LICENSE
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,21 @@
 | 
				
			||||||
 | 
					MIT License
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Copyright (c) 2025 MassiveBox
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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.
 | 
				
			||||||
							
								
								
									
										3
									
								
								MANIFEST.in
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								MANIFEST.in
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,3 @@
 | 
				
			||||||
 | 
					include requirements.txt
 | 
				
			||||||
 | 
					include README.md
 | 
				
			||||||
 | 
					include LICENSE
 | 
				
			||||||
							
								
								
									
										82
									
								
								README.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								README.md
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,82 @@
 | 
				
			||||||
 | 
					# 🏷 Gicisky Python Library
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					A Python library for interacting with Gicisky electronic ink display tags via Bluetooth Low Energy (BLE).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## ℹ️ Features
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Advertisement parsing**: Discover compatible devices and get their info (battery level, model, hardware and software
 | 
				
			||||||
 | 
					version)
 | 
				
			||||||
 | 
					- **Image uploading**: Upload images to your ESL, with all the features it supports (including third color and compression!)
 | 
				
			||||||
 | 
					- **Bluetooth library independent**: Use the provided [Bleak backend](ble/bleak.py) or implement the [BLE Interface](ble/interface.py)
 | 
				
			||||||
 | 
					to use any other Bluetooth library.
 | 
				
			||||||
 | 
					- **Image conversion**: Provide any image and let the library convert it for you to the device's format.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## 📱 Supported Devices
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- EPD 2.1" BWR (0x0B)
 | 
				
			||||||
 | 
					- EPD 2.9" BWR (0x33)
 | 
				
			||||||
 | 
					- EPD 4.2" BWR (0x4B)
 | 
				
			||||||
 | 
					- EPD 7.5" BWR (0x2B)
 | 
				
			||||||
 | 
					- TFT 2.1" BW (0xA0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## ⬇️ Installation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					pip install gicisky
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Or install from source:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					git clone https://git.massive.box/massivebox/gicisky
 | 
				
			||||||
 | 
					cd gicisky
 | 
				
			||||||
 | 
					pip install -r requirements.txt
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## ▶️ Quick Start
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Check out the [examples](examples)!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					python3 examples/send_bleak.py ~/path/to/image.png
 | 
				
			||||||
 | 
					python3 examples/send_bleak.py --no-optimize ~/path/to/image.png # Without dithering
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## ⚙️ Components
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. **BLE Interface** ([ble/](file:///home/massive/Dev/gicisky/ble/)): Handles Bluetooth Low Energy communication
 | 
				
			||||||
 | 
					2. **Core Protocol** ([core/](file:///home/massive/Dev/gicisky/core/)): Implements the Gicisky communication protocol, independent of the Bluetooth library
 | 
				
			||||||
 | 
					3. **Image Processing** ([image/](file:///home/massive/Dev/gicisky/image/)): `conversion` formats images to the Gicisky format, `optimizer` (optional) uses
 | 
				
			||||||
 | 
					dithering and letterboxing for better results
 | 
				
			||||||
 | 
					4. **Logging** ([logger/](file:///home/massive/Dev/gicisky/logger/)): Provides detailed logging capabilities
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## 🧱 Requirements
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Python 3.7+
 | 
				
			||||||
 | 
					- bleak (for BLE communication)
 | 
				
			||||||
 | 
					- PIL/Pillow (for image processing)
 | 
				
			||||||
 | 
					- numpy (for image manipulation)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Install all requirements:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					pip install -r requirements.txt
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## ❤️ Credits
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- For much of the original work on the protocol: [atc1441/ATC_GICISKY_ESL](https://github.com/atc1441/ATC_GICISKY_ESL)
 | 
				
			||||||
 | 
					- For most of the advertisement parsing logic: [eigger/hass-gicisky](https://github.com/eigger/hass-gicisky)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## 🤝 Support the Project
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Thanks for your interest in supporting the project.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Help me by opening issues and creating pull requests: all contributions are welcome!
 | 
				
			||||||
 | 
					- If you want to contribute financially, take a look [here](https://s.massive.box/donate). Thanks a lot!
 | 
				
			||||||
 | 
					- If you haven't bought your Gigisky ESL yet, please buy it through my [AliExpress affiliate link](https://s.click.aliexpress.com/e/_c2IOUkF5).
 | 
				
			||||||
 | 
					I will earn a small commission from your order, but it will not cost you anything. Thanks!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## 📚 License
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					MIT License
 | 
				
			||||||
							
								
								
									
										7
									
								
								__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								__init__.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,7 @@
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					Gicisky - A Python library for interacting with Gicisky electronic ink display tags.
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					__version__ = "0.1.0"
 | 
				
			||||||
 | 
					__author__ = "Your Name"
 | 
				
			||||||
 | 
					__email__ = "your.email@example.com"
 | 
				
			||||||
							
								
								
									
										0
									
								
								ble/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								ble/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										89
									
								
								ble/bleak.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								ble/bleak.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,89 @@
 | 
				
			||||||
 | 
					import asyncio
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from bleak import BleakClient, BleakGATTCharacteristic, BleakScanner
 | 
				
			||||||
 | 
					from typing import Callable, Awaitable, Optional, Dict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from core.protocol import SERVICE_UUID, CHAR_CMD_UUID, CHAR_IMG_UUID
 | 
				
			||||||
 | 
					from logger.logger import GiciskyLogger, LogCategory
 | 
				
			||||||
 | 
					from .interface import BLEInterface, Advertisement
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class BleakBackend(BLEInterface):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Bleak-based implementation of the BLEInterface abstraction.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    This backend handles BLE communication using the Bleak library but exposes
 | 
				
			||||||
 | 
					    a backend-agnostic interface so users can swap in another library (e.g.,
 | 
				
			||||||
 | 
					    BlueZ D-Bus or a custom GATT client) without modifying higher-level logic.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, logger: Optional[logging.Logger] = None):
 | 
				
			||||||
 | 
					        self.client: Optional[BleakClient] = None
 | 
				
			||||||
 | 
					        self.on_notify: Optional[Callable[[int, bytes], Awaitable[None]]] = None
 | 
				
			||||||
 | 
					        self.logger = GiciskyLogger(logger)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async def connect(self, mac: str):
 | 
				
			||||||
 | 
					        """Connect to a BLE device by MAC address."""
 | 
				
			||||||
 | 
					        self.client = BleakClient(mac)
 | 
				
			||||||
 | 
					        await self.client.connect()
 | 
				
			||||||
 | 
					        if not self.client.is_connected:
 | 
				
			||||||
 | 
					            self.logger.error(f"Failed to connect to {mac}", LogCategory.CONNECTION)
 | 
				
			||||||
 | 
					            raise ConnectionError(f"Failed to connect to {mac}")
 | 
				
			||||||
 | 
					        self.logger.info(f"Connected to {mac}", LogCategory.CONNECTION)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async def write(self, characteristic: str, data: bytes, response: bool = False):
 | 
				
			||||||
 | 
					        """Write data to a characteristic."""
 | 
				
			||||||
 | 
					        if not self.client:
 | 
				
			||||||
 | 
					            self.logger.error("Not connected to a device", LogCategory.CONNECTION)
 | 
				
			||||||
 | 
					            raise RuntimeError("Not connected to a device")
 | 
				
			||||||
 | 
					        await self.client.write_gatt_char(characteristic, data, response=response)
 | 
				
			||||||
 | 
					        self.logger.debug(f"Wrote {len(data)} bytes to {characteristic}", LogCategory.DATA_TRANSFER)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async def start_notify(self, characteristic: str, callback: Callable[[bytes], Awaitable[None]]):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Start receiving notifications from a specific characteristic.
 | 
				
			||||||
 | 
					        The callback must be an async function taking (handle, data).
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if not self.client:
 | 
				
			||||||
 | 
					            self.logger.error("Not connected to a device", LogCategory.CONNECTION)
 | 
				
			||||||
 | 
					            raise RuntimeError("Not connected to a device")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        async def handler(sender: BleakGATTCharacteristic, data: bytearray):
 | 
				
			||||||
 | 
					            # Wrap bleak's callback in async-safe call
 | 
				
			||||||
 | 
					            self.logger.debug(f"Received notification from {characteristic}", LogCategory.NOTIFICATION)
 | 
				
			||||||
 | 
					            if asyncio.iscoroutinefunction(callback):
 | 
				
			||||||
 | 
					                await callback(bytes(data))
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                callback(bytes(data))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await self.client.start_notify(characteristic, handler)
 | 
				
			||||||
 | 
					        self.logger.info(f"Notifications started on {characteristic}", LogCategory.CONNECTION)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async def disconnect(self):
 | 
				
			||||||
 | 
					        """Disconnect safely from the device."""
 | 
				
			||||||
 | 
					        if self.client:
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                await self.client.disconnect()
 | 
				
			||||||
 | 
					                self.logger.info("Disconnected successfully", LogCategory.CONNECTION)
 | 
				
			||||||
 | 
					            except Exception as e:
 | 
				
			||||||
 | 
					                if e is EOFError:
 | 
				
			||||||
 | 
					                    self.logger.info("Disconnected successfully (EOF)", LogCategory.CONNECTION)
 | 
				
			||||||
 | 
					                else:
 | 
				
			||||||
 | 
					                    self.logger.error(f"Disconnect error: {e}", LogCategory.CONNECTION)
 | 
				
			||||||
 | 
					            finally:
 | 
				
			||||||
 | 
					                self.client = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async def scan_devices(self, mac: str = None) -> Dict[str, Advertisement]:
 | 
				
			||||||
 | 
					        ret = {}
 | 
				
			||||||
 | 
					        self.logger.info("Starting scan for devices...", LogCategory.CONNECTION)
 | 
				
			||||||
 | 
					        discovered = await BleakScanner.discover(return_adv=True)
 | 
				
			||||||
 | 
					        for device, adv in discovered.values():
 | 
				
			||||||
 | 
					            if (mac is None or mac == device.address) and SERVICE_UUID in adv.service_uuids:
 | 
				
			||||||
 | 
					                ret[device.address] = Advertisement(
 | 
				
			||||||
 | 
					                    name=device.name,
 | 
				
			||||||
 | 
					                    service_uuids=adv.service_uuids,
 | 
				
			||||||
 | 
					                    manufacturer_data=adv.manufacturer_data,
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					        self.logger.info(f"Found {len(discovered)} devices, {len(ret)} of which are compatible", LogCategory.CONNECTION)
 | 
				
			||||||
 | 
					        return ret
 | 
				
			||||||
							
								
								
									
										45
									
								
								ble/interface.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								ble/interface.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,45 @@
 | 
				
			||||||
 | 
					import dataclasses
 | 
				
			||||||
 | 
					from abc import ABC, abstractmethod
 | 
				
			||||||
 | 
					from typing import Callable, Awaitable, TypedDict, Dict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@dataclasses.dataclass
 | 
				
			||||||
 | 
					class Advertisement:
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    BLE advertisement data.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    name: str
 | 
				
			||||||
 | 
					    manufacturer_data: {int: bytes}
 | 
				
			||||||
 | 
					    service_uuids: list[str]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class BLEInterface(ABC):
 | 
				
			||||||
 | 
					    @abstractmethod
 | 
				
			||||||
 | 
					    async def connect(self, mac: str):
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @abstractmethod
 | 
				
			||||||
 | 
					    async def write(self, characteristic: str, data: bytes, response: bool = False):
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @abstractmethod
 | 
				
			||||||
 | 
					    async def start_notify(self, characteristic: str, callback: Callable[[bytes], Awaitable[None]]):
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @abstractmethod
 | 
				
			||||||
 | 
					    async def disconnect(self):
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @abstractmethod
 | 
				
			||||||
 | 
					    async def scan_devices(self, mac: str = None) -> Dict[str, Advertisement]:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Scan for compatible devices and get their BLE advertisement data. A device is considered compatible when it
 | 
				
			||||||
 | 
					        advertises the service UUID specified in core.protocol.SERVICE_UUID.
 | 
				
			||||||
 | 
					        This function uses BLE advertisements, which are rarely available when the device is connected!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Args:
 | 
				
			||||||
 | 
					            mac: Optional MAC address to filter results
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Returns:
 | 
				
			||||||
 | 
					            A dictionary with MAC address as key, advertisement data as value. Only compatible devices matching the
 | 
				
			||||||
 | 
					            MAC (if specified) are returned.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
							
								
								
									
										0
									
								
								core/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								core/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										33
									
								
								core/advertisement.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								core/advertisement.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,33 @@
 | 
				
			||||||
 | 
					import dataclasses
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from ble.interface import Advertisement
 | 
				
			||||||
 | 
					from image.conversion import DEVICE_SPECS, DeviceSpec, ModelId
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@dataclasses.dataclass
 | 
				
			||||||
 | 
					class DeviceData:
 | 
				
			||||||
 | 
					    name: str
 | 
				
			||||||
 | 
					    model: ModelId
 | 
				
			||||||
 | 
					    firmware: int
 | 
				
			||||||
 | 
					    hardware: int
 | 
				
			||||||
 | 
					    battery: float
 | 
				
			||||||
 | 
					    voltage: float
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def parse_advertisement(adv: Advertisement) -> DeviceData:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    data = adv.manufacturer_data[0x5053]
 | 
				
			||||||
 | 
					    device_id = data[0]
 | 
				
			||||||
 | 
					    volt = data[1] / 10
 | 
				
			||||||
 | 
					    firmware = (data[2] << 8) + data[3]
 | 
				
			||||||
 | 
					    hardware = (data[0] << 8) + data[4]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    device = DEVICE_SPECS[device_id]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return DeviceData(
 | 
				
			||||||
 | 
					        name=adv.name,
 | 
				
			||||||
 | 
					        model=device_id,
 | 
				
			||||||
 | 
					        firmware=firmware,
 | 
				
			||||||
 | 
					        hardware=hardware,
 | 
				
			||||||
 | 
					        battery=(volt - device.min_voltage) * 100 / (device.max_voltage - device.min_voltage),
 | 
				
			||||||
 | 
					        voltage=volt
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
							
								
								
									
										86
									
								
								core/protocol.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								core/protocol.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,86 @@
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					import sys, asyncio, struct, time
 | 
				
			||||||
 | 
					from typing import Optional
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from ble.interface import BLEInterface, Advertisement
 | 
				
			||||||
 | 
					from logger.logger import GiciskyLogger, LogCategory
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Default UUIDs for Gicisky tags
 | 
				
			||||||
 | 
					BASE_SERVICE = 0xFEF0
 | 
				
			||||||
 | 
					SERVICE_UUID = f"0000{BASE_SERVICE:04x}-0000-1000-8000-00805f9b34fb"
 | 
				
			||||||
 | 
					CHAR_CMD_UUID = f"0000{BASE_SERVICE+1:04x}-0000-1000-8000-00805f9b34fb"
 | 
				
			||||||
 | 
					CHAR_IMG_UUID = f"0000{BASE_SERVICE+2:04x}-0000-1000-8000-00805f9b34fb"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Constants
 | 
				
			||||||
 | 
					CHUNK_SIZE = 240  # 480 hex chars (as seen in the JS uploader)
 | 
				
			||||||
 | 
					MTU_WAIT = 0.03   # delay between chunks (adjustable)
 | 
				
			||||||
 | 
					DEBUG = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def log(msg):
 | 
				
			||||||
 | 
					    if DEBUG:
 | 
				
			||||||
 | 
					        print(f"[LOG] {msg}", flush=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class GiciskyProtocol:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, ble: BLEInterface, logger: Optional[logging.Logger] = None):
 | 
				
			||||||
 | 
					        self.ble = ble
 | 
				
			||||||
 | 
					        self.packet_index = 0
 | 
				
			||||||
 | 
					        self.ready_to_send = False
 | 
				
			||||||
 | 
					        self.img_hex = b""
 | 
				
			||||||
 | 
					        self.upload_done = False
 | 
				
			||||||
 | 
					        self.logger = GiciskyLogger(logger)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async def handle_notification(self, data):
 | 
				
			||||||
 | 
					        hx = data.hex()
 | 
				
			||||||
 | 
					        self.logger.debug(f"Notify: {hx}", LogCategory.NOTIFICATION)
 | 
				
			||||||
 | 
					        if hx.startswith("01f400"):
 | 
				
			||||||
 | 
					            # Step 2: ready to accept image size
 | 
				
			||||||
 | 
					            self.logger.info("Device ready to accept image size", LogCategory.PROTOCOL)
 | 
				
			||||||
 | 
					            await self.send_command(2, struct.pack("<I", int(len(self.img_hex))) + b"\x00\x00\x00")
 | 
				
			||||||
 | 
					        elif hx.startswith("02"):
 | 
				
			||||||
 | 
					            # Step 3: begin upload
 | 
				
			||||||
 | 
					            self.logger.info("Starting image upload", LogCategory.DATA_TRANSFER)
 | 
				
			||||||
 | 
					            await self.send_command(3)
 | 
				
			||||||
 | 
					        elif hx.startswith("05"):
 | 
				
			||||||
 | 
					            self.logger.debug(f"Handling response: {hx}, err: {hx[2:4]}, part: {hx[4:12]}", LogCategory.NOTIFICATION)
 | 
				
			||||||
 | 
					            err = hx[2:4]
 | 
				
			||||||
 | 
					            if err == "00":  # continue sending chunks
 | 
				
			||||||
 | 
					                await self.send_next_chunk(hx[4:12])
 | 
				
			||||||
 | 
					            elif err == "08":  # upload complete
 | 
				
			||||||
 | 
					                self.logger.info("Upload completed successfully.", LogCategory.DATA_TRANSFER)
 | 
				
			||||||
 | 
					                self.img_hex = b""
 | 
				
			||||||
 | 
					                self.upload_done = True
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                self.logger.error(f"Error code during upload: {err}", LogCategory.DATA_TRANSFER)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async def send_command(self, cmd_id, payload=b""):
 | 
				
			||||||
 | 
					        pkt = bytes([cmd_id]) + payload
 | 
				
			||||||
 | 
					        await self.ble.write(CHAR_CMD_UUID, pkt)
 | 
				
			||||||
 | 
					        self.logger.debug(f"Cmd {cmd_id:02x} sent: {pkt.hex()}", LogCategory.COMMAND)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async def send_next_chunk(self, ack_hex):
 | 
				
			||||||
 | 
					        ack = struct.unpack("<I", bytes.fromhex(ack_hex))[0]
 | 
				
			||||||
 | 
					        if not self.img_hex:
 | 
				
			||||||
 | 
					            self.logger.debug("No more data to send.", LogCategory.DATA_TRANSFER)
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if ack == self.packet_index:
 | 
				
			||||||
 | 
					            prefix = struct.pack("<I", self.packet_index)
 | 
				
			||||||
 | 
					            self.packet_index += 1
 | 
				
			||||||
 | 
					            chunk = self.img_hex[:CHUNK_SIZE]
 | 
				
			||||||
 | 
					            self.img_hex = self.img_hex[CHUNK_SIZE:]
 | 
				
			||||||
 | 
					            await self.ble.write(CHAR_IMG_UUID, prefix + chunk)
 | 
				
			||||||
 | 
					            self.logger.debug(f"Sent packet #{self.packet_index - 1} {ack_hex}", LogCategory.DATA_TRANSFER)
 | 
				
			||||||
 | 
					            await asyncio.sleep(MTU_WAIT)
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            self.logger.warning(f"ACK mismatch ({ack} != {self.packet_index})", LogCategory.DATA_TRANSFER)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async def upload_image(self, img: bytes):
 | 
				
			||||||
 | 
					        self.img_hex = img
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await self.ble.start_notify(CHAR_CMD_UUID, self.handle_notification)
 | 
				
			||||||
 | 
					        self.logger.info("Notifications started.", LogCategory.PROTOCOL)
 | 
				
			||||||
 | 
					        await self.send_command(1)  # init command
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        while not self.upload_done:
 | 
				
			||||||
 | 
					            await asyncio.sleep(0.5)
 | 
				
			||||||
							
								
								
									
										109
									
								
								examples/send_bleak.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								examples/send_bleak.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,109 @@
 | 
				
			||||||
 | 
					import asyncio
 | 
				
			||||||
 | 
					import sys
 | 
				
			||||||
 | 
					import argparse
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from PIL import Image
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from ble.bleak import BleakBackend
 | 
				
			||||||
 | 
					from core.advertisement import parse_advertisement
 | 
				
			||||||
 | 
					from core.protocol import GiciskyProtocol
 | 
				
			||||||
 | 
					from image.conversion import convert_to_gicisky_bytes
 | 
				
			||||||
 | 
					from image import optimize
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async def select_device(ble_backend):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Scan for devices and either automatically select the only one or prompt the user to choose.
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    Returns:
 | 
				
			||||||
 | 
					        tuple: (device_mac, model_id)
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    print("Scanning for Gicisky devices...")
 | 
				
			||||||
 | 
					    devices = await ble_backend.scan_devices()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if not devices:
 | 
				
			||||||
 | 
					        print("No compatible devices found.")
 | 
				
			||||||
 | 
					        return None, None
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if len(devices) == 1:
 | 
				
			||||||
 | 
					        # Automatically select the only device
 | 
				
			||||||
 | 
					        mac = list(devices.keys())[0]
 | 
				
			||||||
 | 
					        print(f"Found one device: {mac}")
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        # Multiple devices found, prompt user to select
 | 
				
			||||||
 | 
					        print("Multiple devices found:")
 | 
				
			||||||
 | 
					        device_list = list(devices.items())
 | 
				
			||||||
 | 
					        for i, (mac, _) in enumerate(device_list):
 | 
				
			||||||
 | 
					            print(f"{i+1}. {mac}")
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        while True:
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                choice = int(input("Select device number: ")) - 1
 | 
				
			||||||
 | 
					                if 0 <= choice < len(device_list):
 | 
				
			||||||
 | 
					                    mac = device_list[choice][0]
 | 
				
			||||||
 | 
					                    break
 | 
				
			||||||
 | 
					                else:
 | 
				
			||||||
 | 
					                    print("Invalid selection. Please try again.")
 | 
				
			||||||
 | 
					            except ValueError:
 | 
				
			||||||
 | 
					                print("Invalid input. Please enter a number.")
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    # Parse advertisement data to get model ID
 | 
				
			||||||
 | 
					    adv = devices[mac]
 | 
				
			||||||
 | 
					    device_data = parse_advertisement(adv)
 | 
				
			||||||
 | 
					    model_id = device_data.model
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    print(f"Selected device: {mac}")
 | 
				
			||||||
 | 
					    print(f"Device model ID: 0x{model_id:02X}")
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    return mac, model_id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async def main_async(image_path: str, no_optimize: bool = False):
 | 
				
			||||||
 | 
					    # Initialize BLE backend (Bleak)
 | 
				
			||||||
 | 
					    ble_backend = BleakBackend()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    # Scan and select device
 | 
				
			||||||
 | 
					    mac, model_id = await select_device(ble_backend)
 | 
				
			||||||
 | 
					    if not mac or model_id is None:
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    # Load image
 | 
				
			||||||
 | 
					    original_img = Image.open(image_path)
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    # Optimize image if not disabled
 | 
				
			||||||
 | 
					    if no_optimize:
 | 
				
			||||||
 | 
					        processed_img = original_img
 | 
				
			||||||
 | 
					        print("Image optimization skipped (--no-optimize flag used)")
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        processed_img = optimize(original_img, model_id)
 | 
				
			||||||
 | 
					        print("Image optimized for target device")
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    # Convert image to Gicisky format bytes
 | 
				
			||||||
 | 
					    img_bytes = convert_to_gicisky_bytes(processed_img, model_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Connect to the device
 | 
				
			||||||
 | 
					    print(f"Connecting to BLE device {mac}...")
 | 
				
			||||||
 | 
					    await ble_backend.connect(mac)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Create protocol instance with the BLE transport
 | 
				
			||||||
 | 
					    protocol = GiciskyProtocol(ble_backend)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Upload image bytes using the Gicisky handshake protocol
 | 
				
			||||||
 | 
					    print("Starting image upload...")
 | 
				
			||||||
 | 
					    await protocol.upload_image(img_bytes)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Disconnect BLE after upload
 | 
				
			||||||
 | 
					    print("Disconnecting...")
 | 
				
			||||||
 | 
					    await ble_backend.disconnect()
 | 
				
			||||||
 | 
					    print("Done.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if __name__ == "__main__":
 | 
				
			||||||
 | 
					    parser = argparse.ArgumentParser(description="Send an image to a Gicisky device over BLE")
 | 
				
			||||||
 | 
					    parser.add_argument("image", help="Path to the image file")
 | 
				
			||||||
 | 
					    parser.add_argument("--no-optimize", action="store_true", 
 | 
				
			||||||
 | 
					                        help="Skip image optimization for target device")
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    args = parser.parse_args()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    asyncio.run(main_async(args.image, args.no_optimize))
 | 
				
			||||||
							
								
								
									
										3
									
								
								image/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								image/__init__.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,3 @@
 | 
				
			||||||
 | 
					from .optimizer import optimize
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					__all__ = ["optimize"]
 | 
				
			||||||
							
								
								
									
										210
									
								
								image/conversion.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										210
									
								
								image/conversion.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,210 @@
 | 
				
			||||||
 | 
					import dataclasses
 | 
				
			||||||
 | 
					from typing import Tuple, Dict, List
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import numpy as np
 | 
				
			||||||
 | 
					from PIL import Image
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@dataclasses.dataclass
 | 
				
			||||||
 | 
					class DeviceSpec:
 | 
				
			||||||
 | 
					    model: str
 | 
				
			||||||
 | 
					    size: Tuple[int, int]
 | 
				
			||||||
 | 
					    mirror: bool = False
 | 
				
			||||||
 | 
					    rotation: bool = False
 | 
				
			||||||
 | 
					    second_color: bool = True
 | 
				
			||||||
 | 
					    tft: bool = False
 | 
				
			||||||
 | 
					    compression: bool = False
 | 
				
			||||||
 | 
					    max_voltage: float = 2.9
 | 
				
			||||||
 | 
					    min_voltage: float = 2.2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ModelId = int
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					DEVICE_SPECS: Dict[ModelId, DeviceSpec] = {
 | 
				
			||||||
 | 
					    0x0B: DeviceSpec(
 | 
				
			||||||
 | 
					        model="EPD 2.1\" BWR",
 | 
				
			||||||
 | 
					        size=(250, 122)
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    0x33: DeviceSpec(
 | 
				
			||||||
 | 
					        model="EPD 2.9\" BWR",
 | 
				
			||||||
 | 
					        size=(296, 128),
 | 
				
			||||||
 | 
					        mirror=True,
 | 
				
			||||||
 | 
					        max_voltage=3.0
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    0x4B: DeviceSpec(
 | 
				
			||||||
 | 
					        model="EPD 4.2\" BWR",
 | 
				
			||||||
 | 
					        size=(400, 300),
 | 
				
			||||||
 | 
					        rotation=True,
 | 
				
			||||||
 | 
					        max_voltage=3.0
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    0x2B: DeviceSpec(
 | 
				
			||||||
 | 
					        model="EPD 7.5\" BWR",
 | 
				
			||||||
 | 
					        size=(800, 480),
 | 
				
			||||||
 | 
					        mirror=True,
 | 
				
			||||||
 | 
					        rotation=True,
 | 
				
			||||||
 | 
					        compression=True,
 | 
				
			||||||
 | 
					        max_voltage=3.0
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    0xA0: DeviceSpec(
 | 
				
			||||||
 | 
					        model="TFT 2.1\" BW",
 | 
				
			||||||
 | 
					        size=(250, 132),
 | 
				
			||||||
 | 
					        second_color=False,
 | 
				
			||||||
 | 
					        tft=True
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def convert_to_gicisky_bytes(img: Image.Image, model: ModelId, lum_threshold: int = 128, red_threshold: int = 170) -> bytes:
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Process image according to Gicisky device specifications.
 | 
				
			||||||
 | 
					    Usage with image.optimize is recommended.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Args:
 | 
				
			||||||
 | 
					        img: PIL Image to process
 | 
				
			||||||
 | 
					        model: Device model identifier
 | 
				
			||||||
 | 
					        lum_threshold: Luminance threshold for black/white conversion
 | 
				
			||||||
 | 
					        red_threshold: Threshold for red color detection
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Returns:
 | 
				
			||||||
 | 
					        bytes: Processed image data in Gicisky format
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    # Get device specifications
 | 
				
			||||||
 | 
					    specs = DEVICE_SPECS.get(model)
 | 
				
			||||||
 | 
					    if not specs:
 | 
				
			||||||
 | 
					        raise ValueError("Unknown model")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    width, height = specs.size
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Resize image to device dimensions
 | 
				
			||||||
 | 
					    img = img.convert("RGB").resize((width, height), Image.Resampling.LANCZOS)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Apply TFT transformation if enabled
 | 
				
			||||||
 | 
					    if specs.tft:
 | 
				
			||||||
 | 
					        # Resize to half width and double height
 | 
				
			||||||
 | 
					        img = img.resize((width // 2, height * 2), Image.Resampling.LANCZOS)
 | 
				
			||||||
 | 
					        width, height = img.size
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Apply mirroring if enabled
 | 
				
			||||||
 | 
					    if specs.mirror:
 | 
				
			||||||
 | 
					        img = img.transpose(Image.FLIP_TOP_BOTTOM)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Convert to numpy array
 | 
				
			||||||
 | 
					    arr = np.array(img)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Process pixels - column-major order
 | 
				
			||||||
 | 
					    byte_data, red_byte_data = [], []
 | 
				
			||||||
 | 
					    current_byte, current_red_byte = 0, 0
 | 
				
			||||||
 | 
					    bit_position = 7
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for x in range(width):
 | 
				
			||||||
 | 
					        for y in range(height):
 | 
				
			||||||
 | 
					            r, g, b = arr[y, x]  # Note: numpy uses [y, x] indexing
 | 
				
			||||||
 | 
					            luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Apply thresholding based on compression setting
 | 
				
			||||||
 | 
					            if specs.compression:
 | 
				
			||||||
 | 
					                # When compression is enabled, dark pixels set bits
 | 
				
			||||||
 | 
					                if luminance < lum_threshold:
 | 
				
			||||||
 | 
					                    current_byte |= (1 << bit_position)
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                # When compression is disabled, light pixels set bits
 | 
				
			||||||
 | 
					                if luminance > lum_threshold:
 | 
				
			||||||
 | 
					                    current_byte |= (1 << bit_position)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Red color detection
 | 
				
			||||||
 | 
					            if r > red_threshold and g < red_threshold:
 | 
				
			||||||
 | 
					                current_red_byte |= (1 << bit_position)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            bit_position -= 1
 | 
				
			||||||
 | 
					            if bit_position < 0:
 | 
				
			||||||
 | 
					                byte_data.append(current_byte)
 | 
				
			||||||
 | 
					                red_byte_data.append(current_red_byte)
 | 
				
			||||||
 | 
					                current_byte, current_red_byte = 0, 0
 | 
				
			||||||
 | 
					                bit_position = 7
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Handle remaining bits
 | 
				
			||||||
 | 
					    if bit_position != 7:
 | 
				
			||||||
 | 
					        byte_data.append(current_byte)
 | 
				
			||||||
 | 
					        red_byte_data.append(current_red_byte)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Apply compression if enabled
 | 
				
			||||||
 | 
					    if specs.compression:
 | 
				
			||||||
 | 
					        byte_data_compressed = _apply_compression(byte_data, red_byte_data, width, height, specs.second_color)
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        # Simple concatenation when compression is disabled
 | 
				
			||||||
 | 
					        byte_data_compressed = byte_data[:]
 | 
				
			||||||
 | 
					        if specs.second_color:
 | 
				
			||||||
 | 
					            byte_data_compressed.extend(red_byte_data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return bytes(byte_data_compressed)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _apply_compression(byte_data: List[int], red_byte_data: List[int],
 | 
				
			||||||
 | 
					                       width: int, height: int, second_color: bool) -> List[int]:
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Apply compression algorithm
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Args:
 | 
				
			||||||
 | 
					        byte_data: Black/white pixel data
 | 
				
			||||||
 | 
					        red_byte_data: Red pixel data
 | 
				
			||||||
 | 
					        width: Image width
 | 
				
			||||||
 | 
					        height: Image height
 | 
				
			||||||
 | 
					        second_color: Whether to include second color (red) data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Returns:
 | 
				
			||||||
 | 
					        List[int]: Compressed byte data
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    byte_data_compressed = [0x00, 0x00, 0x00, 0x00]  # Header
 | 
				
			||||||
 | 
					    byte_per_line = height // 8
 | 
				
			||||||
 | 
					    current_pos = 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Process black/white data
 | 
				
			||||||
 | 
					    for i in range(width):
 | 
				
			||||||
 | 
					        # Add line header
 | 
				
			||||||
 | 
					        byte_data_compressed.extend([
 | 
				
			||||||
 | 
					            0x75,
 | 
				
			||||||
 | 
					            byte_per_line + 7,
 | 
				
			||||||
 | 
					            byte_per_line,
 | 
				
			||||||
 | 
					            0x00,
 | 
				
			||||||
 | 
					            0x00,
 | 
				
			||||||
 | 
					            0x00,
 | 
				
			||||||
 | 
					            0x00
 | 
				
			||||||
 | 
					        ])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Add pixel data for this line
 | 
				
			||||||
 | 
					        for b in range(byte_per_line):
 | 
				
			||||||
 | 
					            if current_pos < len(byte_data):
 | 
				
			||||||
 | 
					                byte_data_compressed.append(byte_data[current_pos])
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                byte_data_compressed.append(0x00)  # Padding
 | 
				
			||||||
 | 
					            current_pos += 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Process red data if enabled
 | 
				
			||||||
 | 
					    if second_color:
 | 
				
			||||||
 | 
					        current_pos = 0
 | 
				
			||||||
 | 
					        for i in range(width):
 | 
				
			||||||
 | 
					            # Add line header
 | 
				
			||||||
 | 
					            byte_data_compressed.extend([
 | 
				
			||||||
 | 
					                0x75,
 | 
				
			||||||
 | 
					                byte_per_line + 7,
 | 
				
			||||||
 | 
					                byte_per_line,
 | 
				
			||||||
 | 
					                0x00,
 | 
				
			||||||
 | 
					                0x00,
 | 
				
			||||||
 | 
					                0x00,
 | 
				
			||||||
 | 
					                0x00
 | 
				
			||||||
 | 
					            ])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Add pixel data for this line
 | 
				
			||||||
 | 
					            for b in range(byte_per_line):
 | 
				
			||||||
 | 
					                if current_pos < len(red_byte_data):
 | 
				
			||||||
 | 
					                    byte_data_compressed.append(red_byte_data[current_pos])
 | 
				
			||||||
 | 
					                else:
 | 
				
			||||||
 | 
					                    byte_data_compressed.append(0x00)  # Padding
 | 
				
			||||||
 | 
					                current_pos += 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Update header with total length
 | 
				
			||||||
 | 
					    total_length = len(byte_data_compressed)
 | 
				
			||||||
 | 
					    byte_data_compressed[0] = total_length & 0xff
 | 
				
			||||||
 | 
					    byte_data_compressed[1] = (total_length >> 8) & 0xff
 | 
				
			||||||
 | 
					    byte_data_compressed[2] = (total_length >> 16) & 0xff
 | 
				
			||||||
 | 
					    byte_data_compressed[3] = (total_length >> 24) & 0xff
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return byte_data_compressed
 | 
				
			||||||
							
								
								
									
										59
									
								
								image/optimizer.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								image/optimizer.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,59 @@
 | 
				
			||||||
 | 
					from PIL import Image
 | 
				
			||||||
 | 
					import numpy as np
 | 
				
			||||||
 | 
					from typing import Tuple
 | 
				
			||||||
 | 
					from .conversion import DEVICE_SPECS, ModelId, DeviceSpec
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def optimize(img: Image.Image, model: ModelId) -> Image.Image:
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Optimize an image for a specific Gicisky device model.
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    Args:
 | 
				
			||||||
 | 
					        img: Input PIL Image
 | 
				
			||||||
 | 
					        model: Device model identifier
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					    Returns:
 | 
				
			||||||
 | 
					        Image.Image: Optimized image
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    specs: DeviceSpec = DEVICE_SPECS.get(model)
 | 
				
			||||||
 | 
					    if not specs:
 | 
				
			||||||
 | 
					        raise ValueError(f"Unknown model: {model}")
 | 
				
			||||||
 | 
					    target_width, target_height = specs.size
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    canvas = Image.new("RGB", (target_width, target_height), color="white")
 | 
				
			||||||
 | 
					    img_width, img_height = img.size
 | 
				
			||||||
 | 
					    scale = min(target_width / img_width, target_height / img_height)
 | 
				
			||||||
 | 
					    new_width = int(img_width * scale)
 | 
				
			||||||
 | 
					    new_height = int(img_height * scale)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    resized_img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
 | 
				
			||||||
 | 
					    x_offset = (target_width - new_width) // 2
 | 
				
			||||||
 | 
					    y_offset = (target_height - new_height) // 2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    canvas.paste(resized_img, (x_offset, y_offset))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    palette = Image.new("P", (1, 1))
 | 
				
			||||||
 | 
					    colors = 3
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    # Apply appropriate color conversion
 | 
				
			||||||
 | 
					    if specs.second_color:
 | 
				
			||||||
 | 
					        palette.putpalette([
 | 
				
			||||||
 | 
					            0, 0, 0,  # Black
 | 
				
			||||||
 | 
					            255, 255, 255,  # White
 | 
				
			||||||
 | 
					            255, 0, 0  # Red
 | 
				
			||||||
 | 
					        ])
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        palette.putpalette([
 | 
				
			||||||
 | 
					            0, 0, 0,  # Black
 | 
				
			||||||
 | 
					            255, 255, 255,  # White
 | 
				
			||||||
 | 
					        ])
 | 
				
			||||||
 | 
					        colors = 2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    processed_img = canvas.quantize(method=Image.MEDIANCUT,
 | 
				
			||||||
 | 
					                                        colors=colors,
 | 
				
			||||||
 | 
					                                        kmeans=0,
 | 
				
			||||||
 | 
					                                        palette=palette)
 | 
				
			||||||
 | 
					    processed_img = processed_img.convert("RGB")
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    return processed_img
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										0
									
								
								logger/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								logger/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										42
									
								
								logger/logger.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								logger/logger.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,42 @@
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					from typing import Optional
 | 
				
			||||||
 | 
					from enum import Enum
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class LogCategory(Enum):
 | 
				
			||||||
 | 
					    CONNECTION = "connection"
 | 
				
			||||||
 | 
					    PROTOCOL = "protocol"
 | 
				
			||||||
 | 
					    DATA_TRANSFER = "data_transfer"
 | 
				
			||||||
 | 
					    NOTIFICATION = "notification"
 | 
				
			||||||
 | 
					    COMMAND = "command"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class GiciskyLogger:
 | 
				
			||||||
 | 
					    def __init__(self, logger: Optional[logging.Logger] = None):
 | 
				
			||||||
 | 
					        if logger:
 | 
				
			||||||
 | 
					            self.logger = logger
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            self.logger = logging.getLogger("gicisky")
 | 
				
			||||||
 | 
					            if not self.logger.handlers:
 | 
				
			||||||
 | 
					                handler = logging.StreamHandler()
 | 
				
			||||||
 | 
					                formatter = logging.Formatter('[%(name)s] %(levelname)s: %(message)s')
 | 
				
			||||||
 | 
					                handler.setFormatter(formatter)
 | 
				
			||||||
 | 
					                self.logger.addHandler(handler)
 | 
				
			||||||
 | 
					                self.logger.setLevel(logging.INFO)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def log(self, level: int, message: str, category: LogCategory = None, **kwargs):
 | 
				
			||||||
 | 
					        extra_info = ""
 | 
				
			||||||
 | 
					        if category:
 | 
				
			||||||
 | 
					            extra_info = f"[{category.value}] "
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.logger.log(level, f"{extra_info}{message}", **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def debug(self, message: str, category: LogCategory = None):
 | 
				
			||||||
 | 
					        self.log(logging.DEBUG, message, category)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def info(self, message: str, category: LogCategory = None):
 | 
				
			||||||
 | 
					        self.log(logging.INFO, message, category)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def warning(self, message: str, category: LogCategory = None):
 | 
				
			||||||
 | 
					        self.log(logging.WARNING, message, category)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def error(self, message: str, category: LogCategory = None):
 | 
				
			||||||
 | 
					        self.log(logging.ERROR, message, category)
 | 
				
			||||||
							
								
								
									
										3
									
								
								requirements.txt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								requirements.txt
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,3 @@
 | 
				
			||||||
 | 
					bleak>=1.1.1
 | 
				
			||||||
 | 
					numpy>=2.2.6
 | 
				
			||||||
 | 
					Pillow>=12.0.0
 | 
				
			||||||
							
								
								
									
										33
									
								
								setup.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								setup.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,33 @@
 | 
				
			||||||
 | 
					from setuptools import setup, find_packages
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					with open("README.md", "r", encoding="utf-8") as fh:
 | 
				
			||||||
 | 
					    long_description = fh.read()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					with open("requirements.txt", "r", encoding="utf-8") as fh:
 | 
				
			||||||
 | 
					    requirements = [line.strip() for line in fh if line.strip() and not line.startswith("#")]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					setup(
 | 
				
			||||||
 | 
					    name="gicisky",
 | 
				
			||||||
 | 
					    version="0.1.0",
 | 
				
			||||||
 | 
					    author="MassiveBox",
 | 
				
			||||||
 | 
					    author_email="box@massive.box",
 | 
				
			||||||
 | 
					    description="A Python library for interacting with Gicisky E-Ink display tags via Bluetooth Low Energy",
 | 
				
			||||||
 | 
					    long_description=long_description,
 | 
				
			||||||
 | 
					    long_description_content_type="text/markdown",
 | 
				
			||||||
 | 
					    url="https://git.massive.box/massivebox/gicisky",
 | 
				
			||||||
 | 
					    packages=find_packages(),
 | 
				
			||||||
 | 
					    classifiers=[
 | 
				
			||||||
 | 
					        "Development Status :: 3 - Alpha",
 | 
				
			||||||
 | 
					        "Intended Audience :: Developers",
 | 
				
			||||||
 | 
					        "License :: OSI Approved :: MIT License",
 | 
				
			||||||
 | 
					        "Operating System :: OS Independent",
 | 
				
			||||||
 | 
					        "Programming Language :: Python :: 3",
 | 
				
			||||||
 | 
					        "Programming Language :: Python :: 3.7",
 | 
				
			||||||
 | 
					        "Programming Language :: Python :: 3.8",
 | 
				
			||||||
 | 
					        "Programming Language :: Python :: 3.9",
 | 
				
			||||||
 | 
					        "Programming Language :: Python :: 3.10",
 | 
				
			||||||
 | 
					        "Programming Language :: Python :: 3.11",
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    python_requires=">=3.7",
 | 
				
			||||||
 | 
					    install_requires=requirements,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue