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