From 70c0b4a1f210ce7c68582803304541a6eaa75511 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Tue, 11 Nov 2025 11:57:39 +0100 Subject: [PATCH] HW integratation --- .gitmodules | 3 + GPIO_BUTTONS.md | 286 +++++++++++ HARDWARE_PINOUT.md | 230 +++++++++ HARDWARE_SETUP.md | 472 ++++++++++++++++++ README.md | 67 ++- dreader/gesture.py | 4 + dreader/gpio_buttons.py | 291 +++++++++++ dreader/hal_hardware.py | 593 +++++++++++++++++++++++ dreader/handlers/gestures.py | 4 + examples/calibrate_accelerometer.py | 363 ++++++++++++++ examples/demo_accelerometer_page_flip.py | 212 ++++++++ examples/demo_accelerometer_simple.py | 118 +++++ examples/run_on_hardware.py | 292 +++++++++++ examples/run_on_hardware_config.py | 293 +++++++++++ external/dreader-hal | 1 + hardware_config.json | 65 +++ install_hardware_drivers.sh | 80 +++ setup_rpi.py | 445 +++++++++++++++++ tests/test_accelerometer_gestures.py | 284 +++++++++++ 19 files changed, 4082 insertions(+), 21 deletions(-) create mode 100644 .gitmodules create mode 100644 GPIO_BUTTONS.md create mode 100755 HARDWARE_PINOUT.md create mode 100644 HARDWARE_SETUP.md create mode 100644 dreader/gpio_buttons.py create mode 100644 dreader/hal_hardware.py create mode 100755 examples/calibrate_accelerometer.py create mode 100755 examples/demo_accelerometer_page_flip.py create mode 100755 examples/demo_accelerometer_simple.py create mode 100755 examples/run_on_hardware.py create mode 100755 examples/run_on_hardware_config.py create mode 160000 external/dreader-hal create mode 100644 hardware_config.json create mode 100755 install_hardware_drivers.sh create mode 100755 setup_rpi.py create mode 100644 tests/test_accelerometer_gestures.py diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..39d8d48 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "external/dreader-hal"] + path = external/dreader-hal + url = https://gitea.tourolle.paris/dtourolle/dreader-hal diff --git a/GPIO_BUTTONS.md b/GPIO_BUTTONS.md new file mode 100644 index 0000000..2481841 --- /dev/null +++ b/GPIO_BUTTONS.md @@ -0,0 +1,286 @@ +# GPIO Button Configuration Guide + +This guide explains how to configure physical buttons for the DReader e-reader. + +## Overview + +Physical buttons provide tactile feedback for page turns and navigation without requiring touch input. Buttons are connected between GPIO pins and GND, using internal pull-up resistors. + +## Hardware Setup + +### Basic Button Wiring + +``` + +3.3V + | + R (internal pull-up) + | +GPIO --|------ Button ------ GND + | + (to BCM2835) +``` + +When the button is pressed, it connects the GPIO pin to GND (0V), pulling the pin LOW. + +### Recommended Button Layout + +For this e-reader device with 3 buttons: + +``` +┌─────────────────────┐ +│ │ +│ [Power Off] │ ← Side button (GPIO 21) +│ │ +│ │ +│ E-INK │ +│ DISPLAY │ +│ 1872x1404 │ +│ │ +│ [Prev] [Next] │ ← Bottom edge +│ │ +└─────────────────────┘ +``` + +**Button Mapping:** +- **Previous Page** (GPIO 22) - Bottom left - Previous page +- **Next Page** (GPIO 27) - Bottom right - Next page +- **Power Off** (GPIO 21) - Side button - Shutdown device (long press) + +## Software Configuration + +### Using Interactive Setup (Recommended) + +```bash +sudo python3 setup_rpi.py +``` + +The setup script will: +1. Ask if you want GPIO buttons enabled +2. Let you configure each button individually +3. Allow custom GPIO pin assignments +4. Generate hardware_config.json automatically + +### Manual Configuration + +Edit `hardware_config.json`: + +```json +{ + "gpio_buttons": { + "enabled": true, + "pull_up": true, + "bounce_time_ms": 200, + "buttons": [ + { + "name": "prev_page", + "gpio": 22, + "gesture": "swipe_right", + "description": "Previous page button" + }, + { + "name": "next_page", + "gpio": 27, + "gesture": "swipe_left", + "description": "Next page button" + }, + { + "name": "power_off", + "gpio": 21, + "gesture": "long_press", + "description": "Power off button (long press to shutdown)" + } + ] + } +} +``` + +### Configuration Options + +- **enabled** (bool): Enable/disable all GPIO buttons +- **pull_up** (bool): Use internal pull-up resistors (always true for button-to-GND wiring) +- **bounce_time_ms** (int): Debounce time in milliseconds (default 200ms) +- **buttons** (array): List of button configurations + +### Button Configuration + +Each button has: +- **name** (string): Unique identifier for the button +- **gpio** (int): BCM GPIO pin number (2-27) +- **gesture** (string): Gesture type to generate when pressed +- **description** (string): Human-readable description + +### Available Gestures + +Buttons can trigger any gesture type: + +| Gesture | Description | Typical Use | +|---------|-------------|-------------| +| `swipe_left` | Swipe left | Next page | +| `swipe_right` | Swipe right | Previous page | +| `swipe_up` | Swipe up from bottom | Open navigation/TOC | +| `swipe_down` | Swipe down from top | Open settings | +| `tap` | Single tap | Select item | +| `long_press` | Hold | Context menu | +| `pinch_in` | Pinch zoom out | Decrease font size | +| `pinch_out` | Pinch zoom in | Increase font size | + +## GPIO Pin Selection + +### Safe GPIO Pins (BCM numbering) + +**Recommended for buttons:** +- GPIO 5, 6, 12, 13, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27 + +**Avoid these pins:** +- GPIO 2, 3 - I2C (SDA, SCL) - Used for touch, sensors +- GPIO 7-11 - SPI - Used for e-ink display +- GPIO 14, 15 - UART - Used for serial console +- GPIO 0, 1 - Reserved for ID EEPROM + +### Pin Layout (BCM Mode) + +``` + 3V3 (1) (2) 5V + GPIO2 (3) (4) 5V GPIO 2,3 = I2C + GPIO3 (5) (6) GND + GPIO4 (7) (8) GPIO14 GPIO 7-11 = SPI + GND (9) (10) GPIO15 GPIO 14,15 = UART + GPIO17 (11) (12) GPIO18 + GPIO27 (13) (14) GND + GPIO22 (15) (16) GPIO23 ← Good for buttons + 3V3 (17) (18) GPIO24 ← Good for buttons + GPIO10 (19) (20) GND + GPIO9 (21) (22) GPIO25 ← Good for buttons + GPIO11 (23) (24) GPIO8 + GND (25) (26) GPIO7 + GPIO0 (27) (28) GPIO1 + GPIO5 (29) (30) GND ← Good for buttons + GPIO6 (31) (32) GPIO12 ← Good for buttons + GPIO13 (33) (34) GND ← Good for buttons + GPIO19 (35) (36) GPIO16 ← Good for buttons + GPIO26 (37) (38) GPIO20 ← Good for buttons + GND (39) (40) GPIO21 ← Good for buttons +``` + +## Testing Buttons + +### Test Button Connections + +```bash +# Install GPIO utilities +sudo apt install gpiod + +# Monitor GPIO 23 (next page button) +gpioget gpiochip0 23 + +# Press button - should show 0 (LOW) +# Release button - should show 1 (HIGH with pull-up) +``` + +### Test in Application + +Run with verbose logging to see button events: + +```bash +python examples/run_on_hardware_config.py --verbose +``` + +Press each button and verify you see log messages like: +``` +Button pressed: next_page (GPIO 23) +Button event queued: next_page -> swipe_left +``` + +## Troubleshooting + +### Buttons Not Working + +**Problem:** Buttons don't generate events + +**Solutions:** +1. Check wiring - button should connect GPIO to GND +2. Verify GPIO pin number in config (BCM mode, not physical pin) +3. Check permissions: `sudo usermod -a -G gpio $USER` then log out/in +4. Test GPIO with `gpioget` (see above) +5. Check logs: `python examples/run_on_hardware_config.py --verbose` + +### False Triggers + +**Problem:** Button triggers multiple times from single press + +**Solutions:** +1. Increase `bounce_time_ms` in config (try 300-500ms) +2. Add hardware debounce capacitor (0.1µF between GPIO and GND) +3. Check for loose connections + +### Wrong Action + +**Problem:** Button does wrong action + +**Solutions:** +1. Check `gesture` field in button config +2. Verify button name matches intended function +3. Check logs to see what gesture is generated + +## Advanced: Custom Button Functions + +You can map buttons to any gesture, creating custom layouts: + +### Example: Reading Mode Buttons + +```json +{ + "gpio_buttons": { + "enabled": true, + "buttons": [ + {"name": "next", "gpio": 23, "gesture": "swipe_left"}, + {"name": "prev", "gpio": 24, "gesture": "swipe_right"}, + {"name": "zoom_in", "gpio": 25, "gesture": "pinch_out"}, + {"name": "zoom_out", "gpio": 22, "gesture": "pinch_in"} + ] + } +} +``` + +### Example: Simple 2-Button Layout + +```json +{ + "gpio_buttons": { + "enabled": true, + "buttons": [ + {"name": "next", "gpio": 23, "gesture": "swipe_left"}, + {"name": "prev", "gpio": 24, "gesture": "swipe_right"} + ] + } +} +``` + +## Hardware Tips + +### Button Quality + +- Use momentary pushbuttons (normally open) +- Tactile switches provide good feedback +- Rated for at least 10,000 cycles +- Consider waterproof buttons for outdoor use + +### Mounting + +- Mount buttons accessible from device edge +- Label buttons for user convenience +- Consider button guards to prevent accidental presses +- Use hot glue or button caps for secure mounting + +### Wiring + +- Keep wires short to reduce noise +- Use stranded wire for flexibility +- Consider using a ribbon cable for clean routing +- Add strain relief at connection points + +## See Also + +- [HARDWARE_SETUP.md](HARDWARE_SETUP.md) - Complete hardware integration guide +- [hardware_config.json](hardware_config.json) - Example configuration +- [dreader/gpio_buttons.py](dreader/gpio_buttons.py) - Button handler source code diff --git a/HARDWARE_PINOUT.md b/HARDWARE_PINOUT.md new file mode 100755 index 0000000..d35af02 --- /dev/null +++ b/HARDWARE_PINOUT.md @@ -0,0 +1,230 @@ +# DReader Hardware Pinout Reference + +Quick reference for the DReader e-ink device hardware configuration. + +## Display Specifications + +- **Resolution:** 1872 × 1404 pixels +- **Controller:** IT8951 (SPI) +- **Touch Panel:** FT5316 (I2C) + +## GPIO Pin Assignments + +### Buttons (BCM Numbering) + +| GPIO | Function | Action | Notes | +|------|----------|--------|-------| +| 21 | Power Off | Long Press (500ms+) | Shutdown button | +| 22 | Previous Page | Swipe Right | Left button | +| 27 | Next Page | Swipe Left | Right button | + +**Wiring:** All buttons connect between GPIO and GND (pull-up resistors enabled in software) + +### SPI (IT8951 E-ink Display) + +| GPIO | Function | Pin | +|------|----------|-----| +| 8 | SPI0 CE0 | 24 | +| 9 | SPI0 MISO | 21 | +| 10 | SPI0 MOSI | 19 | +| 11 | SPI0 SCLK | 23 | +| 17 | RST | 11 | +| 24 | HRDY | 18 | + +### I2C (Touch, Sensors, RTC, Power Monitor) + +| GPIO | Function | Pin | Devices | +|------|----------|-----|---------| +| 2 | I2C1 SDA | 3 | FT5316 (0x38), BMA400 (0x14), PCF8523 (0x68), INA219 (0x40) | +| 3 | I2C1 SCL | 5 | All I2C devices | + +**Note:** I2C bus is shared by all I2C devices. Each device has a unique address. + +## I2C Device Addresses + +| Address | Device | Description | +|---------|--------|-------------| +| 0x38 | FT5316 | Capacitive touch panel | +| 0x14 | BMA400 | 3-axis accelerometer (optional) | +| 0x68 | PCF8523 | Real-time clock (optional) | +| 0x40 | INA219 | Power monitor (optional) | + +## Physical Layout + +``` +Raspberry Pi GPIO Header (BCM Numbering) + + 3V3 (1) (2) 5V + GPIO2 (3) (4) 5V ← I2C1 SDA (touch, sensors) + GPIO3 (5) (6) GND ← I2C1 SCL + GPIO4 (7) (8) GPIO14 + GND (9) (10) GPIO15 + GPIO17 (11) (12) GPIO18 ← Display RST + GPIO27 (13) (14) GND + GPIO22 (15) (16) GPIO23 + 3V3 (17) (18) GPIO24 ← Display HRDY + GPIO10 (19) (20) GND ← SPI0 MOSI + GPIO9 (21) (22) GPIO25 + GPIO11 (23) (24) GPIO8 ← SPI0 SCLK, CE0 + GND (25) (26) GPIO7 + GPIO0 (27) (28) GPIO1 + GPIO5 (29) (30) GND + GPIO6 (31) (32) GPIO12 + GPIO13 (33) (34) GND + GPIO19 (35) (36) GPIO16 + GPIO26 (37) (38) GPIO20 + GND (39) (40) GPIO21 ← Power off button + + +Button Connections: + GPIO21 ──┤ ├── GND (Power off) + GPIO22 ──┤ ├── GND (Previous page) + GPIO27 ──┤ ├── GND (Next page) +``` + +## Power Requirements + +- **Input:** 5V via USB-C or GPIO header +- **Display:** ~3.3V, peak 500mA during refresh +- **Touch Panel:** 3.3V, ~20mA +- **Total (active):** ~1-2W +- **Total (sleep):** ~50-100mW + +## Configuration Files + +### hardware_config.json + +```json +{ + "display": { + "width": 1872, + "height": 1404, + "vcom": -2.0, + "spi_hz": 24000000 + }, + "gpio_buttons": { + "enabled": true, + "buttons": [ + {"name": "prev_page", "gpio": 22, "gesture": "swipe_right"}, + {"name": "next_page", "gpio": 27, "gesture": "swipe_left"}, + {"name": "power_off", "gpio": 21, "gesture": "long_press"} + ] + } +} +``` + +## Testing Connections + +### Check I2C Devices + +```bash +# Scan I2C bus 1 (GPIO 2/3) +i2cdetect -y 1 + +# Expected output: +# 0 1 2 3 4 5 6 7 8 9 a b c d e f +# 00: -- -- -- -- -- -- -- -- -- -- -- -- -- +# 10: -- -- -- -- 14 -- -- -- -- -- -- -- -- -- -- -- +# 20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- +# 30: -- -- -- -- -- -- -- -- 38 -- -- -- -- -- -- -- +# 40: 40 -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- +# 50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- +# 60: -- -- -- -- -- -- -- -- 68 -- -- -- -- -- -- -- +# 70: -- -- -- -- -- -- -- -- +``` + +### Check SPI + +```bash +ls /dev/spi* +# Should show: /dev/spidev0.0 /dev/spidev0.1 +``` + +### Test GPIO Buttons + +```bash +# Install GPIO tools +sudo apt install gpiod + +# Test previous page button (GPIO 22) +gpioget gpiochip0 22 +# Press button: shows 0 (LOW) +# Release button: shows 1 (HIGH, pulled up) + +# Test next page button (GPIO 27) +gpioget gpiochip0 27 + +# Test power button (GPIO 21) +gpioget gpiochip0 21 +``` + +## Quick Start + +```bash +# 1. Clone and install +git clone https://gitea.tourolle.paris/dtourolle/dreader-application.git +cd dreader-application +python3 -m venv venv +source venv/bin/activate +pip install -e . +./install_hardware_drivers.sh + +# 2. Run interactive setup +sudo python3 setup_rpi.py + +# 3. Run DReader +python examples/run_on_hardware_config.py +``` + +## Troubleshooting + +### No I2C Devices Detected + +```bash +# Enable I2C +sudo raspi-config +# Navigate to: Interface Options -> I2C -> Enable + +# Check I2C is loaded +lsmod | grep i2c +# Should show: i2c_dev, i2c_bcm2835 + +# Add user to i2c group +sudo usermod -a -G i2c $USER +# Log out and back in +``` + +### SPI Not Working + +```bash +# Enable SPI +sudo raspi-config +# Navigate to: Interface Options -> SPI -> Enable + +# Check SPI devices +ls -l /dev/spi* + +# Add user to spi group +sudo usermod -a -G spi $USER +``` + +### Buttons Not Responding + +```bash +# Add user to gpio group +sudo usermod -a -G gpio $USER + +# Test button with direct GPIO access +sudo gpioget gpiochip0 22 # Prev button +sudo gpioget gpiochip0 27 # Next button +sudo gpioget gpiochip0 21 # Power button + +# Check for conflicts +# Make sure no other programs are using these GPIOs +``` + +## See Also + +- [HARDWARE_SETUP.md](HARDWARE_SETUP.md) - Complete setup guide +- [GPIO_BUTTONS.md](GPIO_BUTTONS.md) - Button configuration reference +- [hardware_config.json](hardware_config.json) - Hardware configuration file diff --git a/HARDWARE_SETUP.md b/HARDWARE_SETUP.md new file mode 100644 index 0000000..b4e98e1 --- /dev/null +++ b/HARDWARE_SETUP.md @@ -0,0 +1,472 @@ +# Hardware Integration Guide + +This guide explains how to run DReader on real e-ink hardware using the dreader-hal library. + +## Quick Start + +**On Raspberry Pi:** +```bash +# 1. Clone and setup +git clone https://gitea.tourolle.paris/dtourolle/dreader-application.git +cd dreader-application +python3 -m venv venv +source venv/bin/activate + +# 2. Install everything +pip install -e . +./install_hardware_drivers.sh + +# 3. Run interactive setup (detects hardware and configures) +sudo python3 setup_rpi.py + +# 4. Run DReader (uses hardware_config.json) +python examples/run_on_hardware_config.py +``` + +## Overview + +The DReader application uses a plugin-based Hardware Abstraction Layer (HAL) architecture. You can choose different HAL implementations: + +- **PygameDisplayHAL** ([dreader/hal_pygame.py](dreader/hal_pygame.py)) - Desktop testing with pygame window +- **HardwareDisplayHAL** ([dreader/hal_hardware.py](dreader/hal_hardware.py)) - Real e-ink hardware via dreader-hal + +### Configuration Files + +- **hardware_config.json** - Hardware configuration (display, buttons, sensors) +- **accelerometer_config.json** - Accelerometer calibration for tilt gestures + +## Hardware Requirements + +### Required Components +- **Raspberry Pi** (or compatible SBC) +- **IT8951 E-ink Display Controller** (1872×1404 resolution) +- **FT5316 Capacitive Touch Panel** + +### Optional Components +- **BMA400 Accelerometer** - Auto-rotation based on device orientation +- **PCF8523 RTC** - Timekeeping with battery backup +- **INA219 Power Monitor** - Battery level monitoring + +## Software Installation + +### 1. Install System Dependencies + +On Raspberry Pi: + +```bash +# Update package list +sudo apt update + +# Install system dependencies +sudo apt install -y python3-dev python3-pip python3-venv +sudo apt install -y i2c-tools python3-smbus + +# Enable I2C and SPI interfaces +sudo raspi-config +# Navigate to: Interface Options -> Enable I2C and SPI +``` + +### 2. Clone and Set Up DReader Application + +```bash +# Clone the application +git clone https://gitea.tourolle.paris/dtourolle/dreader-application.git +cd dreader-application + +# Initialize and update submodules (includes dreader-hal) +git submodule update --init --recursive + +# Create virtual environment +python3 -m venv venv +source venv/bin/activate + +# Install dreader-application +pip install -e . +``` + +### 3. Install dreader-hal and Dependencies + +The dreader-hal library has external driver dependencies in its `external/` directory: + +```bash +# Install dreader-hal in editable mode +pip install -e external/dreader-hal + +# Install external driver libraries +cd external/dreader-hal/external + +# Install each driver +pip install -e IT8951 +pip install -e PyFTtxx6 +pip install -e PyBMA400 +pip install -e PyPCF8523 +pip install -e pi_ina219 + +cd ../../.. # Back to dreader-application root +``` + +### 4. Install Raspberry Pi GPIO (if on RPi) + +```bash +pip install RPi.GPIO spidev +``` + +## Hardware Wiring + +### IT8951 E-ink Display (SPI) +| IT8951 Pin | Raspberry Pi Pin | Description | +|------------|------------------|-------------| +| VCC | 3.3V (Pin 1) | Power supply | +| GND | GND (Pin 6) | Ground | +| MISO | GPIO 9 (Pin 21) | SPI MISO | +| MOSI | GPIO 10 (Pin 19) | SPI MOSI | +| SCK | GPIO 11 (Pin 23) | SPI Clock | +| CS | GPIO 8 (Pin 24) | SPI Chip Select | +| RST | GPIO 17 (Pin 11) | Reset | +| HRDY | GPIO 24 (Pin 18) | Ready signal | + +### FT5316 Touch Panel (I2C) +| FT5316 Pin | Raspberry Pi Pin | Description | +|------------|------------------|-------------| +| VCC | 3.3V (Pin 1) | Power supply | +| GND | GND (Pin 6) | Ground | +| SDA | GPIO 2 (Pin 3) | I2C Data | +| SCL | GPIO 3 (Pin 5) | I2C Clock | +| INT | GPIO 27 (Pin 13) | Interrupt (optional) | + +### BMA400 Accelerometer (I2C) - Optional +| BMA400 Pin | Raspberry Pi Pin | Description | +|------------|------------------|-------------| +| VCC | 3.3V (Pin 1) | Power supply | +| GND | GND (Pin 6) | Ground | +| SDA | GPIO 2 (Pin 3) | I2C Data | +| SCL | GPIO 3 (Pin 5) | I2C Clock | +| I2C Address | 0x14 or 0x15 | Check your module | + +### PCF8523 RTC (I2C) - Optional +| PCF8523 Pin | Raspberry Pi Pin | Description | +|------------|------------------|-------------| +| VCC | 3.3V (Pin 1) | Power supply | +| GND | GND (Pin 6) | Ground | +| SDA | GPIO 2 (Pin 3) | I2C Data | +| SCL | GPIO 3 (Pin 5) | I2C Clock | +| BAT | CR2032 Battery | Backup battery | + +### INA219 Power Monitor (I2C) - Optional +| INA219 Pin | Raspberry Pi Pin | Description | +|------------|------------------|-------------| +| VCC | 3.3V (Pin 1) | Power supply | +| GND | GND (Pin 6) | Ground | +| SDA | GPIO 2 (Pin 3) | I2C Data | +| SCL | GPIO 3 (Pin 5) | I2C Clock | +| VIN+ | Battery + | Positive voltage sense | +| VIN- | Shunt resistor | Through shunt to load | + +**Note**: Multiple I2C devices can share the same SDA/SCL pins. Ensure each has a unique I2C address. + +### GPIO Buttons (Optional) + +Physical buttons for navigation: + +| Button Function | GPIO Pin | Connection | +|----------------|----------|------------| +| Previous Page | GPIO 22 | Button between GPIO 22 and GND | +| Next Page | GPIO 27 | Button between GPIO 27 and GND | +| Power Off | GPIO 21 | Button between GPIO 21 and GND | + +**Wiring:** +- Connect one side of button to GPIO pin +- Connect other side to GND +- Internal pull-up resistors are enabled in software +- Button press pulls GPIO LOW (0V) + +**Available GPIOs** (BCM numbering): +- Safe to use: 5-27 (except 14, 15 if using UART) +- Avoid: GPIO 2, 3 (I2C), GPIO 7-11 (SPI), GPIO 14, 15 (UART) + +## Verify Hardware Connections + +### Check I2C Devices + +```bash +# Scan I2C bus +i2cdetect -y 1 + +# Expected addresses (approximate): +# 0x38 - FT5316 touch panel +# 0x14 - BMA400 accelerometer +# 0x68 - PCF8523 RTC +# 0x40 - INA219 power monitor +``` + +### Check SPI + +```bash +# List SPI devices +ls /dev/spi* +# Should show: /dev/spidev0.0 /dev/spidev0.1 +``` + +## Important: VCOM Voltage + +⚠️ **CRITICAL**: Each e-ink display has a unique VCOM voltage printed on a label (usually on the back). + +- Check your display label for VCOM voltage (e.g., -2.06V, -1.98V, etc.) +- Pass this value to the HAL using the `--vcom` parameter +- Using incorrect VCOM can damage your display! + +Example from label: `VCOM = -2.06V` → use `--vcom -2.06` + +## Running on Hardware + +### Recommended Method: Interactive Setup + +The easiest way to get started is using the interactive setup script: + +```bash +# 1. Run setup (detects hardware, configures GPIO buttons, etc.) +sudo python3 setup_rpi.py + +# 2. Run DReader using generated config +python examples/run_on_hardware_config.py +``` + +The setup script will: +- Detect connected I2C devices (touch, accelerometer, RTC, power monitor) +- Enable I2C/SPI interfaces if needed +- Configure GPIO button mappings +- Set VCOM voltage +- Generate hardware_config.json + +### Manual Configuration + +Edit `hardware_config.json` to customize your hardware setup: + +```json +{ + "display": { + "width": 1872, + "height": 1404, + "vcom": -2.06 + }, + "gpio_buttons": { + "enabled": true, + "buttons": [ + {"name": "prev_page", "gpio": 22, "gesture": "swipe_right"}, + {"name": "next_page", "gpio": 27, "gesture": "swipe_left"}, + {"name": "power_off", "gpio": 21, "gesture": "long_press"} + ] + } +} +``` + +Then run: +```bash +python examples/run_on_hardware_config.py +``` + +### Direct Command Line (No Config File) + +```bash +# Activate virtual environment +source venv/bin/activate + +# Run with correct VCOM voltage (CHECK YOUR DISPLAY LABEL!) +python examples/run_on_hardware.py /path/to/books --vcom -2.06 +``` + +### Testing Without Hardware (Virtual Display) + +You can test the integration on your development machine using virtual display mode: + +```bash +python examples/run_on_hardware.py /path/to/books \ + --virtual \ + --no-orientation \ + --no-rtc \ + --no-power +``` + +This creates a Tkinter window simulating the e-ink display. + +### Advanced Options + +```bash +# Disable optional hardware components +python examples/run_on_hardware.py /path/to/books \ + --vcom -2.06 \ + --no-orientation \ # Disable accelerometer + --no-rtc \ # Disable RTC + --no-power # Disable battery monitor + +# Show battery level periodically +python examples/run_on_hardware.py /path/to/books \ + --vcom -2.06 \ + --show-battery + +# Custom battery capacity +python examples/run_on_hardware.py /path/to/books \ + --vcom -2.06 \ + --battery-capacity 5000 # mAh + +# Enable verbose debug logging +python examples/run_on_hardware.py /path/to/books \ + --vcom -2.06 \ + --verbose + +# Always start in library mode (ignore saved state) +python examples/run_on_hardware.py /path/to/books \ + --vcom -2.06 \ + --force-library +``` + +### Full Options Reference + +```bash +python examples/run_on_hardware.py --help +``` + +## Touch Gestures + +Once running, the following touch gestures are supported: + +| Gesture | Action | +|---------|--------| +| **Swipe Left** | Next page | +| **Swipe Right** | Previous page | +| **Swipe Up** (from bottom) | Open navigation/TOC overlay | +| **Swipe Down** (from top) | Open settings overlay | +| **Tap** | Select book, word, or link | +| **Long Press** | Show word definition (if implemented) | +| **Pinch In/Out** | Adjust font size | + +## Troubleshooting + +### Display Not Working + +1. **Check VCOM voltage** - Must match label on display +2. **Check SPI connections** - Run `ls /dev/spi*` +3. **Check SPI permissions** - Add user to `spi` group: `sudo usermod -a -G spi $USER` +4. **Try virtual display mode** - Test software without hardware + +### Touch Not Working + +1. **Check I2C connections** - Run `i2cdetect -y 1` +2. **Check I2C permissions** - Add user to `i2c` group: `sudo usermod -a -G i2c $USER` +3. **Check touch panel I2C address** - Should be 0x38 for FT5316 +4. **Calibrate touch** - See dreader-hal calibration docs + +### Import Errors + +If you see `ModuleNotFoundError` for drivers: + +```bash +# Ensure all external drivers are installed +cd external/dreader-hal/external +for dir in */; do pip install -e "$dir"; done +cd ../../.. +``` + +### Permission Errors + +```bash +# Add user to required groups +sudo usermod -a -G spi,i2c,gpio $USER + +# Log out and back in for changes to take effect +``` + +### Display Ghosting + +E-ink displays can show ghosting (image retention). The HAL automatically performs full refreshes every 10 page turns, but you can force one: + +- The `RefreshMode.FULL` is automatically triggered periodically +- Check dreader-hal documentation for manual refresh control + +## Programmatic Usage + +You can also use the hardware HAL programmatically in your own scripts: + +```python +import asyncio +from dreader.hal_hardware import HardwareDisplayHAL +from dreader.main import DReaderApplication, AppConfig + +async def main(): + # Create hardware HAL + hal = HardwareDisplayHAL( + width=1872, + height=1404, + vcom=-2.06, # YOUR DISPLAY'S VCOM! + virtual_display=False, + enable_orientation=True, + enable_rtc=True, + enable_power_monitor=True, + ) + + # Create application config + config = AppConfig( + display_hal=hal, + library_path="/home/pi/Books", + page_size=(1872, 1404), + ) + + # Create and run application + app = DReaderApplication(config) + + try: + await hal.initialize() + await app.start() + + # Main event loop + while app.is_running(): + event = await hal.get_touch_event() + if event: + await app.handle_touch(event) + await asyncio.sleep(0.01) + + finally: + await app.shutdown() + await hal.cleanup() + +if __name__ == '__main__': + asyncio.run(main()) +``` + +## Performance Notes + +### E-ink Refresh Modes + +The dreader-hal library automatically selects refresh modes: + +- **Fast (DU mode)**: ~200ms - Used for text updates +- **Quality (GC16 mode)**: ~1000ms - Used for images +- **Full (INIT mode)**: ~1000ms - Used every 10 pages to clear ghosting + +### Battery Life + +With default settings: +- Active reading: ~10-20 hours +- Standby (display sleeping): ~1-2 weeks +- Enable low power mode for extended battery life + +### Memory Usage + +- Base application: ~30-50MB +- Per book: ~10-30MB (depends on book size) +- Ensure Raspberry Pi has at least 512MB RAM + +## Next Steps + +- See [README.md](README.md) for application features +- See [external/dreader-hal/README.md](external/dreader-hal/README.md) for HAL details +- See [examples/](examples/) for more usage examples +- Check dreader-hal documentation for touch calibration and advanced features + +## Support + +For hardware-specific issues, check: +- [dreader-hal issues](https://gitea.tourolle.paris/dtourolle/dreader-hal/issues) + +For application issues, check: +- [dreader-application issues](https://gitea.tourolle.paris/dtourolle/dreader-application/issues) diff --git a/README.md b/README.md index ce401cc..a8a6570 100644 --- a/README.md +++ b/README.md @@ -471,35 +471,60 @@ pytest --cov=dreader --cov-report=term-missing ## Hardware Integration -DReader requires a Hardware Abstraction Layer (HAL) for display and input: +DReader includes complete hardware support for e-ink displays via the **dreader-hal** library. -```python -from abc import ABC, abstractmethod -from PIL import Image +### Supported Hardware -class DisplayHAL(ABC): - """Abstract display interface for platform integration""" +- **Display**: IT8951 e-ink controller (1872×1404) +- **Touch**: FT5316 capacitive touch panel +- **Buttons**: GPIO buttons (configurable) +- **Sensors**: BMA400 accelerometer, PCF8523 RTC, INA219 power monitor - @abstractmethod - def show_image(self, image: Image.Image): - """Display a PIL Image on the screen""" +### Quick Setup on Raspberry Pi - @abstractmethod - def get_touch_events(self) -> Iterator[TouchEvent]: - """Get iterator of touch events from hardware""" +```bash +# 1. Clone and install +git clone https://gitea.tourolle.paris/dtourolle/dreader-application.git +cd dreader-application +python3 -m venv venv +source venv/bin/activate +pip install -e . +./install_hardware_drivers.sh - @abstractmethod - def set_brightness(self, level: int): - """Set display brightness (0-10)""" +# 2. Interactive hardware setup +sudo python3 setup_rpi.py + +# 3. Run on hardware +python examples/run_on_hardware_config.py ``` -**Example HAL Implementations:** -- **E-ink Displays**: IT8951, Remarkable device SDK -- **Desktop**: pygame, tkinter, Qt -- **Web**: Flask + HTML canvas -- **Embedded**: Device-specific framebuffer +### Hardware Configuration -See [HAL_IMPLEMENTATION_SPEC.md](HAL_IMPLEMENTATION_SPEC.md) for detailed integration guidelines. +The repository includes a pre-configured **[hardware_config.json](hardware_config.json)** for the reference hardware: + +- **Buttons**: GPIO 22 (prev), GPIO 27 (next), GPIO 21 (power) +- **Display**: 1872×1404 IT8951 e-ink +- **I2C Bus**: GPIO 2/3 (touch, sensors, RTC, power) + +See [HARDWARE_SETUP.md](HARDWARE_SETUP.md) for complete wiring diagrams and setup instructions. + +### HAL Architecture + +```python +from dreader.hal_hardware import HardwareDisplayHAL +from dreader.main import DReaderApplication, AppConfig + +# Hardware HAL with GPIO buttons +hal = HardwareDisplayHAL(width=1872, height=1404, vcom=-2.0) +config = AppConfig(display_hal=hal, library_path="~/Books") +app = DReaderApplication(config) +``` + +**Available HAL Implementations:** +- **HardwareDisplayHAL** - Real e-ink hardware (IT8951 + dreader-hal) +- **PygameDisplayHAL** - Desktop testing with pygame window + +See [HARDWARE_PINOUT.md](HARDWARE_PINOUT.md) for pin assignments and [GPIO_BUTTONS.md](GPIO_BUTTONS.md) for button configuration. ## Documentation diff --git a/dreader/gesture.py b/dreader/gesture.py index ffcd2aa..03098ae 100644 --- a/dreader/gesture.py +++ b/dreader/gesture.py @@ -25,6 +25,10 @@ class GestureType(Enum): DRAG_MOVE = "drag_move" # Continue dragging DRAG_END = "drag_end" # End dragging/selection + # Accelerometer-based gestures + TILT_FORWARD = "tilt_forward" # Tilt device forward (page forward) + TILT_BACKWARD = "tilt_backward" # Tilt device backward (page back) + @dataclass class TouchEvent: diff --git a/dreader/gpio_buttons.py b/dreader/gpio_buttons.py new file mode 100644 index 0000000..b94e407 --- /dev/null +++ b/dreader/gpio_buttons.py @@ -0,0 +1,291 @@ +""" +GPIO Button Handler for DReader. + +This module provides GPIO button support for physical buttons on the e-reader device. +Buttons can be mapped to touch gestures for navigation and control. + +Usage: + from dreader.gpio_buttons import GPIOButtonHandler + + buttons = GPIOButtonHandler(config) + await buttons.initialize() + + # Check for button events + event = await buttons.get_button_event() + if event: + print(f"Button pressed: {event.gesture}") + + await buttons.cleanup() +""" + +import asyncio +import logging +from typing import Optional, Dict, List +from dataclasses import dataclass + +from .gesture import TouchEvent, GestureType + +logger = logging.getLogger(__name__) + +# Try to import RPi.GPIO +try: + import RPi.GPIO as GPIO + GPIO_AVAILABLE = True +except ImportError: + GPIO_AVAILABLE = False + logger.warning("RPi.GPIO not available. Button support disabled.") + + +@dataclass +class ButtonConfig: + """Configuration for a single GPIO button.""" + name: str + gpio: int + gesture: GestureType + description: str = "" + + +class GPIOButtonHandler: + """ + Handler for GPIO buttons that generates touch events. + + This class manages physical buttons connected to GPIO pins and converts + button presses into TouchEvent objects that can be handled by the application. + + Args: + buttons: List of ButtonConfig objects defining button mappings + pull_up: Use pull-up resistors (default True) + bounce_time_ms: Debounce time in milliseconds (default 200) + screen_width: Screen width for generating touch coordinates (default 1872) + screen_height: Screen height for generating touch coordinates (default 1404) + + Example: + buttons_config = [ + ButtonConfig("next", 23, GestureType.SWIPE_LEFT, "Next page"), + ButtonConfig("prev", 24, GestureType.SWIPE_RIGHT, "Previous page"), + ] + + handler = GPIOButtonHandler(buttons_config) + await handler.initialize() + + # In main loop + event = await handler.get_button_event() + if event: + await app.handle_touch(event) + """ + + def __init__( + self, + buttons: List[ButtonConfig], + pull_up: bool = True, + bounce_time_ms: int = 200, + screen_width: int = 1872, + screen_height: int = 1404, + ): + """Initialize GPIO button handler.""" + self.buttons = buttons + self.pull_up = pull_up + self.bounce_time_ms = bounce_time_ms + self.screen_width = screen_width + self.screen_height = screen_height + + self._initialized = False + self._event_queue: asyncio.Queue = asyncio.Queue() + self._gpio_map: Dict[int, ButtonConfig] = {} + + if not GPIO_AVAILABLE: + logger.error("RPi.GPIO not available. Buttons will not work.") + return + + logger.info(f"GPIO button handler created with {len(buttons)} buttons") + for btn in buttons: + logger.info(f" Button '{btn.name}' on GPIO {btn.gpio} -> {btn.gesture.value}") + + async def initialize(self): + """Initialize GPIO pins and set up button callbacks.""" + if self._initialized: + return + + if not GPIO_AVAILABLE: + logger.warning("Cannot initialize buttons: RPi.GPIO not available") + return + + logger.info("Initializing GPIO buttons...") + + # Set GPIO mode + GPIO.setmode(GPIO.BCM) + GPIO.setwarnings(False) + + # Configure each button + for button in self.buttons: + try: + # Configure pin + if self.pull_up: + GPIO.setup(button.gpio, GPIO.IN, pull_up_down=GPIO.PUD_UP) + # Button pressed = LOW (0) + edge = GPIO.FALLING + else: + GPIO.setup(button.gpio, GPIO.IN, pull_up_down=GPIO.PUD_DOWN) + # Button pressed = HIGH (1) + edge = GPIO.RISING + + # Add event detection with debounce + GPIO.add_event_detect( + button.gpio, + edge, + callback=lambda channel, btn=button: self._button_callback(btn), + bouncetime=self.bounce_time_ms + ) + + self._gpio_map[button.gpio] = button + logger.info(f"✓ Configured button '{button.name}' on GPIO {button.gpio}") + + except Exception as e: + logger.error(f"Failed to configure button '{button.name}' on GPIO {button.gpio}: {e}") + + self._initialized = True + logger.info("GPIO buttons initialized successfully") + + def _button_callback(self, button: ButtonConfig): + """ + Callback function for button press (runs in GPIO event thread). + + This is called by RPi.GPIO when a button is pressed. We put the event + in a queue for async processing. + """ + logger.debug(f"Button pressed: {button.name} (GPIO {button.gpio})") + + # Create touch event + # Use center of screen for button events (x, y don't matter for swipes) + event = TouchEvent( + gesture=button.gesture, + x=self.screen_width // 2, + y=self.screen_height // 2, + ) + + # Put in queue (non-blocking) + try: + self._event_queue.put_nowait(event) + logger.info(f"Button event queued: {button.name} -> {button.gesture.value}") + except asyncio.QueueFull: + logger.warning("Button event queue full, dropping event") + + async def get_button_event(self) -> Optional[TouchEvent]: + """ + Get the next button event from the queue. + + Returns: + TouchEvent if a button was pressed, None if no events + """ + if not self._initialized: + return None + + try: + # Non-blocking get + event = self._event_queue.get_nowait() + return event + except asyncio.QueueEmpty: + return None + + async def cleanup(self): + """Clean up GPIO resources.""" + if not self._initialized: + return + + if not GPIO_AVAILABLE: + return + + logger.info("Cleaning up GPIO buttons...") + + try: + # Remove event detection for all buttons + for button in self.buttons: + try: + GPIO.remove_event_detect(button.gpio) + except Exception as e: + logger.debug(f"Error removing event detect for GPIO {button.gpio}: {e}") + + # Clean up GPIO + GPIO.cleanup() + logger.info("GPIO buttons cleaned up") + + except Exception as e: + logger.error(f"Error during GPIO cleanup: {e}") + + self._initialized = False + + +def load_button_config_from_dict(config: dict, screen_width: int = 1872, screen_height: int = 1404) -> Optional[GPIOButtonHandler]: + """ + Load GPIO button configuration from a dictionary. + + Args: + config: Configuration dictionary with 'gpio_buttons' section + screen_width: Screen width for touch coordinates + screen_height: Screen height for touch coordinates + + Returns: + GPIOButtonHandler instance if buttons enabled, None otherwise + + Example config: + { + "gpio_buttons": { + "enabled": true, + "pull_up": true, + "bounce_time_ms": 200, + "buttons": [ + { + "name": "next_page", + "gpio": 23, + "gesture": "swipe_left", + "description": "Next page" + } + ] + } + } + """ + gpio_config = config.get("gpio_buttons", {}) + + if not gpio_config.get("enabled", False): + logger.info("GPIO buttons disabled in config") + return None + + if not GPIO_AVAILABLE: + logger.warning("GPIO buttons enabled in config but RPi.GPIO not available") + return None + + # Parse button configurations + buttons = [] + for btn_cfg in gpio_config.get("buttons", []): + try: + # Parse gesture type + gesture_str = btn_cfg["gesture"] + gesture = GestureType(gesture_str) + + button = ButtonConfig( + name=btn_cfg["name"], + gpio=btn_cfg["gpio"], + gesture=gesture, + description=btn_cfg.get("description", "") + ) + buttons.append(button) + + except Exception as e: + logger.error(f"Error parsing button config: {e}") + logger.error(f" Config: {btn_cfg}") + continue + + if not buttons: + logger.warning("No valid button configurations found") + return None + + # Create handler + handler = GPIOButtonHandler( + buttons=buttons, + pull_up=gpio_config.get("pull_up", True), + bounce_time_ms=gpio_config.get("bounce_time_ms", 200), + screen_width=screen_width, + screen_height=screen_height, + ) + + return handler diff --git a/dreader/hal_hardware.py b/dreader/hal_hardware.py new file mode 100644 index 0000000..884dd74 --- /dev/null +++ b/dreader/hal_hardware.py @@ -0,0 +1,593 @@ +""" +Hardware HAL implementation using dreader-hal library. + +This module provides the HardwareDisplayHAL class that bridges the DReader +application HAL interface with the dreader-hal hardware abstraction layer. + +The dreader-hal library provides complete e-ink display integration with: +- IT8951 e-ink display driver +- FT5xx6 capacitive touch sensor +- BMA400 accelerometer (orientation) +- PCF8523 RTC (timekeeping) +- INA219 power monitor (battery) + +Usage: + from dreader.hal_hardware import HardwareDisplayHAL + from dreader.main import DReaderApplication, AppConfig + + # For real hardware + hal = HardwareDisplayHAL(width=800, height=1200, vcom=-2.0) + + # For testing without hardware + hal = HardwareDisplayHAL( + width=800, + height=1200, + virtual_display=True, + enable_orientation=False, + enable_rtc=False, + enable_power_monitor=False + ) + + config = AppConfig(display_hal=hal, library_path="~/Books") + app = DReaderApplication(config) + + await hal.initialize() + await app.start() + + # Main loop + while app.is_running(): + event = await hal.get_touch_event() + if event: + await app.handle_touch(event) + await asyncio.sleep(0.01) + + await app.shutdown() + await hal.cleanup() +""" + +import asyncio +import logging +import json +import math +import time +from typing import Optional +from pathlib import Path +from PIL import Image + +from .hal import DisplayHAL +from .gesture import TouchEvent as AppTouchEvent, GestureType as AppGestureType + +# Import dreader-hal components +try: + from dreader_hal import ( + EReaderDisplayHAL, + TouchEvent as HalTouchEvent, + GestureType as HalGestureType, + RefreshMode, + PowerStats, + Orientation + ) + DREADER_HAL_AVAILABLE = True +except ImportError as e: + DREADER_HAL_AVAILABLE = False + _import_error = e + +logger = logging.getLogger(__name__) + + +# Gesture type mapping between dreader-hal and dreader-application +GESTURE_TYPE_MAP = { + HalGestureType.TAP: AppGestureType.TAP, + HalGestureType.LONG_PRESS: AppGestureType.LONG_PRESS, + HalGestureType.SWIPE_LEFT: AppGestureType.SWIPE_LEFT, + HalGestureType.SWIPE_RIGHT: AppGestureType.SWIPE_RIGHT, + HalGestureType.SWIPE_UP: AppGestureType.SWIPE_UP, + HalGestureType.SWIPE_DOWN: AppGestureType.SWIPE_DOWN, + HalGestureType.PINCH_IN: AppGestureType.PINCH_IN, + HalGestureType.PINCH_OUT: AppGestureType.PINCH_OUT, + HalGestureType.DRAG_START: AppGestureType.DRAG_START, + HalGestureType.DRAG_MOVE: AppGestureType.DRAG_MOVE, + HalGestureType.DRAG_END: AppGestureType.DRAG_END, +} + + +class HardwareDisplayHAL(DisplayHAL): + """ + Hardware HAL implementation using dreader-hal library. + + This class adapts the dreader-hal EReaderDisplayHAL to work with the + DReader application's DisplayHAL interface. + + Args: + width: Display width in pixels (default 1872) + height: Display height in pixels (default 1404) + vcom: E-ink VCOM voltage (default -2.0, check device label!) + spi_hz: SPI clock frequency (default 24MHz) + virtual_display: Use virtual display for testing (default False) + auto_sleep_display: Auto-sleep display after updates (default True) + enable_orientation: Enable orientation sensing (default True) + enable_rtc: Enable RTC timekeeping (default True) + enable_power_monitor: Enable battery monitoring (default True) + shunt_ohms: Power monitor shunt resistor (default 0.1) + battery_capacity_mah: Battery capacity in mAh (default 3000) + + Example: + # For real hardware (Raspberry Pi with e-ink display) + hal = HardwareDisplayHAL(width=1872, height=1404, vcom=-2.0) + + # For testing on development machine + hal = HardwareDisplayHAL( + width=1872, + height=1404, + virtual_display=True, + enable_orientation=False, + enable_rtc=False, + enable_power_monitor=False + ) + """ + + def __init__( + self, + width: int = 1872, + height: int = 1404, + vcom: float = -2.0, + spi_hz: int = 24_000_000, + virtual_display: bool = False, + auto_sleep_display: bool = True, + enable_orientation: bool = True, + enable_rtc: bool = True, + enable_power_monitor: bool = True, + shunt_ohms: float = 0.1, + battery_capacity_mah: float = 3000, + ): + """ + Initialize hardware HAL. + + Raises: + ImportError: If dreader-hal library is not installed + """ + if not DREADER_HAL_AVAILABLE: + raise ImportError( + f"dreader-hal library is required for HardwareDisplayHAL.\n" + f"Install with: pip install -e external/dreader-hal\n" + f"Original error: {_import_error}" + ) + + self.width = width + self.height = height + + logger.info(f"Initializing HardwareDisplayHAL: {width}x{height}") + logger.info(f" VCOM: {vcom}V") + logger.info(f" Virtual display: {virtual_display}") + logger.info(f" Orientation: {enable_orientation}") + logger.info(f" RTC: {enable_rtc}") + logger.info(f" Power monitor: {enable_power_monitor}") + + # Create the underlying dreader-hal implementation + self.hal = EReaderDisplayHAL( + width=width, + height=height, + vcom=vcom, + spi_hz=spi_hz, + virtual_display=virtual_display, + auto_sleep_display=auto_sleep_display, + enable_orientation=enable_orientation, + enable_rtc=enable_rtc, + enable_power_monitor=enable_power_monitor, + shunt_ohms=shunt_ohms, + battery_capacity_mah=battery_capacity_mah, + ) + + self._initialized = False + + async def initialize(self): + """ + Initialize all hardware components. + + This initializes: + - E-ink display controller + - Touch sensor + - Accelerometer (if enabled) + - RTC (if enabled) + - Power monitor (if enabled) + """ + if self._initialized: + logger.warning("Hardware HAL already initialized") + return + + logger.info("Initializing hardware components...") + await self.hal.initialize() + self._initialized = True + logger.info("Hardware HAL initialized successfully") + + async def cleanup(self): + """Clean up all hardware resources.""" + if not self._initialized: + return + + logger.info("Cleaning up hardware HAL") + await self.hal.cleanup() + self._initialized = False + logger.info("Hardware HAL cleaned up") + + async def show_image(self, image: Image.Image): + """ + Display a PIL Image on the e-ink screen. + + Args: + image: PIL Image to display + + The dreader-hal library handles: + - Format conversion (RGB -> grayscale) + - Dithering for e-ink + - Refresh mode selection (auto, fast, quality, full) + - Orientation rotation (if enabled) + """ + if not self._initialized: + logger.warning("Hardware HAL not initialized, initializing now...") + await self.initialize() + + logger.debug(f"Displaying image: {image.size} {image.mode}") + await self.hal.show_image(image) + + async def get_touch_event(self) -> Optional[AppTouchEvent]: + """ + Get the next touch event from hardware. + + Returns: + TouchEvent if available, None if no event + + The dreader-hal library handles gesture classification: + - TAP: Quick tap (< 30px movement, < 300ms) + - LONG_PRESS: Hold (< 30px movement, >= 500ms) + - SWIPE_*: Directional swipes (>= 30px movement) + - PINCH_IN/OUT: Two-finger pinch gestures + """ + if not self._initialized: + return None + + # Get event from dreader-hal + hal_event = await self.hal.get_touch_event() + + if hal_event is None: + return None + + # Convert from dreader-hal TouchEvent to application TouchEvent + app_gesture = GESTURE_TYPE_MAP.get(hal_event.gesture) + + if app_gesture is None: + logger.warning(f"Unknown gesture type from HAL: {hal_event.gesture}") + return None + + logger.debug(f"Touch event: {app_gesture.value} at ({hal_event.x}, {hal_event.y})") + + return AppTouchEvent( + gesture=app_gesture, + x=hal_event.x, + y=hal_event.y + ) + + async def set_brightness(self, level: int): + """ + Set display brightness. + + Args: + level: Brightness level (0-10) + + Note: + Basic IT8951 e-ink displays don't have brightness control. + This is a no-op unless frontlight hardware is connected. + """ + if not 0 <= level <= 10: + raise ValueError("Brightness must be 0-10") + + logger.debug(f"Setting brightness to {level}") + await self.hal.set_brightness(level) + + # ========== Extended Methods (Hardware-Specific Features) ========== + + async def get_battery_level(self) -> float: + """ + Get battery percentage. + + Returns: + Battery level 0-100%, or 0.0 if power monitor unavailable + """ + if not self._initialized: + return 0.0 + + return await self.hal.get_battery_level() + + async def get_power_stats(self) -> PowerStats: + """ + Get detailed power statistics. + + Returns: + PowerStats with voltage, current, power, battery %, etc. + + Raises: + RuntimeError: If power monitor not enabled + """ + if not self._initialized: + raise RuntimeError("Hardware HAL not initialized") + + return await self.hal.get_power_stats() + + async def is_low_battery(self, threshold: float = 20.0) -> bool: + """ + Check if battery is low. + + Args: + threshold: Battery percentage threshold (default 20%) + + Returns: + True if battery below threshold, False otherwise + """ + if not self._initialized: + return False + + return await self.hal.is_low_battery(threshold) + + async def set_low_power_mode(self, enabled: bool): + """ + Enable/disable low power mode. + + In low power mode: + - Display goes to sleep + - Touch polling rate reduced + - Sensors put to low power + + Args: + enabled: True to enable low power mode + """ + if not self._initialized: + return + + logger.info(f"Setting low power mode: {enabled}") + await self.hal.set_low_power_mode(enabled) + + async def enable_orientation_monitoring(self): + """ + Start monitoring device orientation changes. + + When orientation changes, display auto-rotates. + """ + if not self._initialized: + return + + logger.info("Enabling orientation monitoring") + await self.hal.enable_orientation_monitoring() + + async def disable_orientation_monitoring(self): + """Stop monitoring orientation changes.""" + if not self._initialized: + return + + logger.info("Disabling orientation monitoring") + await self.hal.disable_orientation_monitoring() + + @property + def current_orientation(self) -> Optional[Orientation]: + """Get current device orientation.""" + if not self._initialized: + return None + + return self.hal.current_orientation + + @property + def refresh_count(self) -> int: + """Get number of display refreshes since initialization.""" + if not self._initialized: + return 0 + + return self.hal.refresh_count + + async def get_datetime(self): + """ + Get current date/time from RTC. + + Returns: + struct_time with current date and time, or None if RTC unavailable + """ + if not self._initialized: + return None + + return await self.hal.get_datetime() + + async def set_datetime(self, dt): + """ + Set the RTC date/time. + + Args: + dt: time.struct_time object with date and time to set + + Raises: + RuntimeError: If RTC not enabled + """ + if not self._initialized: + raise RuntimeError("Hardware HAL not initialized") + + await self.hal.set_datetime(dt) + + # ========== Accelerometer Tilt Detection ========== + + def load_accelerometer_calibration(self, config_path: str = "accelerometer_config.json") -> bool: + """ + Load accelerometer calibration from file. + + Args: + config_path: Path to calibration JSON file + + Returns: + True if calibration loaded successfully, False otherwise + """ + config_file = Path(config_path) + if not config_file.exists(): + logger.warning(f"Accelerometer calibration file not found: {config_path}") + return False + + try: + with open(config_file, 'r') as f: + config = json.load(f) + + # Load up vector + up = config.get("up_vector", {}) + self.accel_up_vector = (up.get("x", 0), up.get("y", 0), up.get("z", 0)) + + # Load thresholds + self.accel_tilt_threshold = config.get("tilt_threshold", 0.3) + self.accel_debounce_time = config.get("debounce_time", 0.5) + + # State tracking + self.accel_last_tilt_time = 0 + + logger.info(f"Accelerometer calibration loaded: up_vector={self.accel_up_vector}") + logger.info(f" Tilt threshold: {self.accel_tilt_threshold:.2f} rad (~{math.degrees(self.accel_tilt_threshold):.1f}°)") + logger.info(f" Debounce time: {self.accel_debounce_time:.2f}s") + + return True + + except Exception as e: + logger.error(f"Error loading accelerometer calibration: {e}") + return False + + async def get_tilt_gesture(self) -> Optional[AppTouchEvent]: + """ + Check accelerometer for tilt gestures. + + Returns: + TouchEvent with TILT_FORWARD or TILT_BACKWARD gesture if detected, + None otherwise + + Note: + Requires accelerometer calibration to be loaded first via + load_accelerometer_calibration() + """ + if not self._initialized: + return None + + if not self.hal.orientation: + return None + + if not hasattr(self, 'accel_up_vector'): + return None + + # Get current acceleration + try: + ax, ay, az = await self.hal.orientation.get_acceleration() + except Exception as e: + logger.debug(f"Error reading accelerometer: {e}") + return None + + # Check debounce + current_time = time.time() + if current_time - self.accel_last_tilt_time < self.accel_debounce_time: + return None + + # Calculate angle between current gravity and calibrated "up" vector + # Gravity vector is the acceleration (pointing down) + gx, gy, gz = ax, ay, az + + # Normalize gravity + g_mag = math.sqrt(gx**2 + gy**2 + gz**2) + if g_mag < 0.1: + return None + gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag + + # Normalize up vector + ux, uy, uz = self.accel_up_vector + u_mag = math.sqrt(ux**2 + uy**2 + uz**2) + if u_mag < 0.1: + return None + ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag + + # Calculate tilt: project gravity onto the "forward/backward" axis + # Forward/backward axis is perpendicular to up vector + # We'll use the component of gravity that's perpendicular to the up vector + + # Dot product: component of gravity along up vector + dot_up = gx * ux + gy * uy + gz * uz + + # Component of gravity perpendicular to up vector + perp_x = gx - dot_up * ux + perp_y = gy - dot_up * uy + perp_z = gz - dot_up * uz + + perp_mag = math.sqrt(perp_x**2 + perp_y**2 + perp_z**2) + + # Angle from vertical (in radians) + tilt_angle = math.atan2(perp_mag, abs(dot_up)) + + logger.debug(f"Tilt angle: {math.degrees(tilt_angle):.1f}° (threshold: {math.degrees(self.accel_tilt_threshold):.1f}°)") + + # Check if tilted beyond threshold + if tilt_angle < self.accel_tilt_threshold: + return None + + # Determine direction: forward or backward + # We need to determine which direction the device is tilted + # Use the sign of the perpendicular component along a reference axis + + # For simplicity, we'll use the projection onto the original up vector's + # perpendicular plane. If we tilt "forward", the gravity vector should + # rotate in a specific direction. + + # Calculate which direction: check if tilting away from or toward the up vector + # If dot_up is decreasing (device tilting away from up), that's "forward" + # If dot_up is increasing (device tilting back toward up), that's "backward" + + # Actually, a simpler approach: check the direction of the perpendicular component + # relative to a reference direction in the plane + + # Let's define forward as tilting in the direction that increases the + # y-component of acceleration (assuming standard orientation) + # This is device-specific and may need adjustment + + # For now, use a simple heuristic: forward = positive perpendicular y component + if perp_y > 0: + gesture = AppGestureType.TILT_FORWARD + else: + gesture = AppGestureType.TILT_BACKWARD + + # Update debounce timer + self.accel_last_tilt_time = current_time + + logger.info(f"Tilt gesture detected: {gesture.value} (angle: {math.degrees(tilt_angle):.1f}°)") + + # Return gesture at center of screen (x, y don't matter for tilt) + return AppTouchEvent( + gesture=gesture, + x=self.width // 2, + y=self.height // 2, + timestamp_ms=current_time * 1000 + ) + + async def get_event(self) -> Optional[AppTouchEvent]: + """ + Get the next event from any input source (touch or accelerometer). + + This is a convenience method that polls both touch and accelerometer + in a single call, prioritizing touch events over tilt events. + + Returns: + TouchEvent from either touch sensor or accelerometer, or None if no event + + Usage: + while running: + event = await hal.get_event() + if event: + handle_gesture(event) + await asyncio.sleep(0.01) + """ + # Check touch first (higher priority) + touch_event = await self.get_touch_event() + if touch_event: + return touch_event + + # Check accelerometer tilt + if hasattr(self, 'accel_up_vector'): + tilt_event = await self.get_tilt_gesture() + if tilt_event: + return tilt_event + + return None diff --git a/dreader/handlers/gestures.py b/dreader/handlers/gestures.py index 23fa36c..40874ae 100644 --- a/dreader/handlers/gestures.py +++ b/dreader/handlers/gestures.py @@ -81,6 +81,10 @@ class GestureRouter: return self._handle_selection_move(event.x, event.y) elif event.gesture == GestureType.DRAG_END: return self._handle_selection_end(event.x, event.y) + elif event.gesture == GestureType.TILT_FORWARD: + return self._handle_page_forward() + elif event.gesture == GestureType.TILT_BACKWARD: + return self._handle_page_back() return GestureResponse(ActionType.NONE, {}) diff --git a/examples/calibrate_accelerometer.py b/examples/calibrate_accelerometer.py new file mode 100755 index 0000000..fbcac57 --- /dev/null +++ b/examples/calibrate_accelerometer.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +""" +Accelerometer Calibration Script + +This script helps calibrate the accelerometer for gravity-based page flipping. +It displays visual instructions on the e-ink display to guide the user through +aligning the device with the "up" direction. + +The calibration process: +1. Shows an arrow pointing up +2. User rotates device until arrow aligns with desired "up" direction +3. User confirms by tapping screen +4. Script saves calibration offset to config file + +Usage: + python examples/calibrate_accelerometer.py +""" + +import asyncio +import sys +import json +import math +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from dreader.hal_hardware import HardwareDisplayHAL +from dreader.gesture import GestureType + + +class AccelerometerCalibrator: + """Interactive accelerometer calibration tool""" + + def __init__(self, hal: HardwareDisplayHAL, config_path: str = "accelerometer_config.json"): + self.hal = hal + self.config_path = Path(config_path) + self.width = hal.width + self.height = hal.height + self.calibrated = False + + # Calibration data + self.up_vector = None # (x, y, z) when device is in "up" position + + async def run(self): + """Run the calibration process""" + print("Starting accelerometer calibration...") + print(f"Display: {self.width}x{self.height}") + + await self.hal.initialize() + + try: + # Show welcome screen + await self.show_welcome() + await self.wait_for_tap() + + # Calibration loop + await self.calibration_loop() + + # Show completion screen + await self.show_completion() + await asyncio.sleep(3) + + finally: + await self.hal.cleanup() + + async def show_welcome(self): + """Display welcome/instruction screen""" + img = Image.new('RGB', (self.width, self.height), color=(255, 255, 255)) + draw = ImageDraw.Draw(img) + + # Try to load a font, fall back to default + try: + title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48) + body_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32) + except: + title_font = ImageFont.load_default() + body_font = ImageFont.load_default() + + # Title + title = "Accelerometer Calibration" + title_bbox = draw.textbbox((0, 0), title, font=title_font) + title_width = title_bbox[2] - title_bbox[0] + draw.text(((self.width - title_width) // 2, 100), title, fill=(0, 0, 0), font=title_font) + + # Instructions + instructions = [ + "This will calibrate the accelerometer", + "for gravity-based page flipping.", + "", + "You will:", + "1. See an arrow on screen", + "2. Rotate device until arrow points UP", + "3. Tap screen to confirm", + "", + "Tap anywhere to begin..." + ] + + y = 250 + for line in instructions: + line_bbox = draw.textbbox((0, 0), line, font=body_font) + line_width = line_bbox[2] - line_bbox[0] + draw.text(((self.width - line_width) // 2, y), line, fill=(0, 0, 0), font=body_font) + y += 50 + + await self.hal.show_image(img) + + async def calibration_loop(self): + """Main calibration loop - show live arrow and accelerometer reading""" + print("\nCalibration mode:") + print("Rotate device until arrow points UP, then tap screen.") + + last_display_time = 0 + display_interval = 0.2 # Update display every 200ms + + while not self.calibrated: + # Get current acceleration + x, y, z = await self.hal.hal.orientation.get_acceleration() + + # Update display if enough time has passed + current_time = asyncio.get_event_loop().time() + if current_time - last_display_time >= display_interval: + await self.show_calibration_screen(x, y, z) + last_display_time = current_time + + # Check for touch event + event = await self.hal.get_touch_event() + if event and event.gesture == GestureType.TAP: + # Save current orientation as "up" + self.up_vector = (x, y, z) + self.calibrated = True + print(f"\nCalibration saved: up_vector = ({x:.2f}, {y:.2f}, {z:.2f})") + break + + await asyncio.sleep(0.05) # Poll at ~20Hz + + async def show_calibration_screen(self, ax: float, ay: float, az: float): + """ + Show arrow pointing in direction of gravity + + Args: + ax, ay, az: Acceleration components in m/s² + """ + img = Image.new('RGB', (self.width, self.height), color=(255, 255, 255)) + draw = ImageDraw.Draw(img) + + # Calculate gravity direction (normalized) + magnitude = math.sqrt(ax**2 + ay**2 + az**2) + if magnitude < 0.1: # Avoid division by zero + magnitude = 1.0 + + gx = ax / magnitude + gy = ay / magnitude + gz = az / magnitude + + # Project gravity onto screen plane (assuming z is out of screen) + # We want to show which way is "down" on the device + # Arrow should point opposite to gravity (toward "up") + arrow_dx = -gx + arrow_dy = -gy + + # Normalize for display + arrow_length = min(self.width, self.height) * 0.3 + arrow_magnitude = math.sqrt(arrow_dx**2 + arrow_dy**2) + if arrow_magnitude < 0.1: + arrow_magnitude = 1.0 + + arrow_dx = (arrow_dx / arrow_magnitude) * arrow_length + arrow_dy = (arrow_dy / arrow_magnitude) * arrow_length + + # Center point + cx = self.width // 2 + cy = self.height // 2 + + # Arrow endpoint + end_x = cx + int(arrow_dx) + end_y = cy + int(arrow_dy) + + # Draw large arrow + self.draw_arrow(draw, cx, cy, end_x, end_y, width=10) + + # Draw circle at center + circle_radius = 30 + draw.ellipse( + [(cx - circle_radius, cy - circle_radius), + (cx + circle_radius, cy + circle_radius)], + outline=(0, 0, 0), + width=5 + ) + + # Draw text with acceleration values + try: + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 28) + except: + font = ImageFont.load_default() + + text = f"X: {ax:6.2f} m/s²" + draw.text((50, 50), text, fill=(0, 0, 0), font=font) + + text = f"Y: {ay:6.2f} m/s²" + draw.text((50, 100), text, fill=(0, 0, 0), font=font) + + text = f"Z: {az:6.2f} m/s²" + draw.text((50, 150), text, fill=(0, 0, 0), font=font) + + text = "Rotate device until arrow points UP" + text_bbox = draw.textbbox((0, 0), text, font=font) + text_width = text_bbox[2] - text_bbox[0] + draw.text(((self.width - text_width) // 2, self.height - 150), + text, fill=(0, 0, 0), font=font) + + text = "Then TAP screen to save" + text_bbox = draw.textbbox((0, 0), text, font=font) + text_width = text_bbox[2] - text_bbox[0] + draw.text(((self.width - text_width) // 2, self.height - 100), + text, fill=(0, 0, 0), font=font) + + await self.hal.show_image(img) + + def draw_arrow(self, draw: ImageDraw.Draw, x1: int, y1: int, x2: int, y2: int, width: int = 5): + """Draw an arrow from (x1, y1) to (x2, y2)""" + # Main line + draw.line([(x1, y1), (x2, y2)], fill=(0, 0, 0), width=width) + + # Arrow head + dx = x2 - x1 + dy = y2 - y1 + length = math.sqrt(dx**2 + dy**2) + + if length < 0.1: + return + + # Normalize + dx /= length + dy /= length + + # Arrow head size + head_length = 40 + head_width = 30 + + # Perpendicular vector + px = -dy + py = dx + + # Arrow head points + p1_x = x2 - dx * head_length + px * head_width + p1_y = y2 - dy * head_length + py * head_width + + p2_x = x2 - dx * head_length - px * head_width + p2_y = y2 - dy * head_length - py * head_width + + # Draw arrow head + draw.polygon([(x2, y2), (p1_x, p1_y), (p2_x, p2_y)], fill=(0, 0, 0)) + + async def show_completion(self): + """Show calibration complete screen""" + img = Image.new('RGB', (self.width, self.height), color=(255, 255, 255)) + draw = ImageDraw.Draw(img) + + try: + title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48) + body_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32) + except: + title_font = ImageFont.load_default() + body_font = ImageFont.load_default() + + # Title + title = "Calibration Complete!" + title_bbox = draw.textbbox((0, 0), title, font=title_font) + title_width = title_bbox[2] - title_bbox[0] + draw.text(((self.width - title_width) // 2, 200), title, fill=(0, 0, 0), font=title_font) + + # Details + if self.up_vector: + x, y, z = self.up_vector + details = [ + f"Up vector saved:", + f"X: {x:.3f} m/s²", + f"Y: {y:.3f} m/s²", + f"Z: {z:.3f} m/s²", + "", + f"Saved to: {self.config_path}" + ] + + y_pos = 350 + for line in details: + line_bbox = draw.textbbox((0, 0), line, font=body_font) + line_width = line_bbox[2] - line_bbox[0] + draw.text(((self.width - line_width) // 2, y_pos), line, fill=(0, 0, 0), font=body_font) + y_pos += 50 + + await self.hal.show_image(img) + + # Save calibration to file + self.save_calibration() + + def save_calibration(self): + """Save calibration data to JSON file""" + if not self.up_vector: + print("Warning: No calibration data to save") + return + + x, y, z = self.up_vector + + config = { + "up_vector": { + "x": x, + "y": y, + "z": z + }, + "tilt_threshold": 0.3, # Radians (~17 degrees) + "debounce_time": 0.5, # Seconds between tilt gestures + } + + with open(self.config_path, 'w') as f: + json.dump(config, f, indent=2) + + print(f"Calibration saved to {self.config_path}") + + async def wait_for_tap(self): + """Wait for user to tap screen""" + while True: + event = await self.hal.get_touch_event() + if event and event.gesture == GestureType.TAP: + break + await asyncio.sleep(0.05) + + +async def main(): + """Main entry point""" + # Create HAL with accelerometer enabled + print("Initializing hardware...") + hal = HardwareDisplayHAL( + width=1872, + height=1404, + enable_orientation=True, + enable_rtc=False, + enable_power_monitor=False, + virtual_display=False # Set to True for testing without hardware + ) + + # Create calibrator + calibrator = AccelerometerCalibrator(hal) + + # Run calibration + await calibrator.run() + + print("\nCalibration complete!") + print("You can now use accelerometer-based page flipping.") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nCalibration cancelled by user") + sys.exit(0) + except Exception as e: + print(f"\nError during calibration: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/examples/demo_accelerometer_page_flip.py b/examples/demo_accelerometer_page_flip.py new file mode 100755 index 0000000..86ce0cc --- /dev/null +++ b/examples/demo_accelerometer_page_flip.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +Demo: Accelerometer-based Page Flipping + +This example demonstrates how to use the accelerometer for hands-free +page turning by tilting the device forward or backward. + +Features: +- Tilt device forward to advance to next page +- Tilt device backward to go to previous page +- Touch gestures still work normally +- Configurable tilt threshold and debounce time + +Prerequisites: +1. Run calibration first: python examples/calibrate_accelerometer.py +2. This creates accelerometer_config.json with calibration data + +Usage: + python examples/demo_accelerometer_page_flip.py +""" + +import asyncio +import sys +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from dreader.hal_hardware import HardwareDisplayHAL +from dreader.application import EbookReader +from dreader.gesture import GestureType + + +class AccelerometerPageFlipDemo: + """Demo application with accelerometer-based page flipping""" + + def __init__(self, epub_path: str): + self.epub_path = epub_path + + # Create HAL with accelerometer enabled + print("Initializing hardware HAL...") + self.hal = HardwareDisplayHAL( + width=1872, + height=1404, + enable_orientation=True, + enable_rtc=False, + enable_power_monitor=False, + virtual_display=False # Set to True for testing without hardware + ) + + # Create reader + print("Creating ebook reader...") + self.reader = EbookReader( + page_size=(self.hal.width, self.hal.height), + margin=60 + ) + + self.running = False + + async def run(self): + """Run the demo application""" + print("\n" + "="*60) + print("Accelerometer Page Flip Demo") + print("="*60) + + # Initialize HAL + await self.hal.initialize() + + # Load accelerometer calibration + print("\nLoading accelerometer calibration...") + calibrated = self.hal.load_accelerometer_calibration("accelerometer_config.json") + + if not calibrated: + print("\nWARNING: Accelerometer not calibrated!") + print("Please run: python examples/calibrate_accelerometer.py") + print("\nProceeding with touch gestures only...\n") + else: + print("Accelerometer calibration loaded successfully!") + print(f" Up vector: {self.hal.accel_up_vector}") + print(f" Tilt threshold: {self.hal.accel_tilt_threshold:.2f} rad") + print(f" Debounce time: {self.hal.accel_debounce_time:.2f}s") + + # Load EPUB + print(f"\nLoading EPUB: {self.epub_path}") + success = self.reader.load_epub(self.epub_path) + + if not success: + print(f"ERROR: Failed to load {self.epub_path}") + await self.hal.cleanup() + return + + print(f"Loaded: {self.reader.book_title}") + print(f"Author: {self.reader.book_author}") + + # Display first page + print("\nDisplaying first page...") + img = self.reader.get_current_page() + await self.hal.show_image(img) + + # Instructions + print("\n" + "="*60) + print("Controls:") + print(" - Tilt FORWARD to go to next page") + print(" - Tilt BACKWARD to go to previous page") + print(" - Swipe LEFT for next page (touch)") + print(" - Swipe RIGHT for previous page (touch)") + print(" - Long press to exit") + print("="*60 + "\n") + + # Main event loop + self.running = True + try: + await self.event_loop() + finally: + await self.hal.cleanup() + print("\nDemo finished!") + + async def event_loop(self): + """Main event loop - poll for touch and accelerometer events""" + accel_poll_interval = 0.05 # Check accelerometer every 50ms + + while self.running: + # Check for touch events + touch_event = await self.hal.get_touch_event() + if touch_event: + await self.handle_event(touch_event) + + # Check for accelerometer tilt events (if calibrated) + if hasattr(self.hal, 'accel_up_vector'): + tilt_event = await self.hal.get_tilt_gesture() + if tilt_event: + await self.handle_event(tilt_event) + + # Small delay to avoid busy-waiting + await asyncio.sleep(accel_poll_interval) + + async def handle_event(self, event): + """Handle a gesture event (touch or accelerometer)""" + gesture = event.gesture + print(f"Gesture: {gesture.value}") + + # Navigation gestures + if gesture in [GestureType.SWIPE_LEFT, GestureType.TILT_FORWARD]: + await self.next_page() + + elif gesture in [GestureType.SWIPE_RIGHT, GestureType.TILT_BACKWARD]: + await self.previous_page() + + # Exit on long press + elif gesture == GestureType.LONG_PRESS: + print("\nLong press detected - exiting...") + self.running = False + + # Word tap + elif gesture == GestureType.TAP: + # You could implement word selection here + print(f" Tap at ({event.x}, {event.y})") + + async def next_page(self): + """Go to next page""" + img = self.reader.next_page() + if img: + progress = self.reader.get_reading_progress() + chapter = self.reader.get_current_chapter_info() + print(f" -> Next page ({progress['percent']:.1f}% - {chapter['title']})") + await self.hal.show_image(img) + else: + print(" -> At end of book") + + async def previous_page(self): + """Go to previous page""" + img = self.reader.previous_page() + if img: + progress = self.reader.get_reading_progress() + chapter = self.reader.get_current_chapter_info() + print(f" -> Previous page ({progress['percent']:.1f}% - {chapter['title']})") + await self.hal.show_image(img) + else: + print(" -> At start of book") + + +async def main(): + """Main entry point""" + if len(sys.argv) < 2: + print("Usage: python demo_accelerometer_page_flip.py ") + print("\nExample:") + print(" python demo_accelerometer_page_flip.py ~/Books/mybook.epub") + sys.exit(1) + + epub_path = sys.argv[1] + + # Check if file exists + if not Path(epub_path).exists(): + print(f"ERROR: File not found: {epub_path}") + sys.exit(1) + + # Run demo + demo = AccelerometerPageFlipDemo(epub_path) + await demo.run() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nDemo interrupted by user") + sys.exit(0) + except Exception as e: + print(f"\nError: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/examples/demo_accelerometer_simple.py b/examples/demo_accelerometer_simple.py new file mode 100755 index 0000000..3819078 --- /dev/null +++ b/examples/demo_accelerometer_simple.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +Simple Accelerometer Demo - Using Unified Event API + +This is a simplified version of the accelerometer demo that uses +the HAL's get_event() convenience method to poll both touch and +accelerometer in a single call. + +Usage: + python examples/demo_accelerometer_simple.py +""" + +import asyncio +import sys +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from dreader.hal_hardware import HardwareDisplayHAL +from dreader.application import EbookReader +from dreader.gesture import GestureType + + +async def main(): + """Simple demo using unified event API""" + if len(sys.argv) < 2: + print("Usage: python demo_accelerometer_simple.py ") + sys.exit(1) + + epub_path = sys.argv[1] + + # Create HAL with accelerometer enabled + print("Initializing hardware...") + hal = HardwareDisplayHAL( + width=1872, + height=1404, + enable_orientation=True + ) + + await hal.initialize() + + # Load accelerometer calibration (optional) + if hal.load_accelerometer_calibration("accelerometer_config.json"): + print("✓ Accelerometer calibrated - tilt gestures enabled") + else: + print("✗ No accelerometer calibration - touch only") + + # Create reader and load book + print(f"\nLoading: {epub_path}") + reader = EbookReader(page_size=(hal.width, hal.height), margin=60) + + if not reader.load_epub(epub_path): + print(f"ERROR: Failed to load {epub_path}") + await hal.cleanup() + return + + print(f"Loaded: {reader.book_title}") + + # Display first page + img = reader.get_current_page() + await hal.show_image(img) + + print("\nControls:") + print(" Swipe LEFT or Tilt FORWARD → Next page") + print(" Swipe RIGHT or Tilt BACKWARD → Previous page") + print(" Long press → Exit\n") + + # Main event loop - simple unified API! + running = True + while running: + # Get event from any source (touch or accelerometer) + event = await hal.get_event() + + if event: + print(f"Gesture: {event.gesture.value}") + + # Page navigation + if event.gesture in [GestureType.SWIPE_LEFT, GestureType.TILT_FORWARD]: + img = reader.next_page() + if img: + progress = reader.get_reading_progress() + print(f" → Page {progress['current']}/{progress['total']} ({progress['percent']:.1f}%)") + await hal.show_image(img) + else: + print(" → End of book") + + elif event.gesture in [GestureType.SWIPE_RIGHT, GestureType.TILT_BACKWARD]: + img = reader.previous_page() + if img: + progress = reader.get_reading_progress() + print(f" ← Page {progress['current']}/{progress['total']} ({progress['percent']:.1f}%)") + await hal.show_image(img) + else: + print(" ← Start of book") + + # Exit + elif event.gesture == GestureType.LONG_PRESS: + print("\nExiting...") + running = False + + await asyncio.sleep(0.01) + + await hal.cleanup() + print("Done!") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nInterrupted") + sys.exit(0) + except Exception as e: + print(f"\nError: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/examples/run_on_hardware.py b/examples/run_on_hardware.py new file mode 100755 index 0000000..ef0a49b --- /dev/null +++ b/examples/run_on_hardware.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +""" +Run DReader on real e-ink hardware. + +This example demonstrates running the DReader application on real e-ink hardware +using the dreader-hal library for hardware abstraction. + +Requirements: + - Raspberry Pi (or compatible SBC) + - IT8951 e-ink display + - FT5xx6 capacitive touch sensor + - Optional: BMA400 accelerometer, PCF8523 RTC, INA219 power monitor + +Hardware Setup: + See external/dreader-hal/README.md for wiring instructions + +Usage: + # On Raspberry Pi with full hardware + python run_on_hardware.py /path/to/library + + # For testing without hardware (virtual display mode) + python run_on_hardware.py /path/to/library --virtual + + # Disable optional components + python run_on_hardware.py /path/to/library --no-orientation --no-rtc --no-power +""" + +import sys +import asyncio +import argparse +import logging +from pathlib import Path + +# Add parent directory to path to import dreader +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from dreader.hal_hardware import HardwareDisplayHAL +from dreader.main import DReaderApplication, AppConfig + + +async def main(args): + """ + Main application entry point. + + Args: + args: Command line arguments + """ + # Set up logging + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + logger = logging.getLogger(__name__) + + logger.info("Starting DReader on hardware") + logger.info(f"Library path: {args.library_path}") + logger.info(f"Display size: {args.width}x{args.height}") + logger.info(f"VCOM: {args.vcom}V") + logger.info(f"Virtual display: {args.virtual}") + + # Create hardware HAL + logger.info("Initializing hardware HAL...") + hal = HardwareDisplayHAL( + width=args.width, + height=args.height, + vcom=args.vcom, + virtual_display=args.virtual, + auto_sleep_display=args.auto_sleep, + enable_orientation=args.orientation, + enable_rtc=args.rtc, + enable_power_monitor=args.power, + battery_capacity_mah=args.battery_capacity, + ) + + # Create application config + config = AppConfig( + display_hal=hal, + library_path=args.library_path, + page_size=(args.width, args.height), + auto_save_interval=60, + force_library_mode=args.force_library, + log_level=logging.DEBUG if args.verbose else logging.INFO, + ) + + # Create application + app = DReaderApplication(config) + + try: + # Initialize hardware + logger.info("Initializing hardware...") + await hal.initialize() + + # Start application + logger.info("Starting application...") + await app.start() + + # Show battery level if available + if args.power and not args.virtual: + try: + battery = await hal.get_battery_level() + logger.info(f"Battery level: {battery:.1f}%") + + if await hal.is_low_battery(): + logger.warning("⚠️ Low battery!") + except Exception as e: + logger.warning(f"Could not read battery: {e}") + + # Main event loop + logger.info("Entering main event loop (Ctrl+C to exit)") + logger.info("") + logger.info("Touch gestures:") + logger.info(" - Swipe left: Next page") + logger.info(" - Swipe right: Previous page") + logger.info(" - Swipe up (from bottom): Open navigation/TOC") + logger.info(" - Swipe down (from top): Open settings") + logger.info(" - Tap: Select book/word/link") + logger.info("") + + while app.is_running(): + # Get touch event (non-blocking) + event = await hal.get_touch_event() + + if event: + logger.debug(f"Touch event: {event.gesture.value} at ({event.x}, {event.y})") + + # Handle touch event + await app.handle_touch(event) + + # Check battery periodically (every ~100 events) + if args.power and not args.virtual and args.show_battery: + if hasattr(app, '_event_count'): + app._event_count += 1 + else: + app._event_count = 1 + + if app._event_count % 100 == 0: + battery = await hal.get_battery_level() + logger.info(f"Battery: {battery:.1f}%") + + # Small delay to prevent CPU spinning + await asyncio.sleep(0.01) + + except KeyboardInterrupt: + logger.info("Received interrupt signal, shutting down...") + + except Exception as e: + logger.error(f"Error in main loop: {e}", exc_info=True) + + finally: + # Shutdown + logger.info("Shutting down application...") + await app.shutdown() + + logger.info("Cleaning up hardware...") + await hal.cleanup() + + logger.info("DReader stopped") + + +def parse_args(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Run DReader on e-ink hardware", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Run on real hardware + %(prog)s /home/pi/Books + + # Test with virtual display (no hardware required) + %(prog)s /home/pi/Books --virtual + + # Custom display size and VCOM + %(prog)s /home/pi/Books --width 1200 --height 1600 --vcom -2.3 + + # Disable optional sensors + %(prog)s /home/pi/Books --no-orientation --no-rtc --no-power + """ + ) + + # Required arguments + parser.add_argument( + 'library_path', + type=str, + help='Path to directory containing EPUB files' + ) + + # Display arguments + parser.add_argument( + '--width', + type=int, + default=1872, + help='Display width in pixels (default: 1872)' + ) + parser.add_argument( + '--height', + type=int, + default=1404, + help='Display height in pixels (default: 1404)' + ) + parser.add_argument( + '--vcom', + type=float, + default=-2.0, + help='E-ink VCOM voltage - CHECK YOUR DISPLAY LABEL! (default: -2.0)' + ) + + # Virtual display mode + parser.add_argument( + '--virtual', + action='store_true', + help='Use virtual display mode for testing without hardware' + ) + + # Display features + parser.add_argument( + '--no-auto-sleep', + dest='auto_sleep', + action='store_false', + help='Disable automatic display sleep after updates' + ) + + # Optional hardware components + parser.add_argument( + '--no-orientation', + dest='orientation', + action='store_false', + help='Disable orientation sensor (BMA400)' + ) + parser.add_argument( + '--no-rtc', + dest='rtc', + action='store_false', + help='Disable RTC (PCF8523)' + ) + parser.add_argument( + '--no-power', + dest='power', + action='store_false', + help='Disable power monitor (INA219)' + ) + + # Battery monitoring + parser.add_argument( + '--battery-capacity', + type=float, + default=3000, + help='Battery capacity in mAh (default: 3000)' + ) + parser.add_argument( + '--show-battery', + action='store_true', + help='Periodically log battery level' + ) + + # Application behavior + parser.add_argument( + '--force-library', + action='store_true', + help='Always start in library mode (ignore saved state)' + ) + + # Debugging + parser.add_argument( + '-v', '--verbose', + action='store_true', + help='Enable verbose debug logging' + ) + + args = parser.parse_args() + + # Validate library path + library_path = Path(args.library_path).expanduser() + if not library_path.exists(): + parser.error(f"Library path does not exist: {library_path}") + if not library_path.is_dir(): + parser.error(f"Library path is not a directory: {library_path}") + + args.library_path = str(library_path) + + return args + + +if __name__ == '__main__': + args = parse_args() + + # Run async main + try: + asyncio.run(main(args)) + except KeyboardInterrupt: + print("\nInterrupted by user") + sys.exit(0) diff --git a/examples/run_on_hardware_config.py b/examples/run_on_hardware_config.py new file mode 100755 index 0000000..dfa648e --- /dev/null +++ b/examples/run_on_hardware_config.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +""" +Run DReader on hardware using hardware_config.json configuration. + +This script loads all hardware configuration from hardware_config.json, +including display settings, GPIO buttons, and optional components. + +Usage: + # Use default config file (hardware_config.json) + python run_on_hardware_config.py + + # Use custom config file + python run_on_hardware_config.py --config my_config.json + + # Override config settings + python run_on_hardware_config.py --library ~/MyBooks --verbose +""" + +import sys +import asyncio +import argparse +import logging +import json +from pathlib import Path + +# Add parent directory to path to import dreader +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from dreader.hal_hardware import HardwareDisplayHAL +from dreader.main import DReaderApplication, AppConfig +from dreader.gpio_buttons import load_button_config_from_dict + + +def load_config(config_path: str) -> dict: + """Load hardware configuration from JSON file.""" + config_file = Path(config_path) + + if not config_file.exists(): + raise FileNotFoundError( + f"Configuration file not found: {config_path}\n" + f"Run 'sudo python3 setup_rpi.py' to create it." + ) + + with open(config_file, 'r') as f: + config = json.load(f) + + return config + + +async def main(args): + """Main application entry point.""" + # Load configuration + logger = logging.getLogger(__name__) + logger.info(f"Loading configuration from {args.config}") + + try: + config = load_config(args.config) + except Exception as e: + print(f"Error loading configuration: {e}") + sys.exit(1) + + # Apply command-line overrides + if args.library: + config['application']['library_path'] = args.library + + if args.verbose: + config['application']['log_level'] = 'DEBUG' + + # Set up logging + log_level = getattr(logging, config['application']['log_level'].upper()) + logging.basicConfig( + level=log_level, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + + logger.info("="*70) + logger.info("DReader Hardware Mode") + logger.info("="*70) + + # Display configuration summary + display_cfg = config['display'] + logger.info(f"Display: {display_cfg['width']}x{display_cfg['height']}, VCOM={display_cfg['vcom']}V") + + gpio_cfg = config.get('gpio_buttons', {}) + if gpio_cfg.get('enabled', False): + logger.info(f"GPIO Buttons: {len(gpio_cfg.get('buttons', []))} configured") + + accel_cfg = config.get('accelerometer', {}) + if accel_cfg.get('enabled', False): + logger.info("Accelerometer: Enabled") + + rtc_cfg = config.get('rtc', {}) + if rtc_cfg.get('enabled', False): + logger.info("RTC: Enabled") + + power_cfg = config.get('power_monitor', {}) + if power_cfg.get('enabled', False): + logger.info("Power Monitor: Enabled") + + # Create hardware HAL + logger.info("\nInitializing hardware HAL...") + hal = HardwareDisplayHAL( + width=display_cfg['width'], + height=display_cfg['height'], + vcom=display_cfg['vcom'], + spi_hz=display_cfg.get('spi_hz', 24_000_000), + virtual_display=False, + auto_sleep_display=display_cfg.get('auto_sleep', True), + enable_orientation=accel_cfg.get('enabled', True), + enable_rtc=rtc_cfg.get('enabled', True), + enable_power_monitor=power_cfg.get('enabled', True), + shunt_ohms=power_cfg.get('shunt_ohms', 0.1), + battery_capacity_mah=power_cfg.get('battery_capacity_mah', 3000), + ) + + # Load accelerometer tilt calibration if enabled + if accel_cfg.get('tilt_enabled', False): + calib_file = accel_cfg.get('calibration_file', 'accelerometer_config.json') + if hal.load_accelerometer_calibration(calib_file): + logger.info(f"Accelerometer tilt detection enabled (calibration from {calib_file})") + else: + logger.warning("Accelerometer tilt detection requested but calibration not loaded") + + # Set up GPIO buttons + button_handler = None + if gpio_cfg.get('enabled', False): + logger.info("Setting up GPIO buttons...") + button_handler = load_button_config_from_dict( + config, + screen_width=display_cfg['width'], + screen_height=display_cfg['height'] + ) + + if button_handler: + await button_handler.initialize() + logger.info(f"GPIO buttons initialized: {len(gpio_cfg.get('buttons', []))} buttons") + + # Create application config + app_cfg = config['application'] + app_config = AppConfig( + display_hal=hal, + library_path=app_cfg['library_path'], + page_size=(display_cfg['width'], display_cfg['height']), + auto_save_interval=app_cfg.get('auto_save_interval', 60), + force_library_mode=app_cfg.get('force_library_mode', False), + log_level=log_level, + ) + + # Create application + app = DReaderApplication(app_config) + + try: + # Initialize hardware + logger.info("Initializing hardware...") + await hal.initialize() + + # Start application + logger.info("Starting application...") + await app.start() + + # Show battery level if available + if power_cfg.get('enabled', False): + try: + battery = await hal.get_battery_level() + logger.info(f"Battery level: {battery:.1f}%") + + if await hal.is_low_battery(power_cfg.get('low_battery_threshold', 20.0)): + logger.warning("⚠️ Low battery!") + except Exception as e: + logger.warning(f"Could not read battery: {e}") + + # Main event loop + logger.info("\nApplication ready!") + logger.info("="*70) + + event_count = 0 + show_battery_interval = power_cfg.get('show_battery_interval', 100) + + while app.is_running(): + # Check for touch events + touch_event = await hal.get_touch_event() + + if touch_event: + logger.debug(f"Touch: {touch_event.gesture.value} at ({touch_event.x}, {touch_event.y})") + await app.handle_touch(touch_event) + event_count += 1 + + # Check for button events + if button_handler: + button_event = await button_handler.get_button_event() + if button_event: + logger.info(f"Button: {button_event.gesture.value}") + await app.handle_touch(button_event) + event_count += 1 + + # Check for tilt gestures if enabled + if accel_cfg.get('tilt_enabled', False): + tilt_event = await hal.get_tilt_gesture() + if tilt_event: + logger.info(f"Tilt: {tilt_event.gesture.value}") + await app.handle_touch(tilt_event) + event_count += 1 + + # Show battery periodically + if power_cfg.get('enabled', False) and event_count % show_battery_interval == 0 and event_count > 0: + try: + battery = await hal.get_battery_level() + logger.info(f"Battery: {battery:.1f}%") + except: + pass + + # Small delay to prevent CPU spinning + await asyncio.sleep(0.01) + + except KeyboardInterrupt: + logger.info("\nReceived interrupt signal, shutting down...") + + except Exception as e: + logger.error(f"Error in main loop: {e}", exc_info=True) + + finally: + # Shutdown + logger.info("Shutting down application...") + await app.shutdown() + + logger.info("Cleaning up GPIO buttons...") + if button_handler: + await button_handler.cleanup() + + logger.info("Cleaning up hardware...") + await hal.cleanup() + + logger.info("DReader stopped") + + +def parse_args(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Run DReader using hardware_config.json", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Configuration: + Edit hardware_config.json to configure your hardware settings. + Run 'sudo python3 setup_rpi.py' to create/update the config file. + +Examples: + # Use default config + %(prog)s + + # Use custom config file + %(prog)s --config my_hardware.json + + # Override library path + %(prog)s --library ~/MyBooks + + # Enable verbose logging + %(prog)s --verbose + """ + ) + + parser.add_argument( + '--config', + type=str, + default='hardware_config.json', + help='Path to hardware configuration file (default: hardware_config.json)' + ) + + parser.add_argument( + '--library', + type=str, + help='Override library path from config' + ) + + parser.add_argument( + '-v', '--verbose', + action='store_true', + help='Enable verbose debug logging' + ) + + args = parser.parse_args() + + return args + + +if __name__ == '__main__': + args = parse_args() + + # Run async main + try: + asyncio.run(main(args)) + except KeyboardInterrupt: + print("\nInterrupted by user") + sys.exit(0) diff --git a/external/dreader-hal b/external/dreader-hal new file mode 160000 index 0000000..be3aed6 --- /dev/null +++ b/external/dreader-hal @@ -0,0 +1 @@ +Subproject commit be3aed6e5e2ef5c80235dcb3f8efa1aeb2ec5abe diff --git a/hardware_config.json b/hardware_config.json new file mode 100644 index 0000000..7a3ba69 --- /dev/null +++ b/hardware_config.json @@ -0,0 +1,65 @@ +{ + "_description": "Hardware configuration for DReader e-ink device", + "_note": "This config matches the actual hardware: GPIO 22=prev, GPIO 27=next, GPIO 21=power, I2C on GPIO 2/3", + + "display": { + "width": 1872, + "height": 1404, + "vcom": -2.0, + "spi_hz": 24000000, + "auto_sleep": true + }, + + "gpio_buttons": { + "enabled": true, + "pull_up": true, + "bounce_time_ms": 200, + "buttons": [ + { + "name": "prev_page", + "gpio": 22, + "gesture": "swipe_right", + "description": "Previous page button" + }, + { + "name": "next_page", + "gpio": 27, + "gesture": "swipe_left", + "description": "Next page button" + }, + { + "name": "power_off", + "gpio": 21, + "gesture": "long_press", + "description": "Power off button (long press to shutdown)", + "comment": "You may want to implement shutdown logic in the application" + } + ] + }, + + "accelerometer": { + "enabled": true, + "tilt_enabled": false, + "orientation_enabled": true, + "calibration_file": "accelerometer_config.json" + }, + + "rtc": { + "enabled": true + }, + + "power_monitor": { + "enabled": true, + "shunt_ohms": 0.1, + "battery_capacity_mah": 3000, + "low_battery_threshold": 20.0, + "show_battery_interval": 100 + }, + + "application": { + "library_path": "/home/pi/Books", + "auto_save_interval": 60, + "force_library_mode": false, + "log_level": "INFO" + } +} diff --git a/install_hardware_drivers.sh b/install_hardware_drivers.sh new file mode 100755 index 0000000..1101215 --- /dev/null +++ b/install_hardware_drivers.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# Installation script for dreader-hal hardware drivers +# +# This script installs all the external driver dependencies needed +# for running DReader on e-ink hardware. + +set -e # Exit on error + +echo "================================" +echo "DReader Hardware Driver Installer" +echo "================================" +echo "" + +# Check if we're in a virtual environment +if [ -z "$VIRTUAL_ENV" ]; then + echo "⚠️ Warning: No virtual environment detected!" + echo "It's recommended to activate your virtual environment first:" + echo " source venv/bin/activate" + echo "" + read -p "Continue anyway? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi +fi + +# Initialize submodules if not already done +echo "Step 1: Initializing git submodules..." +git submodule update --init --recursive +echo "✓ Submodules initialized" +echo "" + +# Install dreader-hal main package +echo "Step 2: Installing dreader-hal..." +pip install -e external/dreader-hal +echo "✓ dreader-hal installed" +echo "" + +# Install external drivers +echo "Step 3: Installing external driver libraries..." + +echo " - Installing IT8951 (E-ink display driver)..." +pip install -e external/dreader-hal/external/IT8951 + +echo " - Installing PyBMA400 (Accelerometer)..." +pip install -e external/dreader-hal/external/PyBMA400 + +echo " - Installing PyFTtxx6 (Touch panel)..." +pip install -e external/dreader-hal/external/PyFTtxx6/pyft5xx6 + +echo " - Installing PyPCF8523 (RTC)..." +pip install -e external/dreader-hal/external/PyPCF8523 + +echo " - Installing pi_ina219 (Power monitor)..." +pip install -e external/dreader-hal/external/pi_ina219 + +echo "✓ All drivers installed" +echo "" + +# Summary +echo "================================" +echo "Installation Complete!" +echo "================================" +echo "" +echo "Installed packages:" +echo " ✓ dreader-hal (main HAL library)" +echo " ✓ IT8951 (e-ink display)" +echo " ✓ PyBMA400 (accelerometer)" +echo " ✓ PyFTtxx6 (touch panel)" +echo " ✓ PyPCF8523 (RTC)" +echo " ✓ pi_ina219 (power monitor)" +echo "" +echo "Next steps:" +echo " 1. Wire up your hardware according to HARDWARE_SETUP.md" +echo " 2. Check your display's VCOM voltage (on label)" +echo " 3. Run: python examples/run_on_hardware.py /path/to/books --vcom YOUR_VCOM" +echo "" +echo "Example:" +echo " python examples/run_on_hardware.py ~/Books --vcom -2.06" +echo "" diff --git a/setup_rpi.py b/setup_rpi.py new file mode 100755 index 0000000..c42f4ed --- /dev/null +++ b/setup_rpi.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +""" +Raspberry Pi Setup Script for DReader Hardware. + +This interactive script helps configure your DReader e-reader hardware by: +1. Detecting connected hardware (I2C devices, SPI, etc.) +2. Creating/editing hardware_config.json +3. Installing required system packages +4. Setting up permissions and services + +Usage: + sudo python3 setup_rpi.py +""" + +import sys +import os +import json +import subprocess +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +# Check if running on Raspberry Pi +try: + with open('/proc/device-tree/model', 'r') as f: + model = f.read() + if 'Raspberry Pi' not in model: + print("⚠️ Warning: This doesn't appear to be a Raspberry Pi") + print(f" Detected: {model.strip()}") + response = input("Continue anyway? (y/N): ") + if response.lower() != 'y': + sys.exit(1) +except: + print("⚠️ Warning: Could not detect Raspberry Pi") + + +class Colors: + """ANSI color codes for terminal output.""" + HEADER = '\033[95m' + BLUE = '\033[94m' + CYAN = '\033[96m' + GREEN = '\033[92m' + YELLOW = '\033[93m' + RED = '\033[91m' + END = '\033[0m' + BOLD = '\033[1m' + + +def print_header(text: str): + """Print a header.""" + print(f"\n{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.END}") + print(f"{Colors.BOLD}{Colors.BLUE}{text:^70}{Colors.END}") + print(f"{Colors.BOLD}{Colors.BLUE}{'='*70}{Colors.END}\n") + + +def print_step(number: int, text: str): + """Print a step number.""" + print(f"\n{Colors.BOLD}{Colors.CYAN}Step {number}: {text}{Colors.END}") + print(f"{Colors.CYAN}{'-'*70}{Colors.END}") + + +def print_success(text: str): + """Print success message.""" + print(f"{Colors.GREEN}✓ {text}{Colors.END}") + + +def print_warning(text: str): + """Print warning message.""" + print(f"{Colors.YELLOW}⚠ {text}{Colors.END}") + + +def print_error(text: str): + """Print error message.""" + print(f"{Colors.RED}✗ {text}{Colors.END}") + + +def run_command(cmd: str, check: bool = True) -> Tuple[int, str, str]: + """Run a shell command and return result.""" + try: + result = subprocess.run( + cmd, + shell=True, + capture_output=True, + text=True, + check=check + ) + return result.returncode, result.stdout, result.stderr + except subprocess.CalledProcessError as e: + return e.returncode, e.stdout, e.stderr + + +def check_interfaces() -> Dict[str, bool]: + """Check if I2C and SPI interfaces are enabled.""" + print("Checking system interfaces...") + + interfaces = { + 'i2c': False, + 'spi': False, + } + + # Check I2C + if os.path.exists('/dev/i2c-1'): + interfaces['i2c'] = True + print_success("I2C interface enabled") + else: + print_warning("I2C interface not enabled") + + # Check SPI + if os.path.exists('/dev/spidev0.0'): + interfaces['spi'] = True + print_success("SPI interface enabled") + else: + print_warning("SPI interface not enabled") + + return interfaces + + +def detect_i2c_devices() -> List[str]: + """Detect I2C devices.""" + print("\nScanning I2C bus...") + + returncode, stdout, stderr = run_command("i2cdetect -y 1", check=False) + + if returncode != 0: + print_warning("Could not scan I2C bus (i2cdetect not found or no permission)") + return [] + + # Parse i2cdetect output + devices = [] + for line in stdout.split('\n'): + if ':' in line: + # Extract hex addresses + parts = line.split(':')[1].split() + for part in parts: + if part != '--' and len(part) == 2: + devices.append(f"0x{part}") + + if devices: + print_success(f"Found {len(devices)} I2C device(s): {', '.join(devices)}") + + # Identify known devices + device_map = { + '0x38': 'FT5316 Touch Panel', + '0x14': 'BMA400 Accelerometer', + '0x15': 'BMA400 Accelerometer (alt)', + '0x68': 'PCF8523 RTC', + '0x40': 'INA219 Power Monitor', + } + + print("\nDetected devices:") + for addr in devices: + device_name = device_map.get(addr, 'Unknown device') + print(f" {addr}: {device_name}") + else: + print_warning("No I2C devices detected") + + return devices + + +def enable_interfaces(): + """Enable I2C and SPI interfaces.""" + print("\nEnabling interfaces...") + + # Use raspi-config to enable I2C and SPI + print("Enabling I2C...") + run_command("raspi-config nonint do_i2c 0", check=False) + + print("Enabling SPI...") + run_command("raspi-config nonint do_spi 0", check=False) + + print_success("Interfaces enabled (reboot required to take effect)") + + +def setup_permissions(): + """Set up user permissions for GPIO, I2C, and SPI.""" + print("\nSetting up user permissions...") + + user = os.environ.get('SUDO_USER', os.environ.get('USER')) + + groups = ['gpio', 'i2c', 'spi'] + for group in groups: + print(f"Adding user '{user}' to group '{group}'...") + returncode, _, _ = run_command(f"usermod -a -G {group} {user}", check=False) + + if returncode == 0: + print_success(f"Added to {group} group") + else: + print_warning(f"Could not add to {group} group (may not exist)") + + print_warning("You must log out and back in for group changes to take effect") + + +def get_vcom_voltage() -> float: + """Prompt user for VCOM voltage.""" + print("\n" + Colors.BOLD + "VCOM Voltage Configuration" + Colors.END) + print("="*70) + print("Your e-ink display has a VCOM voltage printed on a label.") + print("This is usually on the back of the display.") + print("") + print("Example labels:") + print(" • VCOM = -2.06V") + print(" • VCOM: -1.98V") + print(" • -2.14V") + print("") + print(Colors.RED + Colors.BOLD + "⚠️ IMPORTANT: Using incorrect VCOM can damage your display!" + Colors.END) + print("") + + while True: + vcom_str = input("Enter your display's VCOM voltage (e.g., -2.06): ").strip() + + try: + vcom = float(vcom_str) + + if vcom > 0: + print_warning("VCOM is usually negative. Did you forget the minus sign?") + continue + + if vcom < -3.0 or vcom > -1.0: + print_warning(f"VCOM {vcom}V is unusual. Most displays are between -1.5V and -2.5V") + confirm = input("Are you sure this is correct? (y/N): ") + if confirm.lower() != 'y': + continue + + return vcom + + except ValueError: + print_error("Invalid voltage. Please enter a number (e.g., -2.06)") + + +def configure_gpio_buttons() -> dict: + """Configure GPIO buttons interactively.""" + print("\n" + Colors.BOLD + "GPIO Button Configuration" + Colors.END) + print("="*70) + print("Configure physical buttons for navigation.") + print("Buttons should be connected between GPIO pin and GND.") + print("") + + enable = input("Enable GPIO buttons? (Y/n): ").strip().lower() + if enable == 'n': + return { + "enabled": False, + "pull_up": True, + "bounce_time_ms": 200, + "buttons": [] + } + + buttons = [] + + # Common button configurations (based on actual hardware) + button_presets = [ + ("prev_page", "Previous Page", "swipe_right", 22), + ("next_page", "Next Page", "swipe_left", 27), + ("power_off", "Power Off", "long_press", 21), + ] + + print("\nAvailable GPIOs (BCM numbering): 2-27 (avoid 2, 3 if using I2C)") + print("") + + for name, description, default_gesture, default_gpio in button_presets: + print(f"\n{Colors.BOLD}{description} Button{Colors.END}") + enable_btn = input(f" Enable {description} button? (Y/n): ").strip().lower() + + if enable_btn == 'n': + continue + + # Get GPIO pin + while True: + gpio_str = input(f" GPIO pin (default {default_gpio}): ").strip() + if not gpio_str: + gpio = default_gpio + break + + try: + gpio = int(gpio_str) + if gpio < 2 or gpio > 27: + print_error(" GPIO must be between 2 and 27") + continue + if gpio in [2, 3]: + print_warning(" GPIO 2/3 are I2C pins (SDA/SCL)") + confirm = input(" Use anyway? (y/N): ") + if confirm.lower() != 'y': + continue + break + except ValueError: + print_error(" Invalid GPIO number") + + # Add button + buttons.append({ + "name": name, + "gpio": gpio, + "gesture": default_gesture, + "description": description + }) + + print_success(f" Configured: GPIO {gpio} -> {description}") + + return { + "enabled": True, + "pull_up": True, + "bounce_time_ms": 200, + "buttons": buttons + } + + +def create_hardware_config(vcom: float, gpio_config: dict, i2c_devices: List[str]) -> dict: + """Create hardware configuration dictionary.""" + # Auto-detect which optional components are available + has_touch = '0x38' in i2c_devices + has_accel = '0x14' in i2c_devices or '0x15' in i2c_devices + has_rtc = '0x68' in i2c_devices + has_power = '0x40' in i2c_devices + + config = { + "_description": "Hardware configuration for DReader e-ink device", + "_generated": "Generated by setup_rpi.py", + + "display": { + "width": 1872, + "height": 1404, + "vcom": vcom, + "spi_hz": 24000000, + "auto_sleep": True + }, + + "gpio_buttons": gpio_config, + + "accelerometer": { + "enabled": has_accel, + "tilt_enabled": False, + "orientation_enabled": has_accel, + "calibration_file": "accelerometer_config.json" + }, + + "rtc": { + "enabled": has_rtc + }, + + "power_monitor": { + "enabled": has_power, + "shunt_ohms": 0.1, + "battery_capacity_mah": 3000, + "low_battery_threshold": 20.0, + "show_battery_interval": 100 + }, + + "application": { + "library_path": "/home/pi/Books", + "auto_save_interval": 60, + "force_library_mode": False, + "log_level": "INFO" + } + } + + return config + + +def main(): + """Main setup function.""" + print_header("DReader Raspberry Pi Hardware Setup") + + # Check if running as root + if os.geteuid() != 0: + print_error("This script must be run with sudo") + print("Usage: sudo python3 setup_rpi.py") + sys.exit(1) + + # Step 1: Check interfaces + print_step(1, "Checking System Interfaces") + interfaces = check_interfaces() + + if not all(interfaces.values()): + print("\nSome interfaces are not enabled.") + enable = input("Enable I2C and SPI now? (Y/n): ").strip().lower() + + if enable != 'n': + enable_interfaces() + print_warning("Reboot required for interface changes to take effect") + + # Step 2: Detect hardware + print_step(2, "Detecting I2C Devices") + i2c_devices = detect_i2c_devices() + + if not i2c_devices: + print_warning("No I2C devices detected. Check your wiring.") + print("See HARDWARE_SETUP.md for wiring instructions.") + + # Step 3: Set up permissions + print_step(3, "Setting Up User Permissions") + setup_permissions() + + # Step 4: Configure VCOM + print_step(4, "Display Configuration") + vcom = get_vcom_voltage() + print_success(f"VCOM voltage set to {vcom}V") + + # Step 5: Configure GPIO buttons + print_step(5, "GPIO Button Configuration") + gpio_config = configure_gpio_buttons() + + if gpio_config["enabled"]: + print_success(f"Configured {len(gpio_config['buttons'])} button(s)") + else: + print("GPIO buttons disabled") + + # Step 6: Generate configuration + print_step(6, "Generating Configuration File") + config = create_hardware_config(vcom, gpio_config, i2c_devices) + + config_file = Path("hardware_config.json") + with open(config_file, 'w') as f: + json.dump(config, f, indent=2) + + print_success(f"Configuration saved to {config_file}") + + # Step 7: Summary + print_header("Setup Complete!") + + print("Configuration summary:") + print(f" • Display: {config['display']['width']}x{config['display']['height']}, VCOM={config['display']['vcom']}V") + print(f" • GPIO Buttons: {'Enabled' if gpio_config['enabled'] else 'Disabled'}") + if gpio_config['enabled']: + for btn in gpio_config['buttons']: + print(f" - {btn['description']}: GPIO {btn['gpio']}") + print(f" • Accelerometer: {'Enabled' if config['accelerometer']['enabled'] else 'Disabled'}") + print(f" • RTC: {'Enabled' if config['rtc']['enabled'] else 'Disabled'}") + print(f" • Power Monitor: {'Enabled' if config['power_monitor']['enabled'] else 'Disabled'}") + + print("\n" + Colors.BOLD + "Next Steps:" + Colors.END) + print("1. Review and edit hardware_config.json if needed") + print("2. Reboot if you enabled I2C/SPI: sudo reboot") + print("3. Log out and back in for permission changes") + print("4. Run: python examples/run_on_hardware_config.py") + print("") + + +if __name__ == '__main__': + try: + main() + except KeyboardInterrupt: + print("\n\nSetup cancelled by user") + sys.exit(1) + except Exception as e: + print_error(f"Setup failed: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/tests/test_accelerometer_gestures.py b/tests/test_accelerometer_gestures.py new file mode 100644 index 0000000..98cec19 --- /dev/null +++ b/tests/test_accelerometer_gestures.py @@ -0,0 +1,284 @@ +""" +Tests for accelerometer-based gesture detection. +""" + +import pytest +import asyncio +import json +import math +from pathlib import Path +import sys + +# Test only the gesture types and math, not the full integration +# to avoid dependencies on pyWebLayout + + +class MockOrientationSensor: + """Mock BMA400 accelerometer for testing""" + + def __init__(self): + self.ax = 0.0 + self.ay = 0.0 + self.az = 9.8 # Standard gravity + + async def get_acceleration(self): + """Return mock acceleration data""" + return (self.ax, self.ay, self.az) + + def set_acceleration(self, x, y, z): + """Set acceleration for testing""" + self.ax = x + self.ay = y + self.az = z + + +class MockHAL: + """Mock HAL for testing""" + + def __init__(self): + self.orientation = MockOrientationSensor() + self.width = 800 + self.height = 1200 + + +@pytest.fixture +def mock_hal(): + """Create a mock HAL with accelerometer""" + return MockHAL() + + +@pytest.fixture +def calibration_file(tmp_path): + """Create a temporary calibration file""" + config = { + "up_vector": { + "x": 0.0, + "y": 9.8, + "z": 0.0 + }, + "tilt_threshold": 0.3, # ~17 degrees + "debounce_time": 0.5 + } + + config_path = tmp_path / "test_accel_config.json" + with open(config_path, 'w') as f: + json.dump(config, f) + + return str(config_path) + + +def test_load_calibration_success(mock_hal, calibration_file): + """Test loading accelerometer calibration""" + # Create a minimal HAL-like object + class TestHAL: + def __init__(self): + self.width = 800 + self.height = 1200 + + test_hal = TestHAL() + + # Manually call the load function + result = load_accel_calibration(test_hal, calibration_file) + + assert result is True + assert hasattr(test_hal, 'accel_up_vector') + assert test_hal.accel_up_vector == (0.0, 9.8, 0.0) + assert test_hal.accel_tilt_threshold == 0.3 + assert test_hal.accel_debounce_time == 0.5 + + +def load_accel_calibration(hal, config_path): + """Helper function to load calibration (extracted from HardwareDisplayHAL)""" + import logging + logger = logging.getLogger(__name__) + + config_file = Path(config_path) + if not config_file.exists(): + logger.warning(f"Accelerometer calibration file not found: {config_path}") + return False + + try: + with open(config_file, 'r') as f: + config = json.load(f) + + # Load up vector + up = config.get("up_vector", {}) + hal.accel_up_vector = (up.get("x", 0), up.get("y", 0), up.get("z", 0)) + + # Load thresholds + hal.accel_tilt_threshold = config.get("tilt_threshold", 0.3) + hal.accel_debounce_time = config.get("debounce_time", 0.5) + + # State tracking + hal.accel_last_tilt_time = 0 + + return True + + except Exception as e: + logger.error(f"Error loading accelerometer calibration: {e}") + return False + + +def test_tilt_detection_forward(): + """Test forward tilt detection""" + # Setup: device is upright (y = 9.8), then tilt forward (z increases) + # Calibrated up vector: (0, 9.8, 0) + # Current gravity: (0, 6, 6) - tilted ~45 degrees forward + + up_vector = (0.0, 9.8, 0.0) + current_gravity = (0.0, 6.0, 6.0) + + # Normalize vectors + ux, uy, uz = up_vector + u_mag = math.sqrt(ux**2 + uy**2 + uz**2) + ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag + + gx, gy, gz = current_gravity + g_mag = math.sqrt(gx**2 + gy**2 + gz**2) + gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag + + # Calculate tilt angle + dot_up = gx * ux + gy * uy + gz * uz + + perp_x = gx - dot_up * ux + perp_y = gy - dot_up * uy + perp_z = gz - dot_up * uz + + perp_mag = math.sqrt(perp_x**2 + perp_y**2 + perp_z**2) + tilt_angle = math.atan2(perp_mag, abs(dot_up)) + + # Should be approximately 45 degrees (0.785 radians) + assert abs(tilt_angle - 0.785) < 0.1 + + # Direction: forward tilt should have positive perpendicular y component + # Actually, when tilting forward, gravity vector rotates toward +z + # The perpendicular component should reflect this + + +def test_tilt_detection_backward(): + """Test backward tilt detection""" + # Setup: device is upright (y = 9.8), then tilt backward (z decreases, negative) + # Calibrated up vector: (0, 9.8, 0) + # Current gravity: (0, 6, -6) - tilted ~45 degrees backward + + up_vector = (0.0, 9.8, 0.0) + current_gravity = (0.0, 6.0, -6.0) + + # Normalize vectors + ux, uy, uz = up_vector + u_mag = math.sqrt(ux**2 + uy**2 + uz**2) + ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag + + gx, gy, gz = current_gravity + g_mag = math.sqrt(gx**2 + gy**2 + gz**2) + gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag + + # Calculate tilt angle + dot_up = gx * ux + gy * uy + gz * uz + + perp_x = gx - dot_up * ux + perp_y = gy - dot_up * uy + perp_z = gz - dot_up * uz + + perp_mag = math.sqrt(perp_x**2 + perp_y**2 + perp_z**2) + tilt_angle = math.atan2(perp_mag, abs(dot_up)) + + # Should be approximately 45 degrees (0.785 radians) + assert abs(tilt_angle - 0.785) < 0.1 + + +def test_no_tilt_when_upright(): + """Test that no tilt is detected when device is upright""" + # Setup: device is perfectly upright + # Calibrated up vector: (0, 9.8, 0) + # Current gravity: (0, 9.8, 0) - same as calibration + + up_vector = (0.0, 9.8, 0.0) + current_gravity = (0.0, 9.8, 0.0) + + # Normalize vectors + ux, uy, uz = up_vector + u_mag = math.sqrt(ux**2 + uy**2 + uz**2) + ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag + + gx, gy, gz = current_gravity + g_mag = math.sqrt(gx**2 + gy**2 + gz**2) + gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag + + # Calculate tilt angle + dot_up = gx * ux + gy * uy + gz * uz + + perp_x = gx - dot_up * ux + perp_y = gy - dot_up * uy + perp_z = gz - dot_up * uz + + perp_mag = math.sqrt(perp_x**2 + perp_y**2 + perp_z**2) + tilt_angle = math.atan2(perp_mag, abs(dot_up)) + + # Should be approximately 0 degrees + assert tilt_angle < 0.01 + + +def test_small_tilt_below_threshold(): + """Test that small tilts below threshold are ignored""" + # Setup: device is slightly tilted (10 degrees) + # Calibrated up vector: (0, 9.8, 0) + # Current gravity: small tilt + + angle_rad = math.radians(10) + up_vector = (0.0, 9.8, 0.0) + current_gravity = (0.0, 9.8 * math.cos(angle_rad), 9.8 * math.sin(angle_rad)) + + # Normalize vectors + ux, uy, uz = up_vector + u_mag = math.sqrt(ux**2 + uy**2 + uz**2) + ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag + + gx, gy, gz = current_gravity + g_mag = math.sqrt(gx**2 + gy**2 + gz**2) + gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag + + # Calculate tilt angle + dot_up = gx * ux + gy * uy + gz * uz + + perp_x = gx - dot_up * ux + perp_y = gy - dot_up * uy + perp_z = gz - dot_up * uz + + perp_mag = math.sqrt(perp_x**2 + perp_y**2 + perp_z**2) + tilt_angle = math.atan2(perp_mag, abs(dot_up)) + + # Should be approximately 10 degrees (0.174 radians) + assert abs(tilt_angle - 0.174) < 0.01 + + # Should be below default threshold of 0.3 rad (~17 degrees) + assert tilt_angle < 0.3 + + +def test_gesture_types_exist(): + """Test that accelerometer gesture types are defined""" + # Simple direct test - check that gesture strings are defined + gestures = [ + "tap", + "long_press", + "swipe_left", + "swipe_right", + "swipe_up", + "swipe_down", + "pinch_in", + "pinch_out", + "drag_start", + "drag_move", + "drag_end", + "tilt_forward", # Our new gestures + "tilt_backward" + ] + + # Verify the new gesture strings are valid + assert "tilt_forward" in gestures + assert "tilt_backward" in gestures + + +if __name__ == "__main__": + # Run tests + pytest.main([__file__, "-v"])