Skip to content
Merged

Dev #18

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ on:

env:
DOCKER_IMAGE: xatang/pyunit
PLATFORMS: linux/amd64,linux/arm64
PLATFORMS: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v8

jobs:
build-and-push:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ env/

# Database
*.db
*.db-wal
*.db-shm
*.sqlite
*.sqlite3

Expand Down
71 changes: 59 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ PyUnit is a custom firmware for [iDryer Unit](https://github.com/pavluchenkor/iD
- 🔗 **WebSocket real-time updates**
- 📋 **Embed pages** for Klipper HTTP camera integration
- 🤖 **G-code macro generation** for Klipper
- 🐳 **Multi-platform Docker images** (amd64, arm64)
- 🐳 **Multi-platform Docker images** (amd64, arm64, arm/v7, arm/v8)

## � Screenshots
## 📸 Screenshots

### Dashboard Overview
![Dashboard](images/dashboard.png)
Expand All @@ -42,7 +42,7 @@ PyUnit is a custom firmware for [iDryer Unit](https://github.com/pavluchenkor/iD
![Fluidd Integration](images/fluidd-integration.png)
*PyUnit embedded control panel in Fluidd/Mainsail interface*

## � Quick Start
## 🚀 Quick Start

### One-Command Installation

Expand All @@ -67,10 +67,21 @@ The script will automatically:
### Requirements

- **OS:** Linux (Ubuntu, Debian, Raspbian, Armbian)
- **Architecture:** amd64 (x86_64) or arm64 (aarch64)
- **Architecture:**
- amd64 (x86_64) - Intel/AMD 64-bit
- arm64 (aarch64) - ARM 64-bit
- arm/v7 - ARMv7 32-bit
- arm/v8 - ARMv8 32-bit
- **RAM:** 256MB minimum (512MB recommended)
- **Disk:** ~1GB for Docker image

#### Additionally, you can add the following commands to the Fluidd or Mainsail console filters to avoid a flood of messages during firmware operation.
```bash
SET_LED LED=[led_name] INDEX=
SET_HEATER_TEMPERATURE HEATER=[heater_name]
SET_SERVO SERVO=[servo_name]
```

### Updates

To update to the latest version:
Expand All @@ -80,8 +91,30 @@ cd PyUnit
./run.sh
```


Updates preserve your configuration and IP settings automatically.

### Uninstallation

To completely remove PyUnit (container, volumes, images, network):

```bash
cd PyUnit
chmod +x uninstall.sh
./uninstall.sh
```

This will remove:
- Docker container `pyunit`
- Docker volume `pyunit_pyunit_data` (all data will be lost!)
- Docker network `pyunit_pyunit_network`
- Docker images `xatang/pyunit:*`

**Note:** Local files (pyunit.db, logs) are NOT deleted automatically. To remove them:
```bash
rm -f pyunit.db pyunit.db-wal pyunit.db-shm app.log dryer.log
```

## 🏗️ Architecture

### Backend (FastAPI + Python 3.11)
Expand Down Expand Up @@ -179,7 +212,7 @@ sudo docker compose up -d
**Starting a Drying Cycle:**
1. Select a dryer from the list
2. Choose a preset
3. Click **Start** - the dryer will
3. Click **Start** - the dryer will begin the drying process

### Configuration

Expand All @@ -193,12 +226,26 @@ sudo docker compose up -d

### Klipper Integration

**Example Configuration:**

A complete Klipper configuration example is available in `config_and_macros/U1.cfg.example`. This file contains:
- Heater configuration (`[heater_generic]`)
- Temperature sensor setup (`[temperature_sensor]`)
- LED control (`[neopixel]`)
- Servo configuration (`[servo]`)
- G-code macros for dryer control

**⚠️ Important:** All module and sensor names in Klipper configuration **must be lowercase**:
- ✅ Correct: `idryer_u1_heater`, `idryer_u1_air`, `srv_u1`
- ❌ Wrong: `IDryer_U1_Heater`, `IDRYER_U1_AIR`, `SRV_U1`

**G-code Macros:**
1. Go to dryer detail page
2. Expand **Klipper G-code Macros**
3. Configure Python script path
4. Copy macros to `printer.cfg`
5. Use in Klipper:
1. To control the drying process via gcode macro, your printer must have the [gcode_shell_command](https://github.com/dw-0/kiauh/blob/master/docs/gcode_shell_command.md) extension installed.
2. After installation gcode_shell_command go to dryer detail page
3. Expand **Klipper G-code Macros**
4. Configure Python script path
5. Copy macros to `printer.cfg`
6. Use in Klipper:
```gcode
OFF_DRYER ID=1
PRESET_DRYER ID=1 PRESET_ID=5
Expand All @@ -212,13 +259,13 @@ sudo docker compose up -d
[camera pyunit_control]
location: dryer
service: http
target_fps: 15
aspect_ratio: 4:3
stream_url: http://192.168.1.100:5000/embed/dryer/1/control

[camera pyunit_chart]
location: dryer
service: http
target_fps: 15
aspect_ratio: 4:3
stream_url: http://192.168.1.100:5000/embed/dryer/1/chart
```

Expand Down
47 changes: 43 additions & 4 deletions api/cruds/dryer_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,50 @@ async def add_log(self, db: AsyncSession, log_data: schema.DryerLog) -> models.D
await db.refresh(db_log)
return schema.DryerLog.from_orm(db_log) if db_log else None

async def get_logs(self, db: AsyncSession) -> list[models.DryerLogs]:
"""Return all dryer logs (no filtering)."""
result = await db.execute(select(models.DryerLogs))
async def get_logs(self, db: AsyncSession, dryer_id: Optional[int] = None,
start_time: Optional[str] = None, end_time: Optional[str] = None,
limit: Optional[int] = None) -> list[models.DryerLogs]:
"""Return dryer logs with optional filtering.

Args:
db: Database session
dryer_id: Filter by specific dryer (None = all dryers)
start_time: ISO format datetime string for filtering logs after this time
end_time: ISO format datetime string for filtering logs before this time
limit: Maximum number of logs to return (most recent first)
"""
query = select(models.DryerLogs)

if dryer_id is not None:
query = query.where(models.DryerLogs.dryer_id == dryer_id)

if start_time:
from datetime import datetime
start_dt = datetime.fromisoformat(start_time.replace('Z', '+00:00'))
query = query.where(models.DryerLogs.timestamp >= start_dt)

if end_time:
from datetime import datetime
end_dt = datetime.fromisoformat(end_time.replace('Z', '+00:00'))
query = query.where(models.DryerLogs.timestamp <= end_dt)

# Order by timestamp descending (most recent first)
query = query.order_by(models.DryerLogs.timestamp.desc())

if limit:
query = query.limit(limit)

result = await db.execute(query)
db_logs = result.scalars().all()
return [schema.DryerLog.from_orm(log) for log in db_logs] if db_logs else None

# Return in chronological order (oldest first) for historical data
logs_list = [schema.DryerLog.from_orm(log) for log in reversed(db_logs)] if db_logs else []

if dryer_id:
logger.debug("get_logs dryer_id=%s start=%s end=%s limit=%s returned=%d",
dryer_id, start_time, end_time, limit, len(logs_list))

return logs_list


dryer_crud = DryerCRUD()
35 changes: 33 additions & 2 deletions api/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text, event
from api.models import Base, Preset, MoonrakerConfig
import subprocess
from api.logger import get_logger
Expand All @@ -25,9 +26,36 @@
engine = create_async_engine(
DATABASE_URL,
echo=False,
future=True
future=True,
connect_args={
"timeout": 30, # Increase timeout for lock acquisition (default is 5)
"check_same_thread": False # Allow multi-threaded access
},
pool_pre_ping=True, # Verify connections before use
pool_size=10, # Increase connection pool size
max_overflow=20 # Allow more overflow connections
)

# Configure SQLite PRAGMA settings for each connection
@event.listens_for(engine.sync_engine, "connect")
def set_sqlite_pragma(dbapi_conn, connection_record):
"""Apply SQLite optimizations on each new connection.

Critical for Orange Pi / SD card deployments:
- WAL mode: allows concurrent reads during writes
- synchronous=NORMAL: safe with WAL, much faster on slow storage
- cache_size: reduces SD card access
- temp_store: keeps temp data in RAM
"""
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=30000") # 30 second timeout
cursor.execute("PRAGMA synchronous=NORMAL") # Trade durability for speed (safe with WAL)
cursor.execute("PRAGMA cache_size=-64000") # 64MB cache (negative = KB)
cursor.execute("PRAGMA temp_store=MEMORY") # Keep temp tables in RAM
cursor.close()
db_logger.debug("SQLite PRAGMAs applied to new connection")

# Reduce SQLAlchemy internal logger verbosity
for _name in [
'sqlalchemy.engine',
Expand Down Expand Up @@ -91,7 +119,10 @@ def run_migrations():


async def init_db():
"""Create tables based on current SQLAlchemy metadata (non-destructive)."""
"""Create tables based on current SQLAlchemy metadata (non-destructive).

Note: PRAGMA settings are now applied automatically via engine.connect event.
"""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
db_logger.info("Database schema ensured (create_all)")
Expand Down
19 changes: 17 additions & 2 deletions api/endpoints/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,24 @@ async def ws_info():
{
"path": "/api/dashboard/dryers",
"channel": "dryers_stats",
"purpose": "Historical + live dryer state updates (JSON objects)",
"purpose": "DEPRECATED: Historical + live dryer state updates for ALL dryers (JSON objects). Use /dashboard/dryer/{id} for better performance.",
"client_send": "ignored (reserved for future commands)",
"server_send_example": {"history": [{"id": 1, "status": "DRYING"}]}
"server_send_example": {"history": [{"id": 1, "dryer_id": 1, "status": "drying", "temperature": 45.5}]},
"deprecated": True,
"replacement": "/api/dashboard/dryer/{dryer_id}"
},
{
"path": "/api/dashboard/dryer/{dryer_id}",
"channel": "dryer_{dryer_id}_stats",
"purpose": "RECOMMENDED: Optimized historical + live updates for a SINGLE dryer (JSON objects)",
"query_params": {
"start_time": "ISO datetime string (e.g., 2025-12-18T10:00:00Z) - filter logs after this time",
"end_time": "ISO datetime string - filter logs before this time",
"limit": "Maximum number of historical logs to load (default: 100, prevents memory issues)"
},
"client_send": "ignored (reserved for future commands)",
"server_send_example": {"history": [{"id": 1, "dryer_id": 1, "status": "drying", "temperature": 45.5, "timestamp": "2025-12-18T12:00:00Z"}]},
"benefits": "500x less data on connection, time-based filtering, single dryer focus"
}
]
logger.debug("GET /common/ws-info return %d channels", len(websockets))
Expand Down
Loading