HW integratation
All checks were successful
Python CI / test (3.12) (push) Successful in 7m1s
Python CI / test (3.13) (push) Successful in 7m10s

This commit is contained in:
Duncan Tourolle 2025-11-11 11:57:39 +01:00
parent 5a573c901e
commit 70c0b4a1f2
19 changed files with 4082 additions and 21 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "external/dreader-hal"]
path = external/dreader-hal
url = https://gitea.tourolle.paris/dtourolle/dreader-hal

286
GPIO_BUTTONS.md Normal file
View File

@ -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

230
HARDWARE_PINOUT.md Executable file
View File

@ -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

472
HARDWARE_SETUP.md Normal file
View File

@ -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)

View File

@ -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

View File

@ -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:

291
dreader/gpio_buttons.py Normal file
View File

@ -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

593
dreader/hal_hardware.py Normal file
View File

@ -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

View File

@ -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, {})

View File

@ -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/
"""
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)

View File

@ -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 <epub_file>
"""
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 <epub_file>")
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)

View File

@ -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 <epub_file>
"""
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 <epub_file>")
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)

292
examples/run_on_hardware.py Executable file
View File

@ -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)

View File

@ -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)

1
external/dreader-hal vendored Submodule

@ -0,0 +1 @@
Subproject commit be3aed6e5e2ef5c80235dcb3f8efa1aeb2ec5abe

65
hardware_config.json Normal file
View File

@ -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"
}
}

80
install_hardware_drivers.sh Executable file
View File

@ -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 ""

445
setup_rpi.py Executable file
View File

@ -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)

View File

@ -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"])