HW integratation
This commit is contained in:
Executable
+363
@@ -0,0 +1,363 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Accelerometer Calibration Script
|
||||
|
||||
This script helps calibrate the accelerometer for gravity-based page flipping.
|
||||
It displays visual instructions on the e-ink display to guide the user through
|
||||
aligning the device with the "up" direction.
|
||||
|
||||
The calibration process:
|
||||
1. Shows an arrow pointing up
|
||||
2. User rotates device until arrow aligns with desired "up" direction
|
||||
3. User confirms by tapping screen
|
||||
4. Script saves calibration offset to config file
|
||||
|
||||
Usage:
|
||||
python examples/calibrate_accelerometer.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from dreader.hal_hardware import HardwareDisplayHAL
|
||||
from dreader.gesture import GestureType
|
||||
|
||||
|
||||
class AccelerometerCalibrator:
|
||||
"""Interactive accelerometer calibration tool"""
|
||||
|
||||
def __init__(self, hal: HardwareDisplayHAL, config_path: str = "accelerometer_config.json"):
|
||||
self.hal = hal
|
||||
self.config_path = Path(config_path)
|
||||
self.width = hal.width
|
||||
self.height = hal.height
|
||||
self.calibrated = False
|
||||
|
||||
# Calibration data
|
||||
self.up_vector = None # (x, y, z) when device is in "up" position
|
||||
|
||||
async def run(self):
|
||||
"""Run the calibration process"""
|
||||
print("Starting accelerometer calibration...")
|
||||
print(f"Display: {self.width}x{self.height}")
|
||||
|
||||
await self.hal.initialize()
|
||||
|
||||
try:
|
||||
# Show welcome screen
|
||||
await self.show_welcome()
|
||||
await self.wait_for_tap()
|
||||
|
||||
# Calibration loop
|
||||
await self.calibration_loop()
|
||||
|
||||
# Show completion screen
|
||||
await self.show_completion()
|
||||
await asyncio.sleep(3)
|
||||
|
||||
finally:
|
||||
await self.hal.cleanup()
|
||||
|
||||
async def show_welcome(self):
|
||||
"""Display welcome/instruction screen"""
|
||||
img = Image.new('RGB', (self.width, self.height), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Try to load a font, fall back to default
|
||||
try:
|
||||
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48)
|
||||
body_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
|
||||
except:
|
||||
title_font = ImageFont.load_default()
|
||||
body_font = ImageFont.load_default()
|
||||
|
||||
# Title
|
||||
title = "Accelerometer Calibration"
|
||||
title_bbox = draw.textbbox((0, 0), title, font=title_font)
|
||||
title_width = title_bbox[2] - title_bbox[0]
|
||||
draw.text(((self.width - title_width) // 2, 100), title, fill=(0, 0, 0), font=title_font)
|
||||
|
||||
# Instructions
|
||||
instructions = [
|
||||
"This will calibrate the accelerometer",
|
||||
"for gravity-based page flipping.",
|
||||
"",
|
||||
"You will:",
|
||||
"1. See an arrow on screen",
|
||||
"2. Rotate device until arrow points UP",
|
||||
"3. Tap screen to confirm",
|
||||
"",
|
||||
"Tap anywhere to begin..."
|
||||
]
|
||||
|
||||
y = 250
|
||||
for line in instructions:
|
||||
line_bbox = draw.textbbox((0, 0), line, font=body_font)
|
||||
line_width = line_bbox[2] - line_bbox[0]
|
||||
draw.text(((self.width - line_width) // 2, y), line, fill=(0, 0, 0), font=body_font)
|
||||
y += 50
|
||||
|
||||
await self.hal.show_image(img)
|
||||
|
||||
async def calibration_loop(self):
|
||||
"""Main calibration loop - show live arrow and accelerometer reading"""
|
||||
print("\nCalibration mode:")
|
||||
print("Rotate device until arrow points UP, then tap screen.")
|
||||
|
||||
last_display_time = 0
|
||||
display_interval = 0.2 # Update display every 200ms
|
||||
|
||||
while not self.calibrated:
|
||||
# Get current acceleration
|
||||
x, y, z = await self.hal.hal.orientation.get_acceleration()
|
||||
|
||||
# Update display if enough time has passed
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
if current_time - last_display_time >= display_interval:
|
||||
await self.show_calibration_screen(x, y, z)
|
||||
last_display_time = current_time
|
||||
|
||||
# Check for touch event
|
||||
event = await self.hal.get_touch_event()
|
||||
if event and event.gesture == GestureType.TAP:
|
||||
# Save current orientation as "up"
|
||||
self.up_vector = (x, y, z)
|
||||
self.calibrated = True
|
||||
print(f"\nCalibration saved: up_vector = ({x:.2f}, {y:.2f}, {z:.2f})")
|
||||
break
|
||||
|
||||
await asyncio.sleep(0.05) # Poll at ~20Hz
|
||||
|
||||
async def show_calibration_screen(self, ax: float, ay: float, az: float):
|
||||
"""
|
||||
Show arrow pointing in direction of gravity
|
||||
|
||||
Args:
|
||||
ax, ay, az: Acceleration components in m/s²
|
||||
"""
|
||||
img = Image.new('RGB', (self.width, self.height), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Calculate gravity direction (normalized)
|
||||
magnitude = math.sqrt(ax**2 + ay**2 + az**2)
|
||||
if magnitude < 0.1: # Avoid division by zero
|
||||
magnitude = 1.0
|
||||
|
||||
gx = ax / magnitude
|
||||
gy = ay / magnitude
|
||||
gz = az / magnitude
|
||||
|
||||
# Project gravity onto screen plane (assuming z is out of screen)
|
||||
# We want to show which way is "down" on the device
|
||||
# Arrow should point opposite to gravity (toward "up")
|
||||
arrow_dx = -gx
|
||||
arrow_dy = -gy
|
||||
|
||||
# Normalize for display
|
||||
arrow_length = min(self.width, self.height) * 0.3
|
||||
arrow_magnitude = math.sqrt(arrow_dx**2 + arrow_dy**2)
|
||||
if arrow_magnitude < 0.1:
|
||||
arrow_magnitude = 1.0
|
||||
|
||||
arrow_dx = (arrow_dx / arrow_magnitude) * arrow_length
|
||||
arrow_dy = (arrow_dy / arrow_magnitude) * arrow_length
|
||||
|
||||
# Center point
|
||||
cx = self.width // 2
|
||||
cy = self.height // 2
|
||||
|
||||
# Arrow endpoint
|
||||
end_x = cx + int(arrow_dx)
|
||||
end_y = cy + int(arrow_dy)
|
||||
|
||||
# Draw large arrow
|
||||
self.draw_arrow(draw, cx, cy, end_x, end_y, width=10)
|
||||
|
||||
# Draw circle at center
|
||||
circle_radius = 30
|
||||
draw.ellipse(
|
||||
[(cx - circle_radius, cy - circle_radius),
|
||||
(cx + circle_radius, cy + circle_radius)],
|
||||
outline=(0, 0, 0),
|
||||
width=5
|
||||
)
|
||||
|
||||
# Draw text with acceleration values
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 28)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
text = f"X: {ax:6.2f} m/s²"
|
||||
draw.text((50, 50), text, fill=(0, 0, 0), font=font)
|
||||
|
||||
text = f"Y: {ay:6.2f} m/s²"
|
||||
draw.text((50, 100), text, fill=(0, 0, 0), font=font)
|
||||
|
||||
text = f"Z: {az:6.2f} m/s²"
|
||||
draw.text((50, 150), text, fill=(0, 0, 0), font=font)
|
||||
|
||||
text = "Rotate device until arrow points UP"
|
||||
text_bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = text_bbox[2] - text_bbox[0]
|
||||
draw.text(((self.width - text_width) // 2, self.height - 150),
|
||||
text, fill=(0, 0, 0), font=font)
|
||||
|
||||
text = "Then TAP screen to save"
|
||||
text_bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = text_bbox[2] - text_bbox[0]
|
||||
draw.text(((self.width - text_width) // 2, self.height - 100),
|
||||
text, fill=(0, 0, 0), font=font)
|
||||
|
||||
await self.hal.show_image(img)
|
||||
|
||||
def draw_arrow(self, draw: ImageDraw.Draw, x1: int, y1: int, x2: int, y2: int, width: int = 5):
|
||||
"""Draw an arrow from (x1, y1) to (x2, y2)"""
|
||||
# Main line
|
||||
draw.line([(x1, y1), (x2, y2)], fill=(0, 0, 0), width=width)
|
||||
|
||||
# Arrow head
|
||||
dx = x2 - x1
|
||||
dy = y2 - y1
|
||||
length = math.sqrt(dx**2 + dy**2)
|
||||
|
||||
if length < 0.1:
|
||||
return
|
||||
|
||||
# Normalize
|
||||
dx /= length
|
||||
dy /= length
|
||||
|
||||
# Arrow head size
|
||||
head_length = 40
|
||||
head_width = 30
|
||||
|
||||
# Perpendicular vector
|
||||
px = -dy
|
||||
py = dx
|
||||
|
||||
# Arrow head points
|
||||
p1_x = x2 - dx * head_length + px * head_width
|
||||
p1_y = y2 - dy * head_length + py * head_width
|
||||
|
||||
p2_x = x2 - dx * head_length - px * head_width
|
||||
p2_y = y2 - dy * head_length - py * head_width
|
||||
|
||||
# Draw arrow head
|
||||
draw.polygon([(x2, y2), (p1_x, p1_y), (p2_x, p2_y)], fill=(0, 0, 0))
|
||||
|
||||
async def show_completion(self):
|
||||
"""Show calibration complete screen"""
|
||||
img = Image.new('RGB', (self.width, self.height), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
try:
|
||||
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48)
|
||||
body_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
|
||||
except:
|
||||
title_font = ImageFont.load_default()
|
||||
body_font = ImageFont.load_default()
|
||||
|
||||
# Title
|
||||
title = "Calibration Complete!"
|
||||
title_bbox = draw.textbbox((0, 0), title, font=title_font)
|
||||
title_width = title_bbox[2] - title_bbox[0]
|
||||
draw.text(((self.width - title_width) // 2, 200), title, fill=(0, 0, 0), font=title_font)
|
||||
|
||||
# Details
|
||||
if self.up_vector:
|
||||
x, y, z = self.up_vector
|
||||
details = [
|
||||
f"Up vector saved:",
|
||||
f"X: {x:.3f} m/s²",
|
||||
f"Y: {y:.3f} m/s²",
|
||||
f"Z: {z:.3f} m/s²",
|
||||
"",
|
||||
f"Saved to: {self.config_path}"
|
||||
]
|
||||
|
||||
y_pos = 350
|
||||
for line in details:
|
||||
line_bbox = draw.textbbox((0, 0), line, font=body_font)
|
||||
line_width = line_bbox[2] - line_bbox[0]
|
||||
draw.text(((self.width - line_width) // 2, y_pos), line, fill=(0, 0, 0), font=body_font)
|
||||
y_pos += 50
|
||||
|
||||
await self.hal.show_image(img)
|
||||
|
||||
# Save calibration to file
|
||||
self.save_calibration()
|
||||
|
||||
def save_calibration(self):
|
||||
"""Save calibration data to JSON file"""
|
||||
if not self.up_vector:
|
||||
print("Warning: No calibration data to save")
|
||||
return
|
||||
|
||||
x, y, z = self.up_vector
|
||||
|
||||
config = {
|
||||
"up_vector": {
|
||||
"x": x,
|
||||
"y": y,
|
||||
"z": z
|
||||
},
|
||||
"tilt_threshold": 0.3, # Radians (~17 degrees)
|
||||
"debounce_time": 0.5, # Seconds between tilt gestures
|
||||
}
|
||||
|
||||
with open(self.config_path, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
print(f"Calibration saved to {self.config_path}")
|
||||
|
||||
async def wait_for_tap(self):
|
||||
"""Wait for user to tap screen"""
|
||||
while True:
|
||||
event = await self.hal.get_touch_event()
|
||||
if event and event.gesture == GestureType.TAP:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point"""
|
||||
# Create HAL with accelerometer enabled
|
||||
print("Initializing hardware...")
|
||||
hal = HardwareDisplayHAL(
|
||||
width=1872,
|
||||
height=1404,
|
||||
enable_orientation=True,
|
||||
enable_rtc=False,
|
||||
enable_power_monitor=False,
|
||||
virtual_display=False # Set to True for testing without hardware
|
||||
)
|
||||
|
||||
# Create calibrator
|
||||
calibrator = AccelerometerCalibrator(hal)
|
||||
|
||||
# Run calibration
|
||||
await calibrator.run()
|
||||
|
||||
print("\nCalibration complete!")
|
||||
print("You can now use accelerometer-based page flipping.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\nCalibration cancelled by user")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"\nError during calibration: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
Executable
+212
@@ -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)
|
||||
Executable
+118
@@ -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)
|
||||
Executable
+292
@@ -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)
|
||||
Executable
+293
@@ -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)
|
||||
Reference in New Issue
Block a user