Merge pull request #6 from maziggy/0.1.3

v0.1.3

  New Features

  1. Push Notifications
    - Multiple provider support: WhatsApp (CallMeBot), ntfy, Pushover, Telegram, Email
    - Configurable event triggers: print start, complete, failed, stopped, progress milestones
    - Quiet hours to suppress notifications during specified times
    - Per-printer filtering
  2. K-Profile Management
    - View and manage pressure advance (K) profiles stored on printers
    - Sync profiles from printer
    - Create and delete profiles
  3. Energy Tracking Options
    - Choice to track energy per print only or total cumulative energy
  4. Sidebar Customization
    - Sidebar items can now be reordered
    - Settings for default view
  5. Configurable Logging
    - Adjustable log levels and output options
  6. Timelapse Viewer
    - Escape key now closes the modal (just added)

  Bug Fixes

  - Fixed WebSocket connection issues
  - Fixed printer power stage not updating in frontend
  - Various minor bugfixes and improvements
This commit is contained in:
MartinNYHC
2025-11-30 17:10:12 +01:00
committed by GitHub
58 changed files with 42733 additions and 676 deletions
+12
View File
@@ -0,0 +1,12 @@
# BambuTrack Environment Configuration
# Copy this file to .env and adjust values as needed
# Debug mode (true = DEBUG logging, false = production with INFO logging)
DEBUG=true
# Log level (only used when DEBUG=false)
# Options: DEBUG, INFO, WARNING, ERROR
LOG_LEVEL=INFO
# Enable file logging (logs written to logs/bambutrack.log)
LOG_TO_FILE=true
+1
View File
@@ -41,3 +41,4 @@ archive/
# Logs
*.log
logs/
+186
View File
@@ -0,0 +1,186 @@
# Notifications Feature Implementation Plan
## Overview
Add push notifications for print events (start, complete, fail) with support for multiple notification providers.
## Supported Providers (Initial Release)
1. **CallMeBot/WhatsApp** - Free, uses HTTP API with phone number + API key
2. **ntfy** - Self-hosted or ntfy.sh, simple HTTP POST
3. **Pushover** - Commercial ($5 one-time), HTTP API with user key + app token
4. **Telegram** - Free bot API, requires bot token + chat ID
5. **Email (SMTP)** - Universal fallback
## Database Design
### New Table: `notification_providers`
```sql
CREATE TABLE notification_providers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL, -- User-defined name ("My WhatsApp")
provider_type TEXT NOT NULL, -- "callmebot", "ntfy", "pushover", "telegram", "email"
enabled BOOLEAN DEFAULT true,
-- Provider-specific config (JSON or individual fields)
config TEXT NOT NULL, -- JSON: {"phone": "+1234", "apikey": "xxx"}
-- Event triggers (which events send notifications)
on_print_start BOOLEAN DEFAULT false,
on_print_complete BOOLEAN DEFAULT true,
on_print_failed BOOLEAN DEFAULT true,
-- Optional: Link to specific printer (NULL = all printers)
printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
-- Timestamps
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
### Config JSON Structure by Provider
```python
# CallMeBot/WhatsApp
{"phone": "+1234567890", "apikey": "123456"}
# ntfy
{"server": "https://ntfy.sh", "topic": "my-printer", "auth_token": "optional"}
# Pushover
{"user_key": "xxx", "app_token": "yyy", "priority": 0}
# Telegram
{"bot_token": "123:ABC", "chat_id": "12345678"}
# Email (SMTP)
{"smtp_server": "smtp.gmail.com", "smtp_port": 587, "username": "x", "password": "y", "from_email": "x@gmail.com", "to_email": "dest@example.com"}
```
## Backend Implementation
### 1. Model: `backend/app/models/notification.py`
- SQLAlchemy model for `notification_providers` table
- Relationship to Printer (optional, nullable)
### 2. Schema: `backend/app/schemas/notification.py`
- `NotificationProviderBase` - Common fields
- `NotificationProviderCreate` - For creating new providers
- `NotificationProviderUpdate` - For partial updates
- `NotificationProviderResponse` - API response with id/timestamps
- `NotificationTestRequest` - For testing notifications
### 3. Service: `backend/app/services/notification_service.py`
Core notification dispatcher with provider implementations:
```python
class NotificationService:
async def send_notification(self, provider: NotificationProvider, event: str, data: dict) -> bool
async def on_print_start(self, printer_id: int, data: dict, db: AsyncSession)
async def on_print_complete(self, printer_id: int, status: str, data: dict, db: AsyncSession)
# Provider-specific methods
async def _send_callmebot(self, config: dict, message: str) -> bool
async def _send_ntfy(self, config: dict, title: str, message: str) -> bool
async def _send_pushover(self, config: dict, title: str, message: str) -> bool
async def _send_telegram(self, config: dict, message: str) -> bool
async def _send_email(self, config: dict, subject: str, body: str) -> bool
```
### 4. Routes: `backend/app/api/routes/notifications.py`
```
GET /notifications/ - List all providers
POST /notifications/ - Create provider
GET /notifications/{id} - Get provider details
PATCH /notifications/{id} - Update provider
DELETE /notifications/{id} - Delete provider
POST /notifications/{id}/test - Send test notification
POST /notifications/test-config - Test config before saving
```
### 5. Integration in `main.py`
Add calls to notification service in existing event handlers:
- `on_print_start()` - After smart_plug_manager call (line ~244)
- `on_print_complete()` - After archive update (line ~580)
## Frontend Implementation
### 1. API Client: `frontend/src/api/client.ts`
Add types and API methods for notification providers.
### 2. Components
- `NotificationProviderCard.tsx` - Display single provider with enable/disable toggle
- `AddNotificationModal.tsx` - Modal for adding/editing providers with provider-specific forms
### 3. Settings Page Integration
Add "Notifications" section in SettingsPage.tsx (similar to Smart Plugs section):
- List of configured providers
- Add button
- Per-provider enable/disable
- Test button
- Event toggles (start/complete/failed)
## Message Templates
### Print Started
```
🖨️ Print Started
{printer_name}: {filename}
Estimated time: {est_time}
```
### Print Completed
```
✅ Print Completed
{printer_name}: {filename}
Time: {actual_time}
Filament: {filament_used}g
```
### Print Failed
```
❌ Print Failed
{printer_name}: {filename}
Status: {failure_reason}
Progress: {progress}%
```
## Implementation Order
### Phase 1: Backend Core
1. Create notification model with migrations
2. Create notification schema
3. Create notification service with all 5 providers
4. Create notification routes (CRUD + test)
5. Register routes in main.py
6. Integrate into print event handlers
### Phase 2: Frontend
7. Add API types and methods
8. Create NotificationProviderCard component
9. Create AddNotificationModal component
10. Add Notifications section to SettingsPage
### Phase 3: Testing & Polish
11. Test each provider
12. Add error handling and logging
13. Handle network failures gracefully (don't block print events)
## Technical Notes
### Async HTTP Requests
Use `httpx` (already available) for async HTTP calls to notification APIs.
### Error Handling
- Notifications should NEVER block print events
- Log failures but continue processing
- Store last_error and last_success timestamps for UI feedback
### Security
- Store credentials in database (SQLite file already contains access codes)
- Consider encryption for sensitive fields in future
### Rate Limiting
- Debounce rapid events (don't spam on quick start/stop cycles)
- Consider per-provider rate limits
## Questions for User
None - proceeding with the 5 providers as discussed.
+212 -2
View File
@@ -73,6 +73,21 @@ v∆v
- Multi-color support with color swatches
- **Failure Analysis** - Document failed prints with notes and photos
- **Project Page Editor** - View and edit embedded MakerWorld project pages with images, descriptions, and designer info
- **K-Profiles (Pressure Advance)** - Manage pressure advance settings directly on your printers
- View, edit, add, and delete K-profiles per printer
- Filter by nozzle size (0.2, 0.4, 0.6, 0.8mm) and flow type (High Flow, Standard)
- Search by profile name or filament
- Dual-nozzle support for H2 series (auto-detected from MQTT)
- Left/Right extruder column layout for dual-nozzle printers
- **Push Notifications** - Get notified about print events via multiple channels:
- WhatsApp (via CallMeBot)
- ntfy (self-hosted or ntfy.sh)
- Pushover
- Telegram
- Email (SMTP with TLS/SSL/plain options)
- Configurable event triggers (start, complete, failed, stopped, progress milestones)
- Quiet hours to suppress notifications during sleep
- Per-printer filtering
- **Cloud Profiles Sync** - Access your Bambu Cloud slicer presets
- **File Manager** - Browse and manage files on your printer's SD card
- **Re-print** - Send archived prints back to any connected printer
@@ -83,7 +98,7 @@ v∆v
<!-- Add your screenshots here -->
<p align="center">
<img src="docs/screenshots/dashboard.png" alt="Dashboard" width="800">
<img src="docs/screenshots/statistics.png" alt="Statustics" width="800">
<br><em>Customizable Dashboard with drag-and-drop widgets</em>
</p>
@@ -102,6 +117,11 @@ v∆v
<br><em>Queueing and scheduling</em>
</p>
<p align="center">
<img src="docs/screenshots/k_profiles.png" alt="k Profiles" width="800">
<br><em>k Profiles</em>
</p>
<p align="center">
<img src="docs/screenshots/cloud_profiles.png" alt="Cloud Profiles" width="800">
<br><em>Cloud Profiles</em>
@@ -328,6 +348,36 @@ To connect Bambusy to your printer, you need to enable LAN Mode:
The printer should connect automatically and show real-time status.
### Environment Variables
Bambusy can be configured using environment variables or a `.env` file in the project root. Copy `.env.example` to `.env` and adjust as needed:
```bash
cp .env.example .env
```
| Variable | Default | Description |
|----------|---------|-------------|
| `DEBUG` | `false` | Enable debug mode (verbose logging, SQL queries) |
| `LOG_LEVEL` | `INFO` | Log level when DEBUG=false (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
| `LOG_TO_FILE` | `true` | Write logs to `logs/bambutrack.log` |
**Production (default):**
- INFO level logging
- SQLAlchemy and HTTP library noise suppressed
- Logs written to `logs/bambutrack.log` (5MB rotating, 3 backups)
**Development (`DEBUG=true`):**
- DEBUG level logging (verbose)
- All SQL queries logged
- Useful for troubleshooting printer connections
Example `.env` for development:
```bash
DEBUG=true
LOG_TO_FILE=true
```
## Usage
### Keyboard Shortcuts
@@ -400,6 +450,52 @@ When a scheduled print is ready to start:
4. Click "Edit" to modify the project page metadata
5. Changes are saved directly to the 3MF file
### K-Profiles (Pressure Advance)
K-profiles store pressure advance (Linear Advance) settings for different filament and nozzle combinations. Bambusy lets you view and manage these settings directly on your printers.
#### Viewing K-Profiles
1. Go to **Settings** > **K-Profiles**
2. Select a connected printer from the dropdown
3. Choose a nozzle size (0.2, 0.4, 0.6, or 0.8mm)
4. Profiles are displayed with:
- K-value (pressure advance factor)
- Profile name and filament
- Flow type (HF = High Flow, S = Standard)
#### Dual-Nozzle Printers (H2 Series)
For dual-nozzle printers (H2D, H2C, H2S), Bambusy automatically detects the nozzle configuration and displays:
- **Left/Right columns** showing profiles for each extruder
- **Extruder filter** to show profiles for one extruder only
- **Extruder selector** when adding new profiles
The nozzle count is auto-detected from MQTT temperature data when the printer connects.
#### Editing K-Profiles
1. Click on any profile card to open the edit modal
2. Modify the K-value (typical ranges: 0.01-0.06 for PLA, 0.02-0.10 for PETG)
3. Click **Save** to update the profile on the printer
4. Click the trash icon to delete a profile (with confirmation)
#### Adding K-Profiles
1. Click **Add Profile** in the header
2. Select a filament from the dropdown (populated from existing profiles on the printer)
3. Choose flow type (High Flow or Standard) and nozzle size
4. For dual-nozzle printers, select Left or Right extruder
5. Enter the K-value and click **Save**
**Note:** Filaments must first be calibrated in Bambu Studio to appear in the dropdown. Bambusy reads the filament list from existing K-profiles on the printer.
#### Filtering and Search
- **Search**: Type to filter by profile name or filament ID
- **Extruder filter** (dual-nozzle only): Show All, Left Only, or Right Only
- **Flow type filter**: Show All, HF Only, or S Only
### Smart Plug Integration
Bambusy supports Tasmota-based smart plugs for automated power control. This is useful for:
@@ -446,6 +542,91 @@ Each plug card shows:
- On/Off buttons for manual control
- Expandable settings panel
### Push Notifications
Bambusy can send push notifications when print events occur. Notifications are useful for monitoring prints remotely without checking the app constantly.
#### Supported Providers
| Provider | Description | Setup Required |
|----------|-------------|----------------|
| **WhatsApp** | Via [CallMeBot](https://www.callmebot.com/blog/free-api-whatsapp-messages/) | Free API key from CallMeBot |
| **ntfy** | Self-hosted or [ntfy.sh](https://ntfy.sh) | Just a topic name (no account needed for public server) |
| **Pushover** | [Pushover](https://pushover.net/) push notifications | Pushover account + app token |
| **Telegram** | Via Telegram Bot | Bot token from @BotFather |
| **Email** | SMTP email | SMTP server credentials |
#### Adding a Notification Provider
1. Go to **Settings** > **Notifications**
2. Click **Add Provider**
3. Select a provider type and enter the required configuration
4. Click **Send Test** to verify the configuration works
5. Configure which events should trigger notifications
6. Click **Add**
#### Event Triggers
Configure which events send notifications:
| Event | Description |
|-------|-------------|
| **Print Started** | When a print job begins |
| **Print Completed** | When a print finishes successfully |
| **Print Failed** | When a print fails or errors out |
| **Print Stopped** | When you manually stop/cancel a print |
| **Progress Milestones** | At 25%, 50%, and 75% progress |
| **Printer Offline** | When a printer disconnects |
| **Printer Error** | When HMS errors are detected |
| **Low Filament** | When filament is running low |
#### Quiet Hours
Enable quiet hours to suppress notifications during sleep or work hours:
1. Enable **Quiet Hours** toggle
2. Set start time (e.g., 22:00)
3. Set end time (e.g., 07:00)
Notifications during quiet hours are silently skipped.
#### Per-Printer Filtering
By default, notifications are sent for all printers. To limit notifications to a specific printer:
1. Open the notification provider settings
2. Select a printer from the **Printer** dropdown
3. Only events from that printer will trigger notifications
#### Provider Setup Guides
**WhatsApp (CallMeBot):**
1. Add CallMeBot to your contacts: +34 644 51 95 23
2. Send "I allow callmebot to send me messages" via WhatsApp
3. You'll receive an API key
4. Enter your phone number (with country code) and API key in Bambusy
**ntfy:**
1. Choose a unique topic name (e.g., `my-printer-alerts-xyz123`)
2. Subscribe to it on your phone using the ntfy app or web interface
3. Enter the topic name in Bambusy (server defaults to ntfy.sh)
**Pushover:**
1. Create an account at [pushover.net](https://pushover.net/)
2. Create an application to get an API token
3. Enter your user key and app token in Bambusy
**Telegram:**
1. Message @BotFather on Telegram to create a bot
2. Get your chat ID by messaging @userinfobot
3. Enter the bot token and chat ID in Bambusy
**Email:**
1. Configure your SMTP server settings
2. For Gmail, use an App Password (not your regular password)
3. Choose security mode: STARTTLS (port 587), SSL (port 465), or None (port 25)
4. Enable/disable authentication as needed
## Tech Stack
- **Backend**: Python / FastAPI
@@ -569,6 +750,34 @@ Common causes:
- **File upload failed** - Check FTP connectivity to the printer
- **HMS errors** - Check the printer for any health system errors that prevent printing
### Timelapse not attaching automatically
**The Problem:**
When printers run in **LAN-only mode** (disconnected from Bambu Cloud), they cannot sync time via NTP. This causes the printer's internal clock to drift significantly (sometimes days or weeks off). Bambusy matches timelapses by comparing the print completion time with the timelapse file's modification time - when the printer's clock is wrong, this matching fails.
**Symptoms:**
- "Scan for Timelapse" shows "No matching timelapse found"
- Timelapse files exist on the printer but don't auto-attach
- Printer shows incorrect date/time in its settings
**Workaround - Manual Selection:**
When automatic matching fails, Bambusy now offers manual timelapse selection:
1. Right-click the archive and select **"Scan for Timelapse"**
2. If no match is found, a dialog appears showing all available timelapse files on the printer
3. Files are sorted by date (newest first) with size information
4. Select the correct timelapse and click to attach it
**Permanent Fix:**
To fix the printer's clock:
1. Temporarily connect the printer to the internet (via router or mobile hotspot)
2. Wait for the printer to sync time via NTP
3. Return to LAN-only mode - the clock should remain accurate until the next power cycle
**Note:** Some users report the clock resets after power cycling. In this case, you'll need to either:
- Periodically connect to the internet to sync time
- Use the manual timelapse selection feature
## Known Issues / Roadmap
### Beta Limitations
@@ -588,8 +797,9 @@ Common causes:
- [x] Energy monitoring and statistics
- [x] Print scheduling and queuing
- [x] Automatic finish photo capture
- [x] K-Profiles management (pressure advance)
- [x] Push notifications (WhatsApp, ntfy, Pushover, Telegram, Email)
- [ ] Maintenance tracker
- [ ] Notifications (email, push)
- [ ] Mobile-optimized UI
## License
+185 -26
View File
@@ -3,7 +3,7 @@ import zipfile
import io
import logging
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request, Query
logger = logging.getLogger(__name__)
from fastapi.responses import FileResponse, Response
@@ -209,16 +209,39 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
for printer_key, accs in printer_accuracies.items():
accuracy_by_printer[printer_key] = round(sum(accs) / len(accs), 1)
# Energy totals
energy_kwh_result = await db.execute(
select(func.sum(PrintArchive.energy_kwh))
)
total_energy_kwh = energy_kwh_result.scalar() or 0
# Energy totals - check which mode to use
from backend.app.api.routes.settings import get_setting
energy_tracking_mode = await get_setting(db, "energy_tracking_mode") or "total"
energy_cost_per_kwh_str = await get_setting(db, "energy_cost_per_kwh")
energy_cost_per_kwh = float(energy_cost_per_kwh_str) if energy_cost_per_kwh_str else 0.15
energy_cost_result = await db.execute(
select(func.sum(PrintArchive.energy_cost))
)
total_energy_cost = energy_cost_result.scalar() or 0
if energy_tracking_mode == "total":
# Total mode: sum up 'total' counter from all smart plugs (lifetime consumption)
from backend.app.models.smart_plug import SmartPlug
from backend.app.services.tasmota import tasmota_service
plugs_result = await db.execute(select(SmartPlug))
plugs = list(plugs_result.scalars().all())
total_energy_kwh = 0.0
for plug in plugs:
energy = await tasmota_service.get_energy(plug)
if energy and energy.get("total") is not None:
total_energy_kwh += energy["total"]
total_energy_kwh = round(total_energy_kwh, 3)
total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 2)
else:
# Print mode: sum up per-print energy from archives
energy_kwh_result = await db.execute(
select(func.sum(PrintArchive.energy_kwh))
)
total_energy_kwh = energy_kwh_result.scalar() or 0
energy_cost_result = await db.execute(
select(func.sum(PrintArchive.energy_cost))
)
total_energy_cost = energy_cost_result.scalar() or 0
return ArchiveStats(
total_prints=total_prints,
@@ -618,11 +641,15 @@ async def scan_timelapse(
break
# Strategy 2: Match by timestamp proximity
# Bambu timelapse filename uses the print START time (when recording began)
if not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
import re
from datetime import datetime, timedelta
archive_time = archive.started_at or archive.completed_at or archive.created_at
# Prefer started_at since video filename is the print start time
# Fall back to completed_at or created_at if started_at is not available
archive_start = archive.started_at
archive_end = archive.completed_at or archive.created_at
best_match = None
best_diff = timedelta(hours=24) # Max 24 hour difference
@@ -633,25 +660,76 @@ async def scan_timelapse(
if match:
try:
file_time = datetime.strptime(match.group(1), "%Y-%m-%d_%H-%M-%S")
# Timelapse is usually created at print end, so compare to completed_at or created_at
compare_time = archive.completed_at or archive.created_at
if compare_time:
# Bambu printers use China Standard Time (UTC+8) for filenames
# Try matching with CST offset adjustment
diff_direct = abs(file_time - compare_time)
# Also try with 8-hour offset (CST to UTC-ish local times)
diff_cst_adjusted = abs(file_time - timedelta(hours=8) - compare_time)
diff = min(diff_direct, diff_cst_adjusted)
if diff < best_diff:
best_diff = diff
best_match = f
# Try multiple timezone offsets since printer timezone can vary
# Common cases: local time (0), CST/UTC+8 (+8), or UTC (-local offset)
for hour_offset in [0, 8, -8, 7, -7, 1, -1]:
adjusted_file_time = file_time - timedelta(hours=hour_offset)
# Check against start time (video filename = print start)
if archive_start:
diff = abs(adjusted_file_time - archive_start)
if diff < best_diff:
best_diff = diff
best_match = f
logger.debug(
f"Timelapse match candidate: {fname} with offset {hour_offset}h, "
f"diff from start: {diff}"
)
# Also check against end time with a buffer
# (video timestamp should be BEFORE completion time)
if archive_end:
# The video timestamp should be within the print duration before completion
if adjusted_file_time < archive_end:
diff = archive_end - adjusted_file_time
# Reasonable print duration: up to 48 hours
if diff < timedelta(hours=48) and diff < best_diff:
best_diff = diff
best_match = f
logger.debug(
f"Timelapse match candidate (from end): {fname} with offset {hour_offset}h, "
f"diff: {diff}"
)
except ValueError:
continue
if best_match and best_diff < timedelta(hours=2): # Within 2 hours
# Accept match within 4 hours (more lenient for timezone issues)
if best_match and best_diff < timedelta(hours=4):
matching_file = best_match
logger.info(f"Matched timelapse by timestamp: {best_match.get('name')} (diff: {best_diff})")
# Strategy 3: If only one timelapse exists and archive was recently completed, use it
# Strategy 3: Use file modification time from FTP listing
# This handles cases where printer's filename timestamp is wrong but file mtime is correct
if not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
from datetime import datetime, timedelta
archive_start = archive.started_at
archive_end = archive.completed_at or archive.created_at
best_match = None
best_diff = timedelta(hours=24)
for f in mp4_files:
mtime = f.get("mtime")
if mtime:
# Timelapse file should be modified during or shortly after the print
# The mtime should be close to completion time (video finishes when print ends)
if archive_end:
diff = abs(mtime - archive_end)
if diff < best_diff:
best_diff = diff
best_match = f
logger.debug(
f"Timelapse mtime match candidate: {f.get('name')}, "
f"mtime: {mtime}, diff from end: {diff}"
)
if best_match and best_diff < timedelta(hours=2):
matching_file = best_match
logger.info(f"Matched timelapse by file mtime: {best_match.get('name')} (diff: {best_diff})")
# Strategy 4: If only one timelapse exists and archive was recently completed, use it
# This handles cases where printer clock is wrong or timezone issues exist
if not matching_file and len(mp4_files) == 1:
from datetime import datetime, timedelta
@@ -663,8 +741,28 @@ async def scan_timelapse(
matching_file = mp4_files[0]
logger.info(f"Using single timelapse file as fallback: {mp4_files[0].get('name')}")
# Note: We intentionally don't use a "most recent file" fallback because
# we can't verify if timelapse was actually enabled for this print.
# Instead, return the list of available files for manual selection.
if not matching_file:
return {"status": "not_found", "message": "No matching timelapse found on printer"}
# Return available files for manual selection
available_files = [
{
"name": f.get("name"),
"path": f.get("path"),
"size": f.get("size"),
"mtime": f.get("mtime").isoformat() if f.get("mtime") else None,
}
for f in mp4_files
]
# Sort by mtime descending (most recent first)
available_files.sort(key=lambda x: x.get("mtime") or "", reverse=True)
return {
"status": "not_found",
"message": "No matching timelapse found - please select manually",
"available_files": available_files,
}
# Download the timelapse - use the full path from the file listing
remote_path = matching_file.get('path') or f"/timelapse/{matching_file['name']}"
@@ -690,6 +788,67 @@ async def scan_timelapse(
}
@router.post("/{archive_id}/timelapse/select")
async def select_timelapse(
archive_id: int,
filename: str = Query(..., description="Timelapse filename to attach"),
db: AsyncSession = Depends(get_db),
):
"""Manually select a timelapse from the printer to attach."""
from backend.app.models.printer import Printer
from backend.app.services.bambu_ftp import list_files_async, download_file_bytes_async
service = ArchiveService(db)
archive = await service.get_archive(archive_id)
if not archive:
raise HTTPException(404, "Archive not found")
if not archive.printer_id:
raise HTTPException(400, "Archive has no associated printer")
result = await db.execute(
select(Printer).where(Printer.id == archive.printer_id)
)
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
# Find the file on the printer
files = []
remote_path = None
for timelapse_dir in ["/timelapse", "/timelapse/video"]:
try:
files = await list_files_async(printer.ip_address, printer.access_code, timelapse_dir)
for f in files:
if f.get("name") == filename:
remote_path = f.get("path") or f"{timelapse_dir}/{filename}"
break
if remote_path:
break
except Exception:
continue
if not remote_path:
raise HTTPException(404, f"Timelapse '{filename}' not found on printer")
# Download and attach
timelapse_data = await download_file_bytes_async(
printer.ip_address, printer.access_code, remote_path
)
if not timelapse_data:
raise HTTPException(500, "Failed to download timelapse")
success = await service.attach_timelapse(archive_id, timelapse_data, filename)
if not success:
raise HTTPException(500, "Failed to attach timelapse")
return {
"status": "attached",
"message": f"Timelapse '{filename}' attached successfully",
"filename": filename,
}
@router.post("/{archive_id}/timelapse/upload")
async def upload_timelapse(
archive_id: int,
+148
View File
@@ -0,0 +1,148 @@
"""API routes for K-profile (pressure advance) management."""
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from backend.app.core.database import get_db
from backend.app.models.printer import Printer
from backend.app.schemas.kprofile import (
KProfile,
KProfileCreate,
KProfileDelete,
KProfilesResponse,
)
from backend.app.services.printer_manager import printer_manager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/printers/{printer_id}/kprofiles", tags=["kprofiles"])
@router.get("/", response_model=KProfilesResponse)
async def get_kprofiles(
printer_id: int,
nozzle_diameter: str = "0.4",
db: AsyncSession = Depends(get_db),
):
"""Get K-profiles from a printer.
Args:
printer_id: ID of the printer
nozzle_diameter: Filter by nozzle diameter (default: "0.4")
"""
# Check printer exists
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
# Get MQTT client for printer
client = printer_manager.get_client(printer_id)
if not client or not client.state.connected:
raise HTTPException(400, "Printer not connected")
# Request K-profiles from printer
profiles = await client.get_kprofiles(nozzle_diameter=nozzle_diameter)
# Convert from MQTT dataclass to Pydantic schema
return KProfilesResponse(
profiles=[
KProfile(
slot_id=p.slot_id,
extruder_id=p.extruder_id,
nozzle_id=p.nozzle_id,
nozzle_diameter=p.nozzle_diameter,
filament_id=p.filament_id,
name=p.name,
k_value=p.k_value,
n_coef=p.n_coef,
ams_id=p.ams_id,
tray_id=p.tray_id,
setting_id=p.setting_id,
)
for p in profiles
],
nozzle_diameter=nozzle_diameter,
)
@router.post("/", response_model=dict)
async def set_kprofile(
printer_id: int,
profile: KProfileCreate,
db: AsyncSession = Depends(get_db),
):
"""Create or update a K-profile on the printer.
Args:
printer_id: ID of the printer
profile: K-profile data to set
"""
# Check printer exists
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
# Get MQTT client for printer
client = printer_manager.get_client(printer_id)
if not client or not client.state.connected:
raise HTTPException(400, "Printer not connected")
# Send the K-profile to printer
success = client.set_kprofile(
filament_id=profile.filament_id,
name=profile.name,
k_value=profile.k_value,
nozzle_diameter=profile.nozzle_diameter,
nozzle_id=profile.nozzle_id,
extruder_id=profile.extruder_id,
setting_id=profile.setting_id,
slot_id=profile.slot_id,
)
if not success:
raise HTTPException(500, "Failed to send K-profile command")
return {"success": True, "message": "K-profile set successfully"}
@router.delete("/", response_model=dict)
async def delete_kprofile(
printer_id: int,
profile: KProfileDelete,
db: AsyncSession = Depends(get_db),
):
"""Delete a K-profile from the printer.
Args:
printer_id: ID of the printer
profile: K-profile identification data for deletion
"""
# Check printer exists
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
# Get MQTT client for printer
client = printer_manager.get_client(printer_id)
if not client or not client.state.connected:
raise HTTPException(400, "Printer not connected")
# Send the delete command to printer
success = client.delete_kprofile(
cali_idx=profile.slot_id,
filament_id=profile.filament_id,
nozzle_id=profile.nozzle_id,
nozzle_diameter=profile.nozzle_diameter,
extruder_id=profile.extruder_id,
)
if not success:
raise HTTPException(500, "Failed to send K-profile delete command")
return {"success": True, "message": "K-profile deleted successfully"}
+223
View File
@@ -0,0 +1,223 @@
"""API routes for notification providers."""
import json
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.database import get_db
from backend.app.models.notification import NotificationProvider
from backend.app.schemas.notification import (
NotificationProviderCreate,
NotificationProviderResponse,
NotificationProviderUpdate,
NotificationTestRequest,
NotificationTestResponse,
)
from backend.app.services.notification_service import notification_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/notifications", tags=["notifications"])
def _provider_to_dict(provider: NotificationProvider) -> dict:
"""Convert a NotificationProvider model to a response dictionary."""
return {
"id": provider.id,
"name": provider.name,
"provider_type": provider.provider_type,
"enabled": provider.enabled,
"config": json.loads(provider.config) if isinstance(provider.config, str) else provider.config,
# Print lifecycle events
"on_print_start": provider.on_print_start,
"on_print_complete": provider.on_print_complete,
"on_print_failed": provider.on_print_failed,
"on_print_progress": provider.on_print_progress,
# Printer status events
"on_printer_offline": provider.on_printer_offline,
"on_printer_error": provider.on_printer_error,
"on_filament_low": provider.on_filament_low,
# Quiet hours
"quiet_hours_enabled": provider.quiet_hours_enabled,
"quiet_hours_start": provider.quiet_hours_start,
"quiet_hours_end": provider.quiet_hours_end,
# Printer filter
"printer_id": provider.printer_id,
# Status tracking
"last_success": provider.last_success,
"last_error": provider.last_error,
"last_error_at": provider.last_error_at,
# Timestamps
"created_at": provider.created_at,
"updated_at": provider.updated_at,
}
@router.get("/", response_model=list[NotificationProviderResponse])
async def list_notification_providers(db: AsyncSession = Depends(get_db)):
"""List all notification providers."""
result = await db.execute(
select(NotificationProvider).order_by(NotificationProvider.created_at.desc())
)
providers = result.scalars().all()
return [_provider_to_dict(provider) for provider in providers]
@router.post("/", response_model=NotificationProviderResponse)
async def create_notification_provider(
provider_data: NotificationProviderCreate,
db: AsyncSession = Depends(get_db),
):
"""Create a new notification provider."""
provider = NotificationProvider(
name=provider_data.name,
provider_type=provider_data.provider_type.value,
enabled=provider_data.enabled,
config=json.dumps(provider_data.config),
# Print lifecycle events
on_print_start=provider_data.on_print_start,
on_print_complete=provider_data.on_print_complete,
on_print_failed=provider_data.on_print_failed,
on_print_progress=provider_data.on_print_progress,
# Printer status events
on_printer_offline=provider_data.on_printer_offline,
on_printer_error=provider_data.on_printer_error,
on_filament_low=provider_data.on_filament_low,
# Quiet hours
quiet_hours_enabled=provider_data.quiet_hours_enabled,
quiet_hours_start=provider_data.quiet_hours_start,
quiet_hours_end=provider_data.quiet_hours_end,
# Printer filter
printer_id=provider_data.printer_id,
)
db.add(provider)
await db.commit()
await db.refresh(provider)
logger.info(f"Created notification provider: {provider.name} ({provider.provider_type})")
return _provider_to_dict(provider)
@router.get("/{provider_id}", response_model=NotificationProviderResponse)
async def get_notification_provider(
provider_id: int,
db: AsyncSession = Depends(get_db),
):
"""Get a specific notification provider."""
result = await db.execute(
select(NotificationProvider).where(NotificationProvider.id == provider_id)
)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="Notification provider not found")
return _provider_to_dict(provider)
@router.patch("/{provider_id}", response_model=NotificationProviderResponse)
async def update_notification_provider(
provider_id: int,
update_data: NotificationProviderUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update a notification provider."""
result = await db.execute(
select(NotificationProvider).where(NotificationProvider.id == provider_id)
)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="Notification provider not found")
# Update only provided fields
update_dict = update_data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
if key == "config" and value is not None:
setattr(provider, key, json.dumps(value))
elif key == "provider_type" and value is not None:
setattr(provider, key, value.value)
else:
setattr(provider, key, value)
await db.commit()
await db.refresh(provider)
logger.info(f"Updated notification provider: {provider.name}")
return _provider_to_dict(provider)
@router.delete("/{provider_id}")
async def delete_notification_provider(
provider_id: int,
db: AsyncSession = Depends(get_db),
):
"""Delete a notification provider."""
result = await db.execute(
select(NotificationProvider).where(NotificationProvider.id == provider_id)
)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="Notification provider not found")
name = provider.name
await db.delete(provider)
await db.commit()
logger.info(f"Deleted notification provider: {name}")
return {"message": f"Notification provider '{name}' deleted"}
@router.post("/{provider_id}/test", response_model=NotificationTestResponse)
async def test_notification_provider(
provider_id: int,
db: AsyncSession = Depends(get_db),
):
"""Send a test notification using an existing provider."""
result = await db.execute(
select(NotificationProvider).where(NotificationProvider.id == provider_id)
)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="Notification provider not found")
config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
success, message = await notification_service.send_test_notification(
provider.provider_type, config
)
# Update provider status
if success:
from datetime import datetime
provider.last_success = datetime.utcnow()
else:
from datetime import datetime
provider.last_error = message
provider.last_error_at = datetime.utcnow()
await db.commit()
return NotificationTestResponse(success=success, message=message)
@router.post("/test-config", response_model=NotificationTestResponse)
async def test_notification_config(
test_request: NotificationTestRequest,
):
"""Test notification configuration before saving."""
success, message = await notification_service.send_test_notification(
test_request.provider_type.value, test_request.config
)
return NotificationTestResponse(success=success, message=message)
+7 -1
View File
@@ -20,6 +20,7 @@ from backend.app.schemas.smart_plug import (
SmartPlugEnergy,
)
from backend.app.services.tasmota import tasmota_service
from backend.app.services.printer_manager import printer_manager
logger = logging.getLogger(__name__)
@@ -173,9 +174,14 @@ async def control_smart_plug(
if not success:
raise HTTPException(503, "Failed to communicate with device")
# Update last state
# Update last state and reset auto_off_executed when turning on
if expected_state:
plug.last_state = expected_state
if expected_state == "ON":
plug.auto_off_executed = False # Reset flag when manually turning on
elif expected_state == "OFF" and plug.printer_id:
# Mark printer offline immediately for faster UI update
printer_manager.mark_printer_offline(plug.printer_id)
plug.last_checked = datetime.utcnow()
await db.commit()
+8 -1
View File
@@ -4,14 +4,19 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "BambuTrack"
debug: bool = True
debug: bool = False # Default to production mode
# Paths
base_dir: Path = Path(__file__).resolve().parent.parent.parent.parent
archive_dir: Path = base_dir / "archive"
static_dir: Path = base_dir / "static"
log_dir: Path = base_dir / "logs"
database_url: str = f"sqlite+aiosqlite:///{base_dir}/bambutrack.db"
# Logging
log_level: str = "INFO" # Override with LOG_LEVEL env var or DEBUG=true
log_to_file: bool = True # Set to false to disable file logging
# API
api_prefix: str = "/api/v1"
@@ -25,3 +30,5 @@ settings = Settings()
# Ensure directories exist
settings.archive_dir.mkdir(exist_ok=True)
settings.static_dir.mkdir(exist_ok=True)
if settings.log_to_file:
settings.log_dir.mkdir(exist_ok=True)
+19 -1
View File
@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
async def init_db():
# Import models to register them with SQLAlchemy
from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue # noqa: F401
from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@@ -64,3 +64,21 @@ async def run_migrations(conn):
except Exception:
# Column already exists
pass
# Migration: Add auto_off_executed column to smart_plugs
try:
await conn.execute(text(
"ALTER TABLE smart_plugs ADD COLUMN auto_off_executed BOOLEAN DEFAULT 0"
))
except Exception:
# Column already exists
pass
# Migration: Add on_print_stopped column to notification_providers
try:
await conn.execute(text(
"ALTER TABLE notification_providers ADD COLUMN on_print_stopped BOOLEAN DEFAULT 1"
))
except Exception:
# Column already exists
pass
+237 -78
View File
@@ -1,5 +1,6 @@
import asyncio
import logging
import os
from datetime import datetime
from contextlib import asynccontextmanager
from pathlib import Path
@@ -7,41 +8,55 @@ from logging.handlers import RotatingFileHandler
from fastapi import FastAPI
# Configure logging for all modules - console + file
# Import settings first for logging configuration
from backend.app.core.config import settings as app_settings
# Configure logging based on settings
# DEBUG=true -> DEBUG level, else use LOG_LEVEL setting
log_level_str = "DEBUG" if app_settings.debug else app_settings.log_level.upper()
log_level = getattr(logging, log_level_str, logging.INFO)
log_format = '%(asctime)s %(levelname)s [%(name)s] %(message)s'
log_level = logging.INFO
# Create root logger
root_logger = logging.getLogger()
root_logger.setLevel(log_level)
# Console handler
# Console handler - always enabled
console_handler = logging.StreamHandler()
console_handler.setLevel(log_level)
console_handler.setFormatter(logging.Formatter(log_format))
root_logger.addHandler(console_handler)
# File handler - rotating log file (5MB max, keep 3 backups)
log_file = Path(__file__).parent.parent.parent / "bambutrack.log"
file_handler = RotatingFileHandler(
log_file,
maxBytes=5*1024*1024, # 5MB
backupCount=3,
encoding='utf-8'
)
file_handler.setLevel(log_level)
file_handler.setFormatter(logging.Formatter(log_format))
root_logger.addHandler(file_handler)
# File handler - only in production or if explicitly enabled
if app_settings.log_to_file:
log_file = app_settings.log_dir / "bambutrack.log"
file_handler = RotatingFileHandler(
log_file,
maxBytes=5*1024*1024, # 5MB
backupCount=3,
encoding='utf-8'
)
file_handler.setLevel(log_level)
file_handler.setFormatter(logging.Formatter(log_format))
root_logger.addHandler(file_handler)
logging.info(f"Logging to file: {log_file}")
logging.info(f"Logging to file: {log_file}")
# Reduce noise from third-party libraries in production
if not app_settings.debug:
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.info(f"BambuTrack starting - debug={app_settings.debug}, log_level={log_level_str}")
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from backend.app.core.config import settings as app_settings
from backend.app.core.database import init_db, async_session
from sqlalchemy import select, or_
from backend.app.core.websocket import ws_manager
from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue
from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications
from backend.app.api.routes import settings as settings_routes
from backend.app.services.notification_service import notification_service
from backend.app.services.printer_manager import (
printer_manager,
printer_state_to_dict,
@@ -82,11 +97,39 @@ def register_expected_print(printer_id: int, filename: str, archive_id: int):
_last_status_broadcast: dict[int, str] = {}
_nozzle_count_updated: set[int] = set() # Track printers where we've updated nozzle_count
async def on_printer_status_change(printer_id: int, state: PrinterState):
"""Handle printer status changes - broadcast via WebSocket."""
# Only broadcast if something meaningful changed (reduce WebSocket spam)
status_key = f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}"
# Include rounded temperatures to detect meaningful temp changes (within 1 degree)
temps = state.temperatures or {}
nozzle_temp = round(temps.get("nozzle", 0))
bed_temp = round(temps.get("bed", 0))
nozzle_2_temp = round(temps.get("nozzle_2", 0)) if "nozzle_2" in temps else ""
chamber_temp = round(temps.get("chamber", 0)) if "chamber" in temps else ""
# Auto-detect dual-nozzle printers from MQTT temperature data
if "nozzle_2" in temps and printer_id not in _nozzle_count_updated:
_nozzle_count_updated.add(printer_id)
# Update nozzle_count in database
async with async_session() as db:
from backend.app.models.printer import Printer
result = await db.execute(
select(Printer).where(Printer.id == printer_id)
)
printer = result.scalar_one_or_none()
if printer and printer.nozzle_count != 2:
printer.nozzle_count = 2
await db.commit()
logging.getLogger(__name__).info(
f"Auto-detected dual-nozzle printer {printer_id}, updated nozzle_count=2"
)
status_key = (
f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}"
)
if _last_status_broadcast.get(printer_id) == status_key:
return # No change, skip broadcast
@@ -108,7 +151,6 @@ async def on_print_start(printer_id: int, data: dict):
async with async_session() as db:
from backend.app.models.printer import Printer
from backend.app.services.bambu_ftp import list_files_async
from sqlalchemy import select
result = await db.execute(
select(Printer).where(Printer.id == printer_id)
@@ -179,11 +221,17 @@ async def on_print_start(printer_id: int, data: dict):
select(SmartPlug).where(SmartPlug.printer_id == printer_id)
)
plug = plug_result.scalar_one_or_none()
logger.info(f"[ENERGY] Print start - archive {archive.id}, printer {printer_id}, plug found: {plug is not None}")
if plug:
energy = await tasmota_service.get_energy(plug)
logger.info(f"[ENERGY] Energy response from plug: {energy}")
if energy and energy.get("total") is not None:
_print_energy_start[archive.id] = energy["total"]
logger.info(f"Recorded starting energy for archive {archive.id}: {energy['total']} kWh")
logger.info(f"[ENERGY] Recorded starting energy for archive {archive.id}: {energy['total']} kWh")
else:
logger.warning(f"[ENERGY] No 'total' in energy response for archive {archive.id}")
else:
logger.info(f"[ENERGY] No smart plug found for printer {printer_id}")
except Exception as e:
logger.warning(f"Failed to record starting energy: {e}")
@@ -351,11 +399,17 @@ async def on_print_start(printer_id: int, data: dict):
select(SmartPlug).where(SmartPlug.printer_id == printer_id)
)
plug = plug_result.scalar_one_or_none()
logger.info(f"[ENERGY] Auto-archive print start - archive {archive.id}, printer {printer_id}, plug found: {plug is not None}")
if plug:
energy = await tasmota_service.get_energy(plug)
logger.info(f"[ENERGY] Auto-archive energy response: {energy}")
if energy and energy.get("total") is not None:
_print_energy_start[archive.id] = energy["total"]
logger.info(f"Recorded starting energy for archive {archive.id}: {energy['total']} kWh")
logger.info(f"[ENERGY] Recorded starting energy for archive {archive.id}: {energy['total']} kWh")
else:
logger.warning(f"[ENERGY] No 'total' in energy response for archive {archive.id}")
else:
logger.info(f"[ENERGY] No smart plug found for printer {printer_id}")
except Exception as e:
logger.warning(f"Failed to record starting energy: {e}")
@@ -378,6 +432,20 @@ async def on_print_start(printer_id: int, data: dict):
import logging
logging.getLogger(__name__).warning(f"Smart plug on_print_start failed: {e}")
# Send print start notifications
try:
async with async_session() as db:
from backend.app.models.printer import Printer
result = await db.execute(
select(Printer).where(Printer.id == printer_id)
)
printer = result.scalar_one_or_none()
printer_name = printer.name if printer else f"Printer {printer_id}"
await notification_service.on_print_start(printer_id, printer_name, data, db)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Notification on_print_start failed: {e}")
async def on_print_complete(printer_id: int, data: dict):
"""Handle print completion - update the archive status."""
@@ -387,29 +455,59 @@ async def on_print_complete(printer_id: int, data: dict):
await ws_manager.send_print_complete(printer_id, data)
filename = data.get("filename", "")
if not filename:
subtask_name = data.get("subtask_name", "")
if not filename and not subtask_name:
logger.warning(f"Print complete without filename or subtask_name")
return
logger.info(f"Print complete - filename: {filename}, status: {data.get('status')}")
logger.info(f"Print complete - filename: {filename}, subtask: {subtask_name}, status: {data.get('status')}")
# Build list of possible keys to try
# Build list of possible keys to try (matching how they were registered in on_print_start)
possible_keys = []
if filename.endswith(".3mf"):
possible_keys.append((printer_id, filename))
elif filename.endswith(".gcode"):
base_name = filename.rsplit(".", 1)[0]
possible_keys.append((printer_id, f"{base_name}.3mf"))
possible_keys.append((printer_id, filename))
else:
possible_keys.append((printer_id, f"{filename}.3mf"))
possible_keys.append((printer_id, filename))
# Try subtask_name variations first (most reliable for matching)
if subtask_name:
possible_keys.append((printer_id, f"{subtask_name}.3mf"))
possible_keys.append((printer_id, f"{subtask_name}.gcode.3mf"))
possible_keys.append((printer_id, subtask_name))
# Try filename variations
if filename:
# Extract just the filename if it's a path
fname = filename.split("/")[-1] if "/" in filename else filename
if fname.endswith(".3mf"):
possible_keys.append((printer_id, fname))
elif fname.endswith(".gcode"):
base_name = fname.rsplit(".", 1)[0]
possible_keys.append((printer_id, f"{base_name}.gcode.3mf"))
possible_keys.append((printer_id, f"{base_name}.3mf"))
possible_keys.append((printer_id, fname))
else:
possible_keys.append((printer_id, f"{fname}.gcode.3mf"))
possible_keys.append((printer_id, f"{fname}.3mf"))
possible_keys.append((printer_id, fname))
# Also try full path versions
if filename.endswith(".3mf"):
possible_keys.append((printer_id, filename))
elif filename.endswith(".gcode"):
base_name = filename.rsplit(".", 1)[0]
possible_keys.append((printer_id, f"{base_name}.3mf"))
possible_keys.append((printer_id, filename))
else:
possible_keys.append((printer_id, f"{filename}.3mf"))
possible_keys.append((printer_id, filename))
# Find the archive for this print
logger.info(f"Looking for archive in _active_prints, keys to try: {possible_keys[:5]}...")
logger.info(f"Current _active_prints: {list(_active_prints.keys())}")
archive_id = None
for key in possible_keys:
archive_id = _active_prints.pop(key, None)
if archive_id:
logger.info(f"Found archive {archive_id} with key {key}")
# Also clean up any other keys pointing to this archive
keys_to_remove = [k for k, v in _active_prints.items() if v == archive_id]
for k in keys_to_remove:
@@ -417,24 +515,44 @@ async def on_print_complete(printer_id: int, data: dict):
break
if not archive_id:
# Try to find by filename if not tracked (for prints started before app)
# Try to find by filename or subtask_name if not tracked (for prints started before app)
async with async_session() as db:
from backend.app.models.archive import PrintArchive
from sqlalchemy import select
result = await db.execute(
select(PrintArchive)
.where(PrintArchive.printer_id == printer_id)
.where(PrintArchive.filename == filename)
.where(PrintArchive.status == "printing")
.order_by(PrintArchive.created_at.desc())
.limit(1)
)
archive = result.scalar_one_or_none()
if archive:
archive_id = archive.id
# Try matching by subtask_name (stored as print_name) first
if subtask_name:
result = await db.execute(
select(PrintArchive)
.where(PrintArchive.printer_id == printer_id)
.where(PrintArchive.status == "printing")
.where(or_(
PrintArchive.print_name.ilike(f"%{subtask_name}%"),
PrintArchive.filename.ilike(f"%{subtask_name}%"),
))
.order_by(PrintArchive.created_at.desc())
.limit(1)
)
archive = result.scalar_one_or_none()
if archive:
archive_id = archive.id
logger.info(f"Found archive {archive_id} by subtask_name match: {subtask_name}")
# Also try by filename
if not archive_id and filename:
result = await db.execute(
select(PrintArchive)
.where(PrintArchive.printer_id == printer_id)
.where(PrintArchive.filename == filename)
.where(PrintArchive.status == "printing")
.order_by(PrintArchive.created_at.desc())
.limit(1)
)
archive = result.scalar_one_or_none()
if archive:
archive_id = archive.id
if not archive_id:
logger.warning(f"Could not find archive for print complete: filename={filename}, subtask={subtask_name}")
return
# Update archive status
@@ -452,54 +570,71 @@ async def on_print_complete(printer_id: int, data: dict):
"status": status,
})
# Calculate energy used for this print
# Calculate energy used for this print (always per-print: end - start)
try:
starting_kwh = _print_energy_start.pop(archive_id, None)
if starting_kwh is not None:
async with async_session() as db:
# Get smart plug for this printer
plug_result = await db.execute(
select(SmartPlug).where(SmartPlug.printer_id == printer_id)
)
plug = plug_result.scalar_one_or_none()
logger.info(f"[ENERGY] Print complete for archive {archive_id}, starting_kwh={starting_kwh}")
if plug:
energy = await tasmota_service.get_energy(plug)
if energy and energy.get("total") is not None:
ending_kwh = energy["total"]
energy_used = round(ending_kwh - starting_kwh, 4)
async with async_session() as db:
# Get smart plug for this printer (SmartPlug is imported at module level)
plug_result = await db.execute(
select(SmartPlug).where(SmartPlug.printer_id == printer_id)
)
plug = plug_result.scalar_one_or_none()
# Get energy cost per kWh from settings (default to 0.15)
from backend.app.api.routes.settings import get_setting
energy_cost_per_kwh = await get_setting(db, "energy_cost_per_kwh")
cost_per_kwh = float(energy_cost_per_kwh) if energy_cost_per_kwh else 0.15
energy_cost = round(energy_used * cost_per_kwh, 2)
if plug:
energy = await tasmota_service.get_energy(plug)
logger.info(f"[ENERGY] Print complete - energy response: {energy}")
# Update archive with energy data
from backend.app.models.archive import PrintArchive
result = await db.execute(
select(PrintArchive).where(PrintArchive.id == archive_id)
)
archive = result.scalar_one_or_none()
if archive:
archive.energy_kwh = energy_used
archive.energy_cost = energy_cost
await db.commit()
logger.info(f"Recorded energy for archive {archive_id}: {energy_used} kWh (${energy_cost})")
energy_used = None
# Calculate per-print energy: end total - start total
if starting_kwh is not None and energy and energy.get("total") is not None:
ending_kwh = energy["total"]
energy_used = round(ending_kwh - starting_kwh, 4)
logger.info(f"[ENERGY] Per-print energy: ending={ending_kwh}, starting={starting_kwh}, used={energy_used}")
elif starting_kwh is None:
logger.info(f"[ENERGY] No starting energy recorded for this archive")
else:
logger.warning(f"[ENERGY] No 'total' in ending energy response")
if energy_used is not None and energy_used >= 0:
# Get energy cost per kWh from settings (default to 0.15)
from backend.app.api.routes.settings import get_setting
energy_cost_per_kwh = await get_setting(db, "energy_cost_per_kwh")
cost_per_kwh = float(energy_cost_per_kwh) if energy_cost_per_kwh else 0.15
energy_cost = round(energy_used * cost_per_kwh, 2)
# Update archive with energy data
from backend.app.models.archive import PrintArchive
result = await db.execute(
select(PrintArchive).where(PrintArchive.id == archive_id)
)
archive = result.scalar_one_or_none()
if archive:
archive.energy_kwh = energy_used
archive.energy_cost = energy_cost
await db.commit()
logger.info(f"[ENERGY] Saved to archive {archive_id}: {energy_used} kWh, cost={energy_cost}")
else:
logger.warning(f"[ENERGY] Archive {archive_id} not found when saving energy")
else:
logger.info(f"[ENERGY] No smart plug found for printer {printer_id} at print complete")
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Failed to calculate energy: {e}")
# Capture finish photo from printer camera
logger.info(f"[PHOTO] Starting finish photo capture for archive {archive_id}")
try:
async with async_session() as db:
# Check if finish photo capture is enabled
from backend.app.api.routes.settings import get_setting
capture_enabled = await get_setting(db, "capture_finish_photo")
logger.info(f"[PHOTO] capture_finish_photo setting: {capture_enabled}")
if capture_enabled is None or capture_enabled.lower() == "true":
# Get printer details
from backend.app.models.printer import Printer
from sqlalchemy import select
result = await db.execute(
select(Printer).where(Printer.id == printer_id)
)
@@ -538,20 +673,42 @@ async def on_print_complete(printer_id: int, data: dict):
logging.getLogger(__name__).warning(f"Finish photo capture failed: {e}")
# Smart plug automation: schedule turn off when print completes
logger.info(f"[AUTO-OFF] Calling smart_plug_manager.on_print_complete for printer {printer_id}")
try:
async with async_session() as db:
status = data.get("status", "completed")
await smart_plug_manager.on_print_complete(printer_id, status, db)
logger.info(f"[AUTO-OFF] smart_plug_manager.on_print_complete completed")
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Smart plug on_print_complete failed: {e}")
# Send print complete notifications
try:
async with async_session() as db:
from backend.app.models.printer import Printer
result = await db.execute(
select(Printer).where(Printer.id == printer_id)
)
printer = result.scalar_one_or_none()
printer_name = printer.name if printer else f"Printer {printer_id}"
status = data.get("status", "completed")
# on_print_complete handles all status types: completed, failed, aborted, stopped
await notification_service.on_print_complete(
printer_id, printer_name, status, data, db
)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Notification on_print_complete failed: {e}")
# Update queue item if this was a scheduled print
try:
async with async_session() as db:
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.smart_plug import SmartPlug
from backend.app.services.tasmota import tasmota_service
# Note: SmartPlug is already imported at module level (line 56)
# Do NOT import it here as it would shadow the module-level import
# and cause "cannot access local variable" errors earlier in this function
result = await db.execute(
select(PrintQueueItem)
@@ -638,6 +795,8 @@ app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
app.include_router(cloud.router, prefix=app_settings.api_prefix)
app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
app.include_router(print_queue.router, prefix=app_settings.api_prefix)
app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
app.include_router(notifications.router, prefix=app_settings.api_prefix)
app.include_router(websocket.router, prefix=app_settings.api_prefix)
+54
View File
@@ -0,0 +1,54 @@
"""Notification provider model for push notifications."""
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, Time
from sqlalchemy.orm import relationship
from backend.app.core.database import Base
class NotificationProvider(Base):
"""Model for notification providers (WhatsApp, ntfy, Pushover, etc.)."""
__tablename__ = "notification_providers"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False) # User-defined name
provider_type = Column(String(50), nullable=False) # callmebot, ntfy, pushover, telegram, email
enabled = Column(Boolean, default=True)
# Provider-specific configuration stored as JSON string
config = Column(Text, nullable=False)
# Event triggers - print lifecycle
on_print_start = Column(Boolean, default=False)
on_print_complete = Column(Boolean, default=True)
on_print_failed = Column(Boolean, default=True)
on_print_stopped = Column(Boolean, default=True) # User cancelled/stopped print
on_print_progress = Column(Boolean, default=False) # 25%, 50%, 75% milestones
# Event triggers - printer status
on_printer_offline = Column(Boolean, default=False)
on_printer_error = Column(Boolean, default=False) # AMS issues, etc.
on_filament_low = Column(Boolean, default=False)
# Quiet hours (do not disturb)
quiet_hours_enabled = Column(Boolean, default=False)
quiet_hours_start = Column(String(5), nullable=True) # HH:MM format, e.g., "22:00"
quiet_hours_end = Column(String(5), nullable=True) # HH:MM format, e.g., "07:00"
# Optional: Link to specific printer (NULL = all printers)
printer_id = Column(Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
# Status tracking
last_success = Column(DateTime, nullable=True)
last_error = Column(Text, nullable=True)
last_error_at = Column(DateTime, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
printer = relationship("Printer", back_populates="notification_providers")
+5
View File
@@ -14,6 +14,7 @@ class Printer(Base):
ip_address: Mapped[str] = mapped_column(String(45))
access_code: Mapped[str] = mapped_column(String(20))
model: Mapped[str | None] = mapped_column(String(50))
nozzle_count: Mapped[int] = mapped_column(default=1) # 1 or 2, auto-detected from MQTT
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
auto_archive: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
@@ -30,7 +31,11 @@ class Printer(Base):
smart_plug: Mapped["SmartPlug | None"] = relationship(
back_populates="printer", uselist=False
)
notification_providers: Mapped[list["NotificationProvider"]] = relationship(
back_populates="printer"
)
from backend.app.models.archive import PrintArchive # noqa: E402
from backend.app.models.smart_plug import SmartPlug # noqa: E402
from backend.app.models.notification import NotificationProvider # noqa: E402
+1
View File
@@ -36,6 +36,7 @@ class SmartPlug(Base):
# Status tracking
last_state: Mapped[str | None] = mapped_column(String(10), nullable=True) # "ON"/"OFF"
last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
auto_off_executed: Mapped[bool] = mapped_column(Boolean, default=False) # True when auto-off was triggered
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+52
View File
@@ -0,0 +1,52 @@
"""Pydantic schemas for K-profile (pressure advance) management."""
from pydantic import BaseModel
class KProfile(BaseModel):
"""A pressure advance (K) calibration profile stored on the printer."""
slot_id: int # Storage slot on printer (limited capacity ~20 slots)
extruder_id: int = 0 # 0 or 1 for dual nozzle printers
nozzle_id: str # e.g., "HS00-0.4" (hardened steel 0.4mm)
nozzle_diameter: str # e.g., "0.4"
filament_id: str # Bambu filament identifier
name: str # User-defined name for the profile
k_value: str # Pressure advance coefficient as string, e.g., "0.020000"
n_coef: str = "0.000000" # N coefficient (usually 0)
ams_id: int = 0 # AMS unit ID
tray_id: int = -1 # AMS tray ID (-1 if not linked)
setting_id: str | None = None # Unique setting identifier
class KProfileCreate(BaseModel):
"""Schema for creating/updating a K-profile."""
slot_id: int = 0 # Storage slot, 0 for new profiles
extruder_id: int = 0
nozzle_id: str
nozzle_diameter: str
filament_id: str
name: str
k_value: str
n_coef: str = "0.000000"
ams_id: int = 0
tray_id: int = -1
setting_id: str | None = None
class KProfilesResponse(BaseModel):
"""Response containing K-profiles from a printer."""
profiles: list[KProfile]
nozzle_diameter: str # Current nozzle filter
class KProfileDelete(BaseModel):
"""Schema for deleting a K-profile."""
slot_id: int # cali_idx - calibration index to delete
extruder_id: int = 0
nozzle_id: str # e.g., "HH00-0.4"
nozzle_diameter: str # e.g., "0.4"
filament_id: str # Bambu filament identifier
+168
View File
@@ -0,0 +1,168 @@
"""Pydantic schemas for notification providers."""
from datetime import datetime
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field, field_validator
class ProviderType(str, Enum):
"""Supported notification provider types."""
CALLMEBOT = "callmebot"
NTFY = "ntfy"
PUSHOVER = "pushover"
TELEGRAM = "telegram"
EMAIL = "email"
class NotificationProviderBase(BaseModel):
"""Base schema for notification providers."""
name: str = Field(..., min_length=1, max_length=100, description="User-defined name")
provider_type: ProviderType = Field(..., description="Type of notification provider")
enabled: bool = Field(default=True, description="Whether notifications are enabled")
config: dict[str, Any] = Field(..., description="Provider-specific configuration")
# Event triggers - print lifecycle
on_print_start: bool = Field(default=False, description="Notify on print start")
on_print_complete: bool = Field(default=True, description="Notify on print complete")
on_print_failed: bool = Field(default=True, description="Notify on print failed")
on_print_stopped: bool = Field(default=True, description="Notify when print is stopped/cancelled")
on_print_progress: bool = Field(default=False, description="Notify at 25%, 50%, 75% progress")
# Event triggers - printer status
on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
on_printer_error: bool = Field(default=False, description="Notify on printer errors (AMS, etc.)")
on_filament_low: bool = Field(default=False, description="Notify when filament is running low")
# Quiet hours
quiet_hours_enabled: bool = Field(default=False, description="Enable quiet hours")
quiet_hours_start: str | None = Field(default=None, description="Start time in HH:MM format")
quiet_hours_end: str | None = Field(default=None, description="End time in HH:MM format")
# Printer filter
printer_id: int | None = Field(default=None, description="Specific printer ID or null for all")
@field_validator("quiet_hours_start", "quiet_hours_end")
@classmethod
def validate_time_format(cls, v: str | None) -> str | None:
if v is None:
return v
try:
parts = v.split(":")
if len(parts) != 2:
raise ValueError("Invalid time format")
hour, minute = int(parts[0]), int(parts[1])
if not (0 <= hour <= 23 and 0 <= minute <= 59):
raise ValueError("Invalid time range")
return f"{hour:02d}:{minute:02d}"
except (ValueError, TypeError):
raise ValueError("Time must be in HH:MM format (e.g., 22:00)")
class NotificationProviderCreate(NotificationProviderBase):
"""Schema for creating a notification provider."""
pass
class NotificationProviderUpdate(BaseModel):
"""Schema for updating a notification provider (all fields optional)."""
name: str | None = Field(default=None, min_length=1, max_length=100)
provider_type: ProviderType | None = None
enabled: bool | None = None
config: dict[str, Any] | None = None
# Event triggers - print lifecycle
on_print_start: bool | None = None
on_print_complete: bool | None = None
on_print_failed: bool | None = None
on_print_stopped: bool | None = None
on_print_progress: bool | None = None
# Event triggers - printer status
on_printer_offline: bool | None = None
on_printer_error: bool | None = None
on_filament_low: bool | None = None
# Quiet hours
quiet_hours_enabled: bool | None = None
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
# Printer filter
printer_id: int | None = None
class NotificationProviderResponse(NotificationProviderBase):
"""Schema for notification provider API responses."""
id: int
last_success: datetime | None = None
last_error: str | None = None
last_error_at: datetime | None = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class NotificationTestRequest(BaseModel):
"""Schema for testing notification configuration."""
provider_type: ProviderType
config: dict[str, Any]
class NotificationTestResponse(BaseModel):
"""Schema for test notification response."""
success: bool
message: str
# Provider-specific config schemas for documentation/validation reference
class CallMeBotConfig(BaseModel):
"""CallMeBot/WhatsApp configuration."""
phone: str = Field(..., description="Phone number with country code (e.g., +1234567890)")
apikey: str = Field(..., description="API key from CallMeBot")
class NtfyConfig(BaseModel):
"""ntfy configuration."""
server: str = Field(default="https://ntfy.sh", description="ntfy server URL")
topic: str = Field(..., description="Topic name to publish to")
auth_token: str | None = Field(default=None, description="Optional authentication token")
class PushoverConfig(BaseModel):
"""Pushover configuration."""
user_key: str = Field(..., description="Your Pushover user key")
app_token: str = Field(..., description="Your Pushover application token")
priority: int = Field(default=0, ge=-2, le=2, description="Message priority (-2 to 2)")
class TelegramConfig(BaseModel):
"""Telegram bot configuration."""
bot_token: str = Field(..., description="Bot token from @BotFather")
chat_id: str = Field(..., description="Chat ID to send messages to")
class EmailConfig(BaseModel):
"""Email/SMTP configuration."""
smtp_server: str = Field(..., description="SMTP server hostname")
smtp_port: int = Field(default=587, description="SMTP port (587 for TLS, 465 for SSL)")
username: str = Field(..., description="SMTP username/email")
password: str = Field(..., description="SMTP password or app password")
from_email: str = Field(..., description="From email address")
to_email: str = Field(..., description="Recipient email address")
use_tls: bool = Field(default=True, description="Use TLS encryption")
+1
View File
@@ -27,6 +27,7 @@ class PrinterUpdate(BaseModel):
class PrinterResponse(PrinterBase):
id: int
is_active: bool
nozzle_count: int = 1 # 1 or 2, auto-detected from MQTT
created_at: datetime
updated_at: datetime
+2
View File
@@ -10,6 +10,7 @@ class AppSettings(BaseModel):
default_filament_cost: float = Field(default=25.0, description="Default filament cost per kg")
currency: str = Field(default="USD", description="Currency for cost tracking")
energy_cost_per_kwh: float = Field(default=0.15, description="Electricity cost per kWh for energy tracking")
energy_tracking_mode: str = Field(default="total", description="Energy display mode on stats: 'print' shows sum of per-print energy, 'total' shows lifetime plug consumption")
class AppSettingsUpdate(BaseModel):
@@ -21,3 +22,4 @@ class AppSettingsUpdate(BaseModel):
default_filament_cost: float | None = None
currency: str | None = None
energy_cost_per_kwh: float | None = None
energy_tracking_mode: str | None = None
+1
View File
@@ -39,6 +39,7 @@ class SmartPlugResponse(SmartPlugBase):
id: int
last_state: str | None = None
last_checked: datetime | None = None
auto_off_executed: bool = False # True when auto-off was triggered after print
created_at: datetime
updated_at: datetime
+22 -12
View File
@@ -32,12 +32,12 @@ class ThreeMFParser:
self._parse_3dmodel(zf)
self._extract_thumbnail(zf)
# Prefer slice_info for colors (shows ALL filaments actually used in print)
# project_settings may filter out "support" filaments incorrectly
# ALWAYS prefer slice_info values - they contain ONLY filaments actually used in print
# project_settings contains ALL configured filaments (AMS slots), not just used ones
if self.metadata.get("_slice_filament_type"):
self.metadata["filament_type"] = self.metadata["_slice_filament_type"]
if self.metadata.get("_slice_filament_color"):
self.metadata["filament_color"] = self.metadata["_slice_filament_color"]
if not self.metadata.get("filament_type") and self.metadata.get("_slice_filament_type"):
self.metadata["filament_type"] = self.metadata["_slice_filament_type"]
# Clean up internal keys
self.metadata.pop("_slice_filament_type", None)
@@ -65,20 +65,30 @@ class ThreeMFParser:
elif key == "weight" and value:
self.metadata["filament_used_grams"] = float(value)
# Get filament info from ALL filaments actually used in the print
# Get filament info from filaments ACTUALLY USED in the print
# slice_info has <filament id="1" type="PLA" color="#FFFFFF" used_g="100" />
# Only include filaments where used_g > 0
filaments = root.findall(".//filament")
if filaments:
# Collect all unique filament types and colors
# Collect unique filament types and colors for filaments that are actually used
types = []
colors = []
for f in filaments:
ftype = f.get("type")
fcolor = f.get("color")
if ftype and ftype not in types:
types.append(ftype)
if fcolor and fcolor not in colors:
colors.append(fcolor)
# Check if this filament is actually used in the print
used_g = f.get("used_g", "0")
try:
used_amount = float(used_g)
except (ValueError, TypeError):
used_amount = 0
# Only include if used_g > 0 (filament is actually consumed)
if used_amount > 0:
ftype = f.get("type")
fcolor = f.get("color")
if ftype and ftype not in types:
types.append(ftype)
if fcolor and fcolor not in colors:
colors.append(fcolor)
if types:
self.metadata["_slice_filament_type"] = ", ".join(types)
+32 -2
View File
@@ -106,11 +106,41 @@ class BambuFTPClient:
name = " ".join(parts[8:])
is_dir = item.startswith("d")
size = int(parts[4]) if not is_dir else 0
files.append({
# Parse modification time from FTP listing
# Format: "Nov 30 10:15" or "Nov 30 2024"
mtime = None
try:
from datetime import datetime
month = parts[5]
day = parts[6]
time_or_year = parts[7]
# Determine if it's time (HH:MM) or year
if ":" in time_or_year:
# Recent file: "Nov 30 10:15" - assume current year
year = datetime.now().year
time_str = f"{month} {day} {year} {time_or_year}"
mtime = datetime.strptime(time_str, "%b %d %Y %H:%M")
# If parsed date is in the future, use last year
if mtime > datetime.now():
mtime = mtime.replace(year=year - 1)
else:
# Older file: "Nov 30 2024" - no time, just date
time_str = f"{month} {day} {time_or_year}"
mtime = datetime.strptime(time_str, "%b %d %Y")
except (ValueError, IndexError):
pass
file_entry = {
"name": name,
"is_directory": is_dir,
"size": size,
})
"path": f"{path.rstrip('/')}/{name}",
}
if mtime:
file_entry["mtime"] = mtime
files.append(file_entry)
except Exception:
pass
+262 -3
View File
@@ -31,6 +31,22 @@ class HMSError:
message: str = ""
@dataclass
class KProfile:
"""Pressure advance (K) calibration profile from printer."""
slot_id: int
extruder_id: int
nozzle_id: str
nozzle_diameter: str
filament_id: str
name: str
k_value: str
n_coef: str = "0.000000"
ams_id: int = 0
tray_id: int = -1
setting_id: str | None = None
@dataclass
class PrinterState:
connected: bool = False
@@ -46,6 +62,7 @@ class PrinterState:
gcode_file: str | None = None
subtask_id: str | None = None
hms_errors: list = field(default_factory=list) # List of HMSError
kprofiles: list = field(default_factory=list) # List of KProfile
class BambuMQTTClient:
@@ -74,10 +91,17 @@ class BambuMQTTClient:
self._loop: asyncio.AbstractEventLoop | None = None
self._previous_gcode_state: str | None = None
self._previous_gcode_file: str | None = None
self._was_running: bool = False # Track if we've seen RUNNING state for current print
self._completion_triggered: bool = False # Prevent duplicate completion triggers
self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
self._logging_enabled: bool = False
self._last_message_time: float = 0.0 # Track when we last received a message
# K-profile command tracking
self._sequence_id: int = 0
self._pending_kprofile_response: asyncio.Event | None = None
self._kprofile_response_data: list | None = None
@property
def topic_subscribe(self) -> str:
return f"device/{self.serial_number}/report"
@@ -92,6 +116,9 @@ class BambuMQTTClient:
client.subscribe(self.topic_subscribe)
# Request full status update
self._request_push_all()
# Immediately broadcast connection state change
if self.on_state_change:
self.on_state_change(self.state)
else:
self.state.connected = False
@@ -138,6 +165,11 @@ class BambuMQTTClient:
f"[{self.serial_number}] Received gcode_state: {print_data.get('gcode_state')}, "
f"gcode_file: {print_data.get('gcode_file')}, subtask_name: {print_data.get('subtask_name')}"
)
# Check for K-profile response (extrusion_cali)
if "command" in print_data and print_data.get("command") == "extrusion_cali_get":
self._handle_kprofile_response(print_data)
self._update_state(print_data)
def _update_state(self, data: dict):
@@ -251,9 +283,19 @@ class BambuMQTTClient:
and self._previous_gcode_file is not None
)
# Track RUNNING state for more robust completion detection
if self.state.state == "RUNNING" and current_file:
if not self._was_running:
logger.info(f"[{self.serial_number}] Now tracking RUNNING state for {current_file}")
self._was_running = True
self._completion_triggered = False
if is_new_print or is_file_change:
# Clear any old HMS errors when a new print starts
self.state.hms_errors = []
# Reset completion tracking for new print
self._was_running = True
self._completion_triggered = False
if (is_new_print or is_file_change) and self.on_print_start:
logger.info(
@@ -267,11 +309,27 @@ class BambuMQTTClient:
})
# Detect print completion (FINISH = success, FAILED = error, IDLE = aborted)
# Use _was_running flag in addition to _previous_gcode_state for more robust detection
# This handles cases where server restarts during a print
should_trigger_completion = (
self.state.state in ("FINISH", "FAILED")
and not self._completion_triggered
and self.on_print_complete
and (
self._previous_gcode_state == "RUNNING" # Normal transition
or (self._was_running and self._previous_gcode_state != self.state.state) # After server restart
)
)
# For IDLE, only trigger if we just came from RUNNING (explicit abort/cancel)
if (
self._previous_gcode_state == "RUNNING"
and self.state.state in ("FINISH", "FAILED", "IDLE")
self.state.state == "IDLE"
and self._previous_gcode_state == "RUNNING"
and not self._completion_triggered
and self.on_print_complete
):
should_trigger_completion = True
if should_trigger_completion:
if self.state.state == "FINISH":
status = "completed"
elif self.state.state == "FAILED":
@@ -280,11 +338,15 @@ class BambuMQTTClient:
status = "aborted"
logger.info(
f"[{self.serial_number}] PRINT COMPLETE detected - state: {self.state.state}, "
f"status: {status}, file: {self._previous_gcode_file or current_file}"
f"status: {status}, file: {self._previous_gcode_file or current_file}, "
f"subtask: {self.state.subtask_name}, was_running: {self._was_running}"
)
self._completion_triggered = True
self._was_running = False
self.on_print_complete({
"status": status,
"filename": self._previous_gcode_file or current_file,
"subtask_name": self.state.subtask_name,
"raw_data": data,
})
@@ -405,3 +467,200 @@ class BambuMQTTClient:
def logging_enabled(self) -> bool:
"""Check if logging is enabled."""
return self._logging_enabled
def _handle_kprofile_response(self, data: dict):
"""Handle K-profile response from printer."""
filaments = data.get("filaments", [])
profiles = []
# Log first profile to see what fields the printer returns
if filaments and isinstance(filaments[0], dict):
logger.debug(f"[{self.serial_number}] Raw K-profile fields: {list(filaments[0].keys())}")
logger.debug(f"[{self.serial_number}] First K-profile: {filaments[0]}")
for i, f in enumerate(filaments):
if isinstance(f, dict):
try:
# cali_idx is the actual slot/calibration index from the printer
cali_idx = f.get("cali_idx", i)
profiles.append(KProfile(
slot_id=cali_idx,
extruder_id=int(f.get("extruder_id", 0)),
nozzle_id=str(f.get("nozzle_id", "")),
nozzle_diameter=str(f.get("nozzle_diameter", "0.4")),
filament_id=str(f.get("filament_id", "")),
name=str(f.get("name", "")),
k_value=str(f.get("k_value", "0.000000")),
n_coef=str(f.get("n_coef", "0.000000")),
ams_id=int(f.get("ams_id", 0)),
tray_id=int(f.get("tray_id", -1)),
setting_id=f.get("setting_id"),
))
except (ValueError, TypeError) as e:
logger.warning(f"Failed to parse K-profile: {e}")
self.state.kprofiles = profiles
self._kprofile_response_data = profiles
# Signal that we received the response
if self._pending_kprofile_response:
self._pending_kprofile_response.set()
logger.info(f"[{self.serial_number}] Received {len(profiles)} K-profiles")
async def get_kprofiles(self, nozzle_diameter: str = "0.4", timeout: float = 5.0) -> list[KProfile]:
"""Request K-profiles from the printer.
Args:
nozzle_diameter: Filter by nozzle diameter (e.g., "0.4")
timeout: Timeout in seconds to wait for response
Returns:
List of KProfile objects
"""
if not self._client or not self.state.connected:
logger.warning(f"[{self.serial_number}] Cannot get K-profiles: not connected")
return []
# Set up response event
self._sequence_id += 1
self._pending_kprofile_response = asyncio.Event()
self._kprofile_response_data = None
# Send the command
command = {
"print": {
"command": "extrusion_cali_get",
"filament_id": "",
"nozzle_diameter": nozzle_diameter,
"sequence_id": str(self._sequence_id),
}
}
logger.info(f"[{self.serial_number}] Requesting K-profiles for nozzle {nozzle_diameter}")
self._client.publish(self.topic_publish, json.dumps(command))
# Wait for response
try:
await asyncio.wait_for(self._pending_kprofile_response.wait(), timeout=timeout)
return self._kprofile_response_data or []
except asyncio.TimeoutError:
logger.warning(f"[{self.serial_number}] Timeout waiting for K-profiles response")
return []
finally:
self._pending_kprofile_response = None
def set_kprofile(
self,
filament_id: str,
name: str,
k_value: str,
nozzle_diameter: str = "0.4",
nozzle_id: str = "HS00-0.4",
extruder_id: int = 0,
setting_id: str | None = None,
slot_id: int = 0,
) -> bool:
"""Set/update a K-profile on the printer.
Args:
filament_id: Bambu filament identifier
name: Profile name
k_value: Pressure advance value (e.g., "0.020000")
nozzle_diameter: Nozzle diameter (e.g., "0.4")
nozzle_id: Nozzle identifier (e.g., "HS00-0.4")
extruder_id: Extruder ID (0 or 1 for dual nozzle)
setting_id: Existing setting ID for updates, None for new
slot_id: Calibration index (cali_idx) for the profile
Returns:
True if command was sent, False otherwise
"""
if not self._client or not self.state.connected:
logger.warning(f"[{self.serial_number}] Cannot set K-profile: not connected")
return False
self._sequence_id += 1
# Build the filament entry - printer uses cali_idx for profile identification
# For new profiles (slot_id=0), use cali_idx=-1 to tell printer to create new slot
cali_idx = -1 if slot_id == 0 else slot_id
# Generate a setting_id for new profiles (required by printer)
# Format: "PF" + 17 random digits
import random
if not setting_id and slot_id == 0:
setting_id = f"PF{random.randint(10000000000000000, 99999999999999999)}"
filament_entry = {
"ams_id": 0,
"cali_idx": cali_idx,
"extruder_id": extruder_id,
"filament_id": filament_id,
"k_value": k_value,
"n_coef": "0.000000",
"name": name,
"nozzle_diameter": nozzle_diameter,
"nozzle_id": nozzle_id,
"setting_id": setting_id, # Always include setting_id
"tray_id": -1,
}
command = {
"print": {
"command": "extrusion_cali_set",
"filaments": [filament_entry],
"nozzle_diameter": nozzle_diameter,
"sequence_id": str(self._sequence_id),
}
}
command_json = json.dumps(command)
logger.info(f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={cali_idx}, new={slot_id==0})")
logger.debug(f"[{self.serial_number}] K-profile command: {command_json}")
self._client.publish(self.topic_publish, command_json)
return True
def delete_kprofile(
self,
cali_idx: int,
filament_id: str,
nozzle_id: str,
nozzle_diameter: str = "0.4",
extruder_id: int = 0,
) -> bool:
"""Delete a K-profile from the printer.
Args:
cali_idx: The calibration index (slot_id) of the profile to delete
filament_id: Bambu filament identifier
nozzle_id: Nozzle identifier (e.g., "HH00-0.4")
nozzle_diameter: Nozzle diameter (e.g., "0.4")
extruder_id: Extruder ID (0 or 1 for dual nozzle)
Returns:
True if command was sent, False otherwise
"""
if not self._client or not self.state.connected:
logger.warning(f"[{self.serial_number}] Cannot delete K-profile: not connected")
return False
self._sequence_id += 1
command = {
"print": {
"command": "extrusion_cali_del",
"sequence_id": str(self._sequence_id),
"extruder_id": extruder_id,
"nozzle_id": nozzle_id,
"filament_id": filament_id,
"cali_idx": cali_idx,
"nozzle_diameter": nozzle_diameter,
}
}
command_json = json.dumps(command)
logger.info(f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}")
logger.debug(f"[{self.serial_number}] K-profile delete command: {command_json}")
self._client.publish(self.topic_publish, command_json)
return True
@@ -0,0 +1,534 @@
"""Notification service for sending push notifications via various providers."""
import json
import logging
import smtplib
from datetime import datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any
from urllib.parse import quote
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.notification import NotificationProvider
logger = logging.getLogger(__name__)
class NotificationService:
"""Service for sending notifications through various providers."""
def __init__(self):
self._http_client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client."""
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(timeout=30.0)
return self._http_client
async def close(self):
"""Close HTTP client."""
if self._http_client and not self._http_client.is_closed:
await self._http_client.aclose()
def _is_in_quiet_hours(self, provider: NotificationProvider) -> bool:
"""Check if current time is within provider's quiet hours."""
if not provider.quiet_hours_enabled:
return False
if not provider.quiet_hours_start or not provider.quiet_hours_end:
return False
try:
now = datetime.now()
current_time = now.hour * 60 + now.minute
start_parts = provider.quiet_hours_start.split(":")
end_parts = provider.quiet_hours_end.split(":")
start_minutes = int(start_parts[0]) * 60 + int(start_parts[1])
end_minutes = int(end_parts[0]) * 60 + int(end_parts[1])
# Handle overnight quiet hours (e.g., 22:00 to 07:00)
if start_minutes > end_minutes:
# Quiet hours span midnight
return current_time >= start_minutes or current_time < end_minutes
else:
# Same day quiet hours
return start_minutes <= current_time < end_minutes
except (ValueError, TypeError, AttributeError):
logger.warning(f"Invalid quiet hours format for provider {provider.name}")
return False
def _format_duration(self, seconds: int | None) -> str:
"""Format duration in seconds to human-readable string."""
if seconds is None:
return "Unknown"
hours = seconds // 3600
minutes = (seconds % 3600) // 60
if hours > 0:
return f"{hours}h {minutes}m"
return f"{minutes}m"
def _build_print_start_message(self, printer_name: str, data: dict) -> tuple[str, str]:
"""Build notification message for print start event."""
filename = data.get("filename", "Unknown")
# Clean up filename
if filename.endswith(".gcode.3mf"):
filename = filename[:-10]
elif filename.endswith(".3mf"):
filename = filename[:-4]
title = "Print Started"
estimated_time = data.get("raw_data", {}).get("print", {}).get("mc_remaining_time")
time_str = self._format_duration(estimated_time * 60 if estimated_time else None)
message = f"{printer_name}: {filename}\nEstimated: {time_str}"
return title, message
def _build_print_complete_message(
self, printer_name: str, status: str, data: dict, archive_data: dict | None = None
) -> tuple[str, str]:
"""Build notification message for print complete event."""
filename = data.get("filename", "Unknown")
if filename.endswith(".gcode.3mf"):
filename = filename[:-10]
elif filename.endswith(".3mf"):
filename = filename[:-4]
if status == "completed":
title = "Print Completed"
elif status == "failed":
title = "Print Failed"
elif status in ("aborted", "stopped", "cancelled"):
title = "Print Stopped"
else:
title = "Print Ended"
lines = [f"{printer_name}: {filename}"]
if archive_data:
# Add print time if available
if archive_data.get("print_time_seconds"):
lines.append(f"Time: {self._format_duration(archive_data['print_time_seconds'])}")
# Add filament used if available
if archive_data.get("actual_filament_grams"):
lines.append(f"Filament: {archive_data['actual_filament_grams']:.1f}g")
# Add failure reason if failed
if status == "failed" and archive_data.get("failure_reason"):
lines.append(f"Reason: {archive_data['failure_reason']}")
message = "\n".join(lines)
return title, message
def _build_progress_message(
self, printer_name: str, filename: str, progress: int
) -> tuple[str, str]:
"""Build notification message for print progress milestone."""
if filename.endswith(".gcode.3mf"):
filename = filename[:-10]
elif filename.endswith(".3mf"):
filename = filename[:-4]
title = f"Print {progress}% Complete"
message = f"{printer_name}: {filename}"
return title, message
def _build_printer_offline_message(self, printer_name: str) -> tuple[str, str]:
"""Build notification message for printer offline event."""
title = "Printer Offline"
message = f"{printer_name} has disconnected"
return title, message
def _build_printer_error_message(
self, printer_name: str, error_type: str, error_detail: str | None = None
) -> tuple[str, str]:
"""Build notification message for printer error event."""
title = f"Printer Error: {error_type}"
message = f"{printer_name}"
if error_detail:
message += f"\n{error_detail}"
return title, message
def _build_filament_low_message(
self, printer_name: str, slot: int, remaining_percent: int
) -> tuple[str, str]:
"""Build notification message for low filament event."""
title = "Filament Low"
message = f"{printer_name}: Slot {slot} at {remaining_percent}%"
return title, message
async def send_test_notification(
self, provider_type: str, config: dict[str, Any]
) -> tuple[bool, str]:
"""Send a test notification to verify configuration."""
title = "BambuTrack Test"
message = "This is a test notification from BambuTrack. If you see this, notifications are working correctly!"
try:
if provider_type == "callmebot":
return await self._send_callmebot(config, f"{title}\n{message}")
elif provider_type == "ntfy":
return await self._send_ntfy(config, title, message)
elif provider_type == "pushover":
return await self._send_pushover(config, title, message)
elif provider_type == "telegram":
return await self._send_telegram(config, f"*{title}*\n{message}")
elif provider_type == "email":
return await self._send_email(config, title, message)
else:
return False, f"Unknown provider type: {provider_type}"
except Exception as e:
logger.exception(f"Error sending test notification via {provider_type}")
return False, str(e)
async def _send_callmebot(self, config: dict, message: str) -> tuple[bool, str]:
"""Send notification via CallMeBot (WhatsApp)."""
phone = config.get("phone", "").strip()
apikey = config.get("apikey", "").strip()
if not phone or not apikey:
return False, "Phone number and API key are required"
# URL encode the message
encoded_message = quote(message)
url = f"https://api.callmebot.com/whatsapp.php?phone={phone}&text={encoded_message}&apikey={apikey}"
client = await self._get_client()
response = await client.get(url)
if response.status_code == 200:
return True, "Message sent successfully"
else:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
async def _send_ntfy(self, config: dict, title: str, message: str) -> tuple[bool, str]:
"""Send notification via ntfy."""
server = config.get("server", "https://ntfy.sh").rstrip("/")
topic = config.get("topic", "").strip()
auth_token = config.get("auth_token", "").strip()
if not topic:
return False, "Topic is required"
url = f"{server}/{topic}"
headers = {"Title": title}
if auth_token:
headers["Authorization"] = f"Bearer {auth_token}"
client = await self._get_client()
response = await client.post(url, content=message, headers=headers)
if response.status_code in (200, 204):
return True, "Message sent successfully"
else:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
async def _send_pushover(self, config: dict, title: str, message: str) -> tuple[bool, str]:
"""Send notification via Pushover."""
user_key = config.get("user_key", "").strip()
app_token = config.get("app_token", "").strip()
priority = config.get("priority", 0)
if not user_key or not app_token:
return False, "User key and app token are required"
url = "https://api.pushover.net/1/messages.json"
data = {
"token": app_token,
"user": user_key,
"title": title,
"message": message,
"priority": priority,
}
client = await self._get_client()
response = await client.post(url, data=data)
if response.status_code == 200:
return True, "Message sent successfully"
else:
try:
error_data = response.json()
errors = error_data.get("errors", [])
return False, f"Pushover error: {', '.join(errors)}"
except Exception:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
async def _send_telegram(self, config: dict, message: str) -> tuple[bool, str]:
"""Send notification via Telegram bot."""
bot_token = config.get("bot_token", "").strip()
chat_id = config.get("chat_id", "").strip()
if not bot_token or not chat_id:
return False, "Bot token and chat ID are required"
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
data = {
"chat_id": chat_id,
"text": message,
"parse_mode": "Markdown",
}
client = await self._get_client()
response = await client.post(url, json=data)
if response.status_code == 200:
result = response.json()
if result.get("ok"):
return True, "Message sent successfully"
else:
return False, f"Telegram error: {result.get('description', 'Unknown error')}"
else:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
async def _send_email(self, config: dict, subject: str, body: str) -> tuple[bool, str]:
"""Send notification via email (SMTP)."""
smtp_server = config.get("smtp_server", "").strip()
smtp_port = int(config.get("smtp_port", 587))
username = config.get("username", "").strip()
password = config.get("password", "").strip()
from_email = config.get("from_email", "").strip()
to_email = config.get("to_email", "").strip()
# Security: "starttls" (port 587), "ssl" (port 465), "none" (port 25)
security = config.get("security", "starttls")
# Authentication: "true" or "false"
auth_enabled = config.get("auth_enabled", "true").lower() == "true"
if not all([smtp_server, from_email, to_email]):
return False, "SMTP server, from email, and to email are required"
if auth_enabled and not all([username, password]):
return False, "Username and password are required when authentication is enabled"
try:
msg = MIMEMultipart()
msg["From"] = from_email
msg["To"] = to_email
msg["Subject"] = f"[BambuTrack] {subject}"
msg.attach(MIMEText(body, "plain"))
if security == "ssl":
# Direct SSL connection (typically port 465)
server = smtplib.SMTP_SSL(smtp_server, smtp_port)
elif security == "starttls":
# STARTTLS upgrade (typically port 587)
server = smtplib.SMTP(smtp_server, smtp_port)
server.starttls()
else:
# No encryption (typically port 25) - use with caution
server = smtplib.SMTP(smtp_server, smtp_port)
if auth_enabled:
server.login(username, password)
server.sendmail(from_email, to_email, msg.as_string())
server.quit()
return True, "Email sent successfully"
except smtplib.SMTPAuthenticationError:
return False, "SMTP authentication failed - check username/password"
except smtplib.SMTPException as e:
return False, f"SMTP error: {str(e)}"
except Exception as e:
return False, f"Email error: {str(e)}"
async def _send_to_provider(
self, provider: NotificationProvider, title: str, message: str
) -> tuple[bool, str]:
"""Send notification to a specific provider."""
# Check quiet hours
if self._is_in_quiet_hours(provider):
logger.info(f"Skipping notification to {provider.name} - quiet hours active")
return True, "Skipped - quiet hours"
config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
try:
if provider.provider_type == "callmebot":
return await self._send_callmebot(config, f"{title}\n{message}")
elif provider.provider_type == "ntfy":
return await self._send_ntfy(config, title, message)
elif provider.provider_type == "pushover":
return await self._send_pushover(config, title, message)
elif provider.provider_type == "telegram":
return await self._send_telegram(config, f"*{title}*\n{message}")
elif provider.provider_type == "email":
return await self._send_email(config, title, message)
else:
return False, f"Unknown provider type: {provider.provider_type}"
except Exception as e:
logger.exception(f"Error sending notification via {provider.provider_type}")
return False, str(e)
async def _update_provider_status(
self, db: AsyncSession, provider_id: int, success: bool, error: str | None = None
):
"""Update provider status after sending notification."""
result = await db.execute(
select(NotificationProvider).where(NotificationProvider.id == provider_id)
)
provider = result.scalar_one_or_none()
if provider:
if success:
provider.last_success = datetime.utcnow()
else:
provider.last_error = error
provider.last_error_at = datetime.utcnow()
await db.commit()
async def _get_providers_for_event(
self,
db: AsyncSession,
event_field: str,
printer_id: int | None = None,
) -> list[NotificationProvider]:
"""Get all enabled providers that want a specific event type."""
# Build the query dynamically based on event field
query = select(NotificationProvider).where(
NotificationProvider.enabled == True,
getattr(NotificationProvider, event_field) == True,
)
if printer_id is not None:
query = query.where(
(NotificationProvider.printer_id == None) | (NotificationProvider.printer_id == printer_id)
)
result = await db.execute(query)
return list(result.scalars().all())
async def _send_to_providers(
self,
providers: list[NotificationProvider],
title: str,
message: str,
db: AsyncSession,
):
"""Send notification to multiple providers."""
for provider in providers:
try:
success, error = await self._send_to_provider(provider, title, message)
await self._update_provider_status(db, provider.id, success, error if not success else None)
if success:
logger.info(f"Sent notification via {provider.name}")
else:
logger.warning(f"Failed to send notification via {provider.name}: {error}")
except Exception as e:
logger.exception(f"Error sending notification via {provider.name}")
await self._update_provider_status(db, provider.id, False, str(e))
async def on_print_start(
self, printer_id: int, printer_name: str, data: dict, db: AsyncSession
):
"""Handle print start event - send notifications to relevant providers."""
logger.info(f"on_print_start called for printer {printer_id} ({printer_name})")
providers = await self._get_providers_for_event(db, "on_print_start", printer_id)
if not providers:
logger.info(f"No notification providers configured for print_start event on printer {printer_id}")
return
logger.info(f"Found {len(providers)} providers for print_start: {[p.name for p in providers]}")
title, message = self._build_print_start_message(printer_name, data)
await self._send_to_providers(providers, title, message, db)
async def on_print_complete(
self,
printer_id: int,
printer_name: str,
status: str,
data: dict,
db: AsyncSession,
archive_data: dict | None = None,
):
"""Handle print complete event - send notifications to relevant providers."""
logger.info(f"on_print_complete called for printer {printer_id} ({printer_name}), status={status}")
# Determine which event type this is
if status == "completed":
event_field = "on_print_complete"
elif status in ("failed",):
event_field = "on_print_failed"
elif status in ("aborted", "stopped", "cancelled"):
event_field = "on_print_stopped"
else:
# Unknown status, default to on_print_complete
logger.warning(f"Unknown print status '{status}', defaulting to on_print_complete")
event_field = "on_print_complete"
providers = await self._get_providers_for_event(db, event_field, printer_id)
if not providers:
logger.info(f"No notification providers configured for {event_field} event on printer {printer_id}")
return
logger.info(f"Found {len(providers)} providers for {event_field}: {[p.name for p in providers]}")
title, message = self._build_print_complete_message(printer_name, status, data, archive_data)
await self._send_to_providers(providers, title, message, db)
async def on_print_progress(
self,
printer_id: int,
printer_name: str,
filename: str,
progress: int,
db: AsyncSession,
):
"""Handle print progress milestone (25%, 50%, 75%)."""
providers = await self._get_providers_for_event(db, "on_print_progress", printer_id)
if not providers:
return
title, message = self._build_progress_message(printer_name, filename, progress)
await self._send_to_providers(providers, title, message, db)
async def on_printer_offline(
self, printer_id: int, printer_name: str, db: AsyncSession
):
"""Handle printer offline event."""
providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)
if not providers:
return
title, message = self._build_printer_offline_message(printer_name)
await self._send_to_providers(providers, title, message, db)
async def on_printer_error(
self,
printer_id: int,
printer_name: str,
error_type: str,
db: AsyncSession,
error_detail: str | None = None,
):
"""Handle printer error event (AMS issues, etc.)."""
providers = await self._get_providers_for_event(db, "on_printer_error", printer_id)
if not providers:
return
title, message = self._build_printer_error_message(printer_name, error_type, error_detail)
await self._send_to_providers(providers, title, message, db)
async def on_filament_low(
self,
printer_id: int,
printer_name: str,
slot: int,
remaining_percent: int,
db: AsyncSession,
):
"""Handle low filament event."""
providers = await self._get_providers_for_event(db, "on_filament_low", printer_id)
if not providers:
return
title, message = self._build_filament_low_message(printer_name, slot, remaining_percent)
await self._send_to_providers(providers, title, message, db)
# Global instance
notification_service = NotificationService()
+23
View File
@@ -112,6 +112,29 @@ class PrinterManager:
return self._clients[printer_id].state.connected
return False
def get_client(self, printer_id: int) -> BambuMQTTClient | None:
"""Get the MQTT client for a printer."""
return self._clients.get(printer_id)
def mark_printer_offline(self, printer_id: int):
"""Mark a printer as offline and trigger status callback.
This is used when we know the printer power was cut (e.g., smart plug turned off)
to immediately update the UI without waiting for MQTT timeout.
"""
import logging
logger = logging.getLogger(__name__)
if printer_id in self._clients:
client = self._clients[printer_id]
if client.state.connected:
logger.info(f"Marking printer {printer_id} as offline (smart plug power off)")
client.state.connected = False
client.state.state = "unknown"
# Trigger the status change callback to broadcast via WebSocket
if self._on_status_change:
self._schedule_async(self._on_status_change(printer_id, client.state))
def start_print(self, printer_id: int, filename: str) -> bool:
"""Start a print on a connected printer."""
if printer_id in self._clients:
+42 -6
View File
@@ -62,9 +62,10 @@ class SmartPlugManager:
success = await tasmota_service.turn_on(plug)
if success:
# Update last state
# Update last state and reset auto_off_executed
plug.last_state = "ON"
plug.last_checked = datetime.utcnow()
plug.auto_off_executed = False # Reset flag when turning on
await db.commit()
async def on_print_complete(
@@ -103,11 +104,11 @@ class SmartPlugManager:
)
if plug.off_delay_mode == "time":
self._schedule_delayed_off(plug, plug.off_delay_minutes * 60)
self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
elif plug.off_delay_mode == "temperature":
self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
def _schedule_delayed_off(self, plug: "SmartPlug", delay_seconds: int):
def _schedule_delayed_off(self, plug: "SmartPlug", printer_id: int, delay_seconds: int):
"""Schedule turn-off after delay."""
# Cancel any existing task for this plug
self._cancel_pending_off(plug.id)
@@ -117,7 +118,7 @@ class SmartPlugManager:
)
task = asyncio.create_task(
self._delayed_off(plug.id, plug.ip_address, plug.username, plug.password, delay_seconds)
self._delayed_off(plug.id, plug.ip_address, plug.username, plug.password, printer_id, delay_seconds)
)
self._pending_off[plug.id] = task
@@ -127,6 +128,7 @@ class SmartPlugManager:
ip_address: str,
username: str | None,
password: str | None,
printer_id: int,
delay_seconds: int,
):
"""Wait and turn off."""
@@ -142,9 +144,15 @@ class SmartPlugManager:
self.name = f"plug_{plug_id}"
plug_info = PlugInfo()
await tasmota_service.turn_off(plug_info)
success = await tasmota_service.turn_off(plug_info)
logger.info(f"Turned off plug {plug_id} after time delay")
# Mark auto_off_executed in database and update printer status
if success:
await self._mark_auto_off_executed(plug_id)
# Mark the printer as offline immediately
printer_manager.mark_printer_offline(printer_id)
except asyncio.CancelledError:
logger.debug(f"Delayed turn-off cancelled for plug {plug_id}")
finally:
@@ -226,11 +234,18 @@ class SmartPlugManager:
self.name = f"plug_{plug_id}"
plug_info = PlugInfo()
await tasmota_service.turn_off(plug_info)
success = await tasmota_service.turn_off(plug_info)
logger.info(
f"Turned off plug {plug_id} after nozzle temp dropped to "
f"{max_nozzle_temp}°C (threshold: {temp_threshold}°C)"
)
# Mark auto_off_executed in database and update printer status
if success:
await self._mark_auto_off_executed(plug_id)
# Mark the printer as offline immediately
printer_manager.mark_printer_offline(printer_id)
break
await asyncio.sleep(check_interval)
@@ -246,6 +261,27 @@ class SmartPlugManager:
finally:
self._pending_off.pop(plug_id, None)
async def _mark_auto_off_executed(self, plug_id: int):
"""Disable auto-off after it was executed (one-shot behavior)."""
try:
from backend.app.core.database import async_session
from backend.app.models.smart_plug import SmartPlug
async with async_session() as db:
result = await db.execute(
select(SmartPlug).where(SmartPlug.id == plug_id)
)
plug = result.scalar_one_or_none()
if plug:
plug.auto_off = False # Disable auto-off (one-shot behavior)
plug.auto_off_executed = False # Reset the flag
plug.last_state = "OFF"
plug.last_checked = datetime.utcnow()
await db.commit()
logger.info(f"Auto-off executed and disabled for plug {plug_id}")
except Exception as e:
logger.warning(f"Failed to update plug {plug_id} after auto-off: {e}")
def _cancel_pending_off(self, plug_id: int):
"""Cancel any pending off task for this plug."""
if plug_id in self._pending_off:
+37455
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 526 KiB

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 359 KiB

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 KiB

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 KiB

After

Width:  |  Height:  |  Size: 587 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 KiB

+2 -2
View File
@@ -6,7 +6,7 @@ import { ArchivesPage } from './pages/ArchivesPage';
import { QueuePage } from './pages/QueuePage';
import { StatsPage } from './pages/StatsPage';
import { SettingsPage } from './pages/SettingsPage';
import { CloudProfilesPage } from './pages/CloudProfilesPage';
import { ProfilesPage } from './pages/ProfilesPage';
import { useWebSocket } from './hooks/useWebSocket';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
@@ -38,7 +38,7 @@ function App() {
<Route path="archives" element={<ArchivesPage />} />
<Route path="queue" element={<QueuePage />} />
<Route path="stats" element={<StatsPage />} />
<Route path="cloud" element={<CloudProfilesPage />} />
<Route path="profiles" element={<ProfilesPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
</Routes>
+216 -1
View File
@@ -28,6 +28,7 @@ export interface Printer {
ip_address: string;
access_code: string;
model: string | null;
nozzle_count: number; // 1 or 2, auto-detected from MQTT
is_active: boolean;
auto_archive: boolean;
created_at: string;
@@ -150,6 +151,7 @@ export interface AppSettings {
default_filament_cost: number;
currency: string;
energy_cost_per_kwh: number;
energy_tracking_mode: 'print' | 'total';
}
export type AppSettingsUpdate = Partial<AppSettings>;
@@ -205,6 +207,7 @@ export interface SmartPlug {
password: string | null;
last_state: string | null;
last_checked: string | null;
auto_off_executed: boolean; // True when auto-off was triggered after print
created_at: string;
updated_at: string;
}
@@ -310,6 +313,171 @@ export interface MQTTLogsResponse {
logs: MQTTLogEntry[];
}
// K-Profile types
export interface KProfile {
slot_id: number;
extruder_id: number;
nozzle_id: string;
nozzle_diameter: string;
filament_id: string;
name: string;
k_value: string;
n_coef: string;
ams_id: number;
tray_id: number;
setting_id: string | null;
}
export interface KProfileCreate {
slot_id?: number; // Storage slot, 0 for new profiles
extruder_id?: number;
nozzle_id: string;
nozzle_diameter: string;
filament_id: string;
name: string;
k_value: string;
n_coef?: string;
ams_id?: number;
tray_id?: number;
setting_id?: string | null;
}
export interface KProfileDelete {
slot_id: number; // cali_idx - calibration index to delete
extruder_id: number;
nozzle_id: string; // e.g., "HH00-0.4"
nozzle_diameter: string; // e.g., "0.4"
filament_id: string; // Bambu filament identifier
}
export interface KProfilesResponse {
profiles: KProfile[];
nozzle_diameter: string;
}
// Notification Provider types
export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email';
export interface NotificationProvider {
id: number;
name: string;
provider_type: ProviderType;
enabled: boolean;
config: Record<string, unknown>;
// Print lifecycle events
on_print_start: boolean;
on_print_complete: boolean;
on_print_failed: boolean;
on_print_stopped: boolean;
on_print_progress: boolean;
// Printer status events
on_printer_offline: boolean;
on_printer_error: boolean;
on_filament_low: boolean;
// Quiet hours
quiet_hours_enabled: boolean;
quiet_hours_start: string | null;
quiet_hours_end: string | null;
// Printer filter
printer_id: number | null;
// Status tracking
last_success: string | null;
last_error: string | null;
last_error_at: string | null;
// Timestamps
created_at: string;
updated_at: string;
}
export interface NotificationProviderCreate {
name: string;
provider_type: ProviderType;
enabled?: boolean;
config: Record<string, unknown>;
// Print lifecycle events
on_print_start?: boolean;
on_print_complete?: boolean;
on_print_failed?: boolean;
on_print_stopped?: boolean;
on_print_progress?: boolean;
// Printer status events
on_printer_offline?: boolean;
on_printer_error?: boolean;
on_filament_low?: boolean;
// Quiet hours
quiet_hours_enabled?: boolean;
quiet_hours_start?: string | null;
quiet_hours_end?: string | null;
// Printer filter
printer_id?: number | null;
}
export interface NotificationProviderUpdate {
name?: string;
provider_type?: ProviderType;
enabled?: boolean;
config?: Record<string, unknown>;
// Print lifecycle events
on_print_start?: boolean;
on_print_complete?: boolean;
on_print_failed?: boolean;
on_print_stopped?: boolean;
on_print_progress?: boolean;
// Printer status events
on_printer_offline?: boolean;
on_printer_error?: boolean;
on_filament_low?: boolean;
// Quiet hours
quiet_hours_enabled?: boolean;
quiet_hours_start?: string | null;
quiet_hours_end?: string | null;
// Printer filter
printer_id?: number | null;
}
export interface NotificationTestRequest {
provider_type: ProviderType;
config: Record<string, unknown>;
}
export interface NotificationTestResponse {
success: boolean;
message: string;
}
// Provider-specific config types for reference
export interface CallMeBotConfig {
phone: string;
apikey: string;
}
export interface NtfyConfig {
server?: string;
topic: string;
auth_token?: string | null;
}
export interface PushoverConfig {
user_key: string;
app_token: string;
priority?: number;
}
export interface TelegramConfig {
bot_token: string;
chat_id: string;
}
export interface EmailConfig {
smtp_server: string;
smtp_port?: number;
username: string;
password: string;
from_email: string;
to_email: string;
use_tls?: boolean;
}
// API functions
export const api = {
// Printers
@@ -412,9 +580,19 @@ export const api = {
getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
getArchiveTimelapse: (id: number) => `${API_BASE}/archives/${id}/timelapse`,
scanArchiveTimelapse: (id: number) =>
request<{ status: string; message: string; filename?: string }>(`/archives/${id}/timelapse/scan`, {
request<{
status: string;
message: string;
filename?: string;
available_files?: Array<{ name: string; path: string; size: number; mtime: string | null }>;
}>(`/archives/${id}/timelapse/scan`, {
method: 'POST',
}),
selectArchiveTimelapse: (id: number, filename: string) =>
request<{ status: string; message: string; filename: string }>(
`/archives/${id}/timelapse/select?filename=${encodeURIComponent(filename)}`,
{ method: 'POST' }
),
uploadArchiveTimelapse: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
const formData = new FormData();
formData.append('file', file);
@@ -631,4 +809,41 @@ export const api = {
request<{ message: string }>(`/queue/${id}/cancel`, { method: 'POST' }),
stopQueueItem: (id: number) =>
request<{ message: string }>(`/queue/${id}/stop`, { method: 'POST' }),
// K-Profiles
getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>
request<KProfilesResponse>(`/printers/${printerId}/kprofiles/?nozzle_diameter=${nozzleDiameter}`),
setKProfile: (printerId: number, profile: KProfileCreate) =>
request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/`, {
method: 'POST',
body: JSON.stringify(profile),
}),
deleteKProfile: (printerId: number, profile: KProfileDelete) =>
request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/`, {
method: 'DELETE',
body: JSON.stringify(profile),
}),
// Notification Providers
getNotificationProviders: () => request<NotificationProvider[]>('/notifications/'),
getNotificationProvider: (id: number) => request<NotificationProvider>(`/notifications/${id}`),
createNotificationProvider: (data: NotificationProviderCreate) =>
request<NotificationProvider>('/notifications/', {
method: 'POST',
body: JSON.stringify(data),
}),
updateNotificationProvider: (id: number, data: NotificationProviderUpdate) =>
request<NotificationProvider>(`/notifications/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
deleteNotificationProvider: (id: number) =>
request<{ message: string }>(`/notifications/${id}`, { method: 'DELETE' }),
testNotificationProvider: (id: number) =>
request<NotificationTestResponse>(`/notifications/${id}/test`, { method: 'POST' }),
testNotificationConfig: (data: NotificationTestRequest) =>
request<NotificationTestResponse>('/notifications/test-config', {
method: 'POST',
body: JSON.stringify(data),
}),
};
@@ -0,0 +1,414 @@
import { useState, useEffect } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { X, Save, Loader2, Send, CheckCircle, XCircle } from 'lucide-react';
import { api } from '../api/client';
import type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType } from '../api/client';
import { Button } from './Button';
interface AddNotificationModalProps {
provider?: NotificationProvider | null;
onClose: () => void;
}
const PROVIDER_OPTIONS: { value: ProviderType; label: string; description: string }[] = [
{ value: 'callmebot', label: 'CallMeBot/WhatsApp', description: 'Free WhatsApp notifications via CallMeBot' },
{ value: 'ntfy', label: 'ntfy', description: 'Free, self-hostable push notifications' },
{ value: 'pushover', label: 'Pushover', description: 'Simple, reliable push notifications' },
{ value: 'telegram', label: 'Telegram', description: 'Notifications via Telegram bot' },
{ value: 'email', label: 'Email', description: 'SMTP email notifications' },
];
export function AddNotificationModal({ provider, onClose }: AddNotificationModalProps) {
const queryClient = useQueryClient();
const isEditing = !!provider;
const [name, setName] = useState(provider?.name || '');
const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type || 'ntfy');
const [printerId, setPrinterId] = useState<number | null>(provider?.printer_id || null);
const [quietHoursEnabled, setQuietHoursEnabled] = useState(provider?.quiet_hours_enabled || false);
const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00');
const [quietHoursEnd, setQuietHoursEnd] = useState(provider?.quiet_hours_end || '07:00');
// Provider-specific config
const [config, setConfig] = useState<Record<string, string>>(
provider?.config ? Object.fromEntries(Object.entries(provider.config).map(([k, v]) => [k, String(v)])) : {}
);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [error, setError] = useState<string | null>(null);
// Fetch printers for linking
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
// Test configuration mutation
const testMutation = useMutation({
mutationFn: () => api.testNotificationConfig({ provider_type: providerType, config }),
onSuccess: (result) => {
setTestResult(result);
setError(null);
},
onError: (err: Error) => {
setTestResult({ success: false, message: err.message });
},
});
// Create mutation
const createMutation = useMutation({
mutationFn: (data: NotificationProviderCreate) => api.createNotificationProvider(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
onClose();
},
onError: (err: Error) => {
setError(err.message);
},
});
// Update mutation
const updateMutation = useMutation({
mutationFn: (data: NotificationProviderUpdate) => api.updateNotificationProvider(provider!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
onClose();
},
onError: (err: Error) => {
setError(err.message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError('Name is required');
return;
}
// Validate provider-specific config
const requiredFields = getRequiredFields(providerType);
for (const field of requiredFields) {
if (!config[field.key]?.trim()) {
setError(`${field.label} is required`);
return;
}
}
const data = {
name: name.trim(),
provider_type: providerType,
config,
printer_id: printerId,
quiet_hours_enabled: quietHoursEnabled,
quiet_hours_start: quietHoursEnabled ? quietHoursStart : null,
quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null,
};
if (isEditing) {
updateMutation.mutate(data);
} else {
createMutation.mutate(data);
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
// Get config fields for each provider type
const getConfigFields = (type: ProviderType) => {
switch (type) {
case 'callmebot':
return [
{ key: 'phone', label: 'Phone Number', placeholder: '+1234567890', type: 'text', required: true },
{ key: 'apikey', label: 'API Key', placeholder: 'Your CallMeBot API key', type: 'text', required: true },
];
case 'ntfy':
return [
{ key: 'server', label: 'Server URL', placeholder: 'https://ntfy.sh', type: 'text', required: false },
{ key: 'topic', label: 'Topic', placeholder: 'my-bambutrack', type: 'text', required: true },
{ key: 'auth_token', label: 'Auth Token', placeholder: 'Optional authentication', type: 'password', required: false },
];
case 'pushover':
return [
{ key: 'user_key', label: 'User Key', placeholder: 'Your Pushover user key', type: 'text', required: true },
{ key: 'app_token', label: 'App Token', placeholder: 'Your Pushover app token', type: 'text', required: true },
{ key: 'priority', label: 'Priority', placeholder: '0 (normal)', type: 'number', required: false },
];
case 'telegram':
return [
{ key: 'bot_token', label: 'Bot Token', placeholder: 'Bot token from @BotFather', type: 'password', required: true },
{ key: 'chat_id', label: 'Chat ID', placeholder: 'Your chat or group ID', type: 'text', required: true },
];
case 'email':
return [
{ key: 'smtp_server', label: 'SMTP Server', placeholder: 'smtp.gmail.com', type: 'text', required: true },
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587', type: 'number', required: false },
{ key: 'security', label: 'Security', type: 'select', required: false, options: [
{ value: 'starttls', label: 'STARTTLS (Port 587)' },
{ value: 'ssl', label: 'SSL/TLS (Port 465)' },
{ value: 'none', label: 'None (Port 25)' },
]},
{ key: 'auth_enabled', label: 'Authentication', type: 'select', required: false, options: [
{ value: 'true', label: 'Enabled' },
{ value: 'false', label: 'Disabled' },
]},
{ key: 'username', label: 'Username', placeholder: 'your@email.com', type: 'text', required: false },
{ key: 'password', label: 'Password', placeholder: 'App password', type: 'password', required: false },
{ key: 'from_email', label: 'From Email', placeholder: 'your@email.com', type: 'text', required: true },
{ key: 'to_email', label: 'To Email', placeholder: 'recipient@email.com', type: 'text', required: true },
];
default:
return [];
}
};
const getRequiredFields = (type: ProviderType) => {
return getConfigFields(type).filter(f => f.required);
};
const configFields = getConfigFields(providerType);
return (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4 overflow-y-auto"
onClick={onClose}
>
<div
className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg my-8"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
<h2 className="text-lg font-semibold text-white">
{isEditing ? 'Edit Notification Provider' : 'Add Notification Provider'}
</h2>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
{error}
</div>
)}
{/* Name */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Notifications"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
{/* Provider Type */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Provider Type *</label>
<select
value={providerType}
onChange={(e) => {
setProviderType(e.target.value as ProviderType);
setConfig({}); // Reset config when changing type
setTestResult(null);
}}
disabled={isEditing}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-50"
>
{PROVIDER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
{PROVIDER_OPTIONS.find(o => o.value === providerType)?.description}
</p>
</div>
{/* Provider-specific configuration */}
<div className="space-y-3">
<p className="text-sm text-bambu-gray">Configuration</p>
{configFields.map((field) => (
<div key={field.key}>
<label className="block text-sm text-bambu-gray mb-1">
{field.label} {field.required && '*'}
</label>
{field.type === 'select' && field.options ? (
<select
value={config[field.key] || field.options[0]?.value || ''}
onChange={(e) => {
setConfig({ ...config, [field.key]: e.target.value });
setTestResult(null);
}}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
{field.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : (
<input
type={field.type}
value={config[field.key] || ''}
onChange={(e) => {
setConfig({ ...config, [field.key]: e.target.value });
setTestResult(null);
}}
placeholder={field.placeholder}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
)}
</div>
))}
</div>
{/* Test Button */}
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
onClick={() => {
setTestResult(null);
testMutation.mutate();
}}
disabled={testMutation.isPending || !config[getRequiredFields(providerType)[0]?.key]}
className="flex-1"
>
{testMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
Test Configuration
</Button>
</div>
{/* Test Result */}
{testResult && (
<div className={`p-3 rounded-lg flex items-center gap-2 ${
testResult.success
? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'
: 'bg-red-500/20 border border-red-500/50 text-red-400'
}`}>
{testResult.success ? (
<>
<CheckCircle className="w-5 h-5" />
<span>{testResult.message}</span>
</>
) : (
<>
<XCircle className="w-5 h-5" />
<span>{testResult.message}</span>
</>
)}
</div>
)}
{/* Link to Printer */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Printer Filter</label>
<select
value={printerId ?? ''}
onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="">All printers</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
Only send notifications for events from this printer
</p>
</div>
{/* Quiet Hours */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm text-white">Quiet Hours (Do Not Disturb)</label>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={quietHoursEnabled}
onChange={(e) => setQuietHoursEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
{quietHoursEnabled && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-bambu-gray mb-1">Start</label>
<input
type="time"
value={quietHoursStart}
onChange={(e) => setQuietHoursStart(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-xs text-bambu-gray mb-1">End</label>
<input
type="time"
value={quietHoursEnd}
onChange={(e) => setQuietHoursEnd(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="secondary"
onClick={onClose}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={isPending}
className="flex-1"
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
{isEditing ? 'Save' : 'Add'}
</Button>
</div>
</form>
</div>
</div>
);
}
+10 -1
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Tag, Plus, Loader2 } from 'lucide-react';
import { api } from '../api/client';
@@ -19,6 +19,15 @@ export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagMo
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
const [mode, setMode] = useState<'add' | 'remove'>('add');
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const batchTagMutation = useMutation({
mutationFn: async () => {
const tagsArray = Array.from(selectedTags);
+10
View File
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { X, AlertTriangle, AlertCircle, Info, ExternalLink } from 'lucide-react';
import type { HMSError } from '../api/client';
@@ -49,6 +50,15 @@ function getHMSWikiUrl(code: string): string {
}
export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalProps) {
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-bambu-dark-secondary rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] flex flex-col">
+786
View File
@@ -0,0 +1,786 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Gauge,
Loader2,
RefreshCw,
Printer,
Plus,
X,
AlertCircle,
WifiOff,
Trash2,
Search,
} from 'lucide-react';
import { api } from '../api/client';
import type { KProfile, KProfileCreate, KProfileDelete } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
interface KProfileCardProps {
profile: KProfile;
onEdit: () => void;
}
// Truncate to 3 decimal places (like Bambu Studio) instead of rounding
const truncateK = (value: string) => {
const num = parseFloat(value);
return (Math.trunc(num * 1000) / 1000).toFixed(3);
};
// Get flow type label from nozzle_id (e.g., "HH00-0.4" -> "HF", "HS00-0.4" -> "S")
const getFlowTypeLabel = (nozzleId: string) => {
if (nozzleId.startsWith('HH')) return 'HF'; // High Flow
return 'S'; // Standard Flow (default)
};
// Extract nozzle type prefix from nozzle_id (e.g., "HH00-0.4" -> "HH00")
const getNozzleTypePrefix = (nozzleId: string) => {
const match = nozzleId.match(/^([A-Z]{2}\d{2})/);
return match ? match[1] : 'HH00';
};
// Extract filament name from profile name (e.g., "High Flow_Devil Design PLA Basic" -> "Devil Design PLA Basic")
const extractFilamentName = (profileName: string) => {
// Profile names are formatted as "{Flow Type}_{Filament Name}" or "{Flow Type} {Filament Name}"
// Remove common prefixes - check both underscore and space separators
const prefixes = [
'High Flow_', 'High Flow ', // underscore or space
'Standard_', 'Standard ',
'HF_', 'HF ',
'S_', 'S ',
];
for (const prefix of prefixes) {
if (profileName.startsWith(prefix)) {
return profileName.slice(prefix.length);
}
}
// If no prefix found, check for underscore separator
const underscoreIdx = profileName.indexOf('_');
if (underscoreIdx > 0) {
return profileName.slice(underscoreIdx + 1);
}
return profileName;
};
function KProfileCard({ profile, onEdit }: KProfileCardProps) {
const flowType = getFlowTypeLabel(profile.nozzle_id);
const diameter = profile.nozzle_diameter;
const profileName = profile.name || 'Unnamed';
// Extract filament name from profile name (e.g., "High Flow_eSUN ABS+" -> "eSUN ABS+")
const filamentName = extractFilamentName(profile.name || '');
return (
<button
onClick={onEdit}
className="w-full text-left px-3 py-2 bg-bambu-dark rounded hover:bg-bambu-dark-tertiary transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-bambu-green font-mono text-sm font-bold whitespace-nowrap">
{truncateK(profile.k_value)}
</span>
<span className="text-white text-sm truncate flex-1" title={profileName}>
{profileName}
</span>
<span className="text-xs text-bambu-gray whitespace-nowrap">
{flowType} {diameter}
</span>
</div>
<div className="text-xs text-bambu-gray mt-0.5 truncate" title={`Filament: ${filamentName}`}>
Filament: {filamentName || profile.filament_id}
</div>
</button>
);
}
interface KProfileModalProps {
profile?: KProfile;
printerId: number;
nozzleDiameter: string;
existingProfiles?: KProfile[]; // Existing profiles for filament selection
isDualNozzle?: boolean; // Whether this is a dual-nozzle printer
onClose: () => void;
onSave: () => void;
}
function KProfileModal({
profile,
printerId,
nozzleDiameter,
existingProfiles = [],
isDualNozzle = false,
onClose,
onSave,
}: KProfileModalProps) {
const { showToast } = useToast();
const queryClient = useQueryClient();
const [name, setName] = useState(profile?.name || '');
const [kValue, setKValue] = useState(
profile?.k_value ? truncateK(profile.k_value) : '0.020'
);
const [filamentId, setFilamentId] = useState(profile?.filament_id || '');
// Split nozzle into type and diameter
const [nozzleType, setNozzleType] = useState(
profile?.nozzle_id ? getNozzleTypePrefix(profile.nozzle_id) : 'HH00'
);
const [modalDiameter, setModalDiameter] = useState(
profile?.nozzle_diameter || nozzleDiameter
);
const [extruderId, setExtruderId] = useState(profile?.extruder_id || 0);
// Extract unique filaments from existing K-profiles on the printer
// These have valid filament_ids that the printer recognizes
const knownFilaments = React.useMemo(() => {
const filamentMap = new Map<string, { id: string; name: string }>();
for (const p of existingProfiles) {
if (p.filament_id && !filamentMap.has(p.filament_id)) {
const filamentName = extractFilamentName(p.name || '');
filamentMap.set(p.filament_id, {
id: p.filament_id,
name: filamentName || p.filament_id,
});
}
}
return Array.from(filamentMap.values()).sort((a, b) =>
a.name.localeCompare(b.name)
);
}, [existingProfiles]);
const saveMutation = useMutation({
mutationFn: (data: KProfileCreate) => {
console.log('[KProfile] Calling API...');
return api.setKProfile(printerId, data);
},
onSuccess: (result) => {
console.log('[KProfile] Save success:', result);
showToast('K-profile saved');
queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
onSave();
},
onError: (error: Error) => {
console.error('[KProfile] Save error:', error);
showToast(error.message, 'error');
},
});
const deleteMutation = useMutation({
mutationFn: (data: KProfileDelete) => {
console.log('[KProfile] Deleting profile...');
return api.deleteKProfile(printerId, data);
},
onSuccess: (result) => {
console.log('[KProfile] Delete success:', result);
showToast('K-profile deleted');
queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
onClose();
},
onError: (error: Error) => {
console.error('[KProfile] Delete error:', error);
showToast(error.message, 'error');
},
});
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const handleDelete = () => {
if (!profile) return;
deleteMutation.mutate({
slot_id: profile.slot_id,
extruder_id: profile.extruder_id,
nozzle_id: profile.nozzle_id,
nozzle_diameter: profile.nozzle_diameter,
filament_id: profile.filament_id,
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Format k_value to 6 decimal places for Bambu protocol
const formattedKValue = parseFloat(kValue).toFixed(6);
// Combine nozzle type and diameter into nozzle_id (e.g., "HH00-0.4")
const nozzleId = `${nozzleType}-${modalDiameter}`;
// Use the name from the form - it's auto-populated when filament is selected
// but can be edited by the user
const payload = {
name: name,
k_value: formattedKValue,
filament_id: filamentId,
nozzle_id: nozzleId,
nozzle_diameter: modalDiameter,
extruder_id: extruderId,
setting_id: profile?.setting_id,
slot_id: profile?.slot_id ?? 0,
};
console.log('[KProfile] Saving profile:', payload);
saveMutation.mutate(payload);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-md">
<CardContent className="p-0">
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<h2 className="text-xl font-semibold text-white">
{profile ? 'Edit K-Profile' : 'Add K-Profile'}
</h2>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{/* Profile Name - read-only when editing */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Profile Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!!profile}
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
placeholder="My PLA Profile"
required
/>
</div>
{/* K-Value - always editable */}
<div>
<label className="block text-sm text-bambu-gray mb-1">K-Value</label>
<input
type="text"
inputMode="decimal"
value={kValue}
onChange={(e) => {
// Allow typing any decimal value
const val = e.target.value;
if (val === '' || /^\d*\.?\d*$/.test(val)) {
setKValue(val);
}
}}
onBlur={(e) => {
// Format to 3 decimal places on blur
const num = parseFloat(e.target.value);
if (!isNaN(num)) {
setKValue((Math.trunc(num * 1000) / 1000).toFixed(3));
}
}}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none font-mono"
placeholder="0.020"
required
/>
<p className="text-xs text-bambu-gray mt-1">
Typical range: 0.01 - 0.06 for PLA, 0.02 - 0.10 for PETG
</p>
</div>
{/* Filament - read-only when editing */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Filament</label>
<select
value={filamentId}
onChange={(e) => {
const newFilamentId = e.target.value;
setFilamentId(newFilamentId);
// Auto-generate profile name when filament is selected (for new profiles)
// Only auto-generate if name is empty - don't overwrite user input
if (!profile && newFilamentId && !name) {
const selectedFilament = knownFilaments.find(f => f.id === newFilamentId);
if (selectedFilament) {
const flowLabel = nozzleType === 'HH00' ? 'HF' : 'S';
setName(`${flowLabel} ${selectedFilament.name}`);
}
}
}}
disabled={!!profile}
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
required
>
<option value="">Select filament...</option>
{/* Show current filament when editing */}
{profile?.filament_id && (
<option key={profile.filament_id} value={profile.filament_id}>
{extractFilamentName(profile.name || profile.filament_id)}
</option>
)}
{/* Show known filaments from existing K-profiles (for new profiles) */}
{!profile && knownFilaments.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
{!profile && knownFilaments.length === 0 && (
<p className="text-xs text-bambu-gray mt-1">
No filaments found. Create a K-profile in Bambu Studio first.
</p>
)}
</div>
{/* Flow Type and Nozzle Size - read-only when editing */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-bambu-gray mb-1">Flow Type</label>
<select
value={nozzleType}
onChange={(e) => {
const newNozzleType = e.target.value;
setNozzleType(newNozzleType);
// Update profile name when flow type changes (for new profiles)
// Only auto-generate if name is empty - don't overwrite user input
if (!profile && filamentId && !name) {
const selectedFilament = knownFilaments.find(f => f.id === filamentId);
if (selectedFilament) {
const flowLabel = newNozzleType === 'HH00' ? 'HF' : 'S';
setName(`${flowLabel} ${selectedFilament.name}`);
}
}
}}
disabled={!!profile}
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<option value="HH00">High Flow</option>
<option value="HS00">Standard</option>
</select>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Nozzle Size</label>
<select
value={modalDiameter}
onChange={(e) => setModalDiameter(e.target.value)}
disabled={!!profile}
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<option value="0.2">0.2mm</option>
<option value="0.4">0.4mm</option>
<option value="0.6">0.6mm</option>
<option value="0.8">0.8mm</option>
</select>
</div>
</div>
{/* Extruder - only show for dual-nozzle printers, read-only when editing */}
{isDualNozzle && (
<div>
<label className="block text-sm text-bambu-gray mb-1">Extruder</label>
<select
value={extruderId}
onChange={(e) => setExtruderId(parseInt(e.target.value))}
disabled={!!profile}
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<option value={1}>Left</option>
<option value={0}>Right</option>
</select>
</div>
)}
<div className="flex gap-2 pt-4">
{profile && (
<Button
type="button"
variant="secondary"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteMutation.isPending}
className="text-red-500 hover:bg-red-500/10"
>
{deleteMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
)}
<Button
type="button"
variant="secondary"
onClick={onClose}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={saveMutation.isPending}
className="flex-1"
>
{saveMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Gauge className="w-4 h-4" />
)}
Save
</Button>
</div>
</form>
</CardContent>
</Card>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-[60]">
<Card className="w-full max-w-sm">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center">
<Trash2 className="w-5 h-5 text-red-500" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Delete Profile</h3>
<p className="text-sm text-bambu-gray">This cannot be undone</p>
</div>
</div>
<p className="text-bambu-gray mb-6">
Are you sure you want to delete <span className="text-white font-medium">"{profile?.name}"</span> from the printer?
</p>
<div className="flex gap-3">
<Button
variant="secondary"
onClick={() => setShowDeleteConfirm(false)}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={() => {
setShowDeleteConfirm(false);
handleDelete();
}}
disabled={deleteMutation.isPending}
className="flex-1 bg-red-500 hover:bg-red-600 text-white"
>
{deleteMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
Delete
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
);
}
type ExtruderFilter = 'all' | 'left' | 'right';
type FlowTypeFilter = 'all' | 'hf' | 's';
export function KProfilesView() {
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
const [nozzleDiameter, setNozzleDiameter] = useState('0.4');
const [editingProfile, setEditingProfile] = useState<KProfile | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [extruderFilter, setExtruderFilter] = useState<ExtruderFilter>('all');
const [flowTypeFilter, setFlowTypeFilter] = useState<FlowTypeFilter>('all');
// Get available printers
const { data: printers, isLoading: printersLoading } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
// Get K-profiles for selected printer
const {
data: kprofiles,
isLoading: kprofilesLoading,
error: kprofilesError,
refetch: refetchProfiles,
} = useQuery({
queryKey: ['kprofiles', selectedPrinter, nozzleDiameter],
queryFn: () => api.getKProfiles(selectedPrinter!, nozzleDiameter),
enabled: !!selectedPrinter,
retry: false,
});
// Check if error is due to printer not being connected
const isOfflineError = kprofilesError?.message?.includes('not connected');
// Filter profiles based on search query, extruder filter, and flow type
const filteredProfiles = React.useMemo(() => {
if (!kprofiles?.profiles) return [];
return kprofiles.profiles.filter((p) => {
// Search filter - match name or filament_id (case-insensitive)
const query = searchQuery.toLowerCase();
const matchesSearch =
!query ||
p.name.toLowerCase().includes(query) ||
p.filament_id.toLowerCase().includes(query);
// Extruder filter
const matchesExtruder =
extruderFilter === 'all' ||
(extruderFilter === 'left' && p.extruder_id === 1) ||
(extruderFilter === 'right' && p.extruder_id === 0);
// Flow type filter (HH = High Flow, HS = Standard)
const matchesFlowType =
flowTypeFilter === 'all' ||
(flowTypeFilter === 'hf' && p.nozzle_id.startsWith('HH')) ||
(flowTypeFilter === 's' && p.nozzle_id.startsWith('HS'));
return matchesSearch && matchesExtruder && matchesFlowType;
});
}, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter]);
// Auto-select first connected printer
const connectedPrinters = printers?.filter((p) => p.is_active) || [];
if (!selectedPrinter && connectedPrinters.length > 0) {
setSelectedPrinter(connectedPrinters[0].id);
}
// Check if selected printer is dual-nozzle (auto-detected from MQTT temperature data)
const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
const isDualNozzle = selectedPrinterData?.nozzle_count === 2;
if (printersLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
);
}
if (!printers || printers.length === 0) {
return (
<Card>
<CardContent className="py-12 text-center">
<AlertCircle className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No Printers Configured</h3>
<p className="text-bambu-gray">
Add a printer in Settings to manage K-profiles
</p>
</CardContent>
</Card>
);
}
if (connectedPrinters.length === 0) {
return (
<Card>
<CardContent className="py-12 text-center">
<Printer className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No Active Printers</h3>
<p className="text-bambu-gray">
Enable a printer connection to view its K-profiles
</p>
</CardContent>
</Card>
);
}
return (
<>
{/* Printer & Nozzle Selector */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex-1 min-w-48">
<label className="block text-sm text-bambu-gray mb-1">Printer</label>
<select
value={selectedPrinter || ''}
onChange={(e) => setSelectedPrinter(parseInt(e.target.value))}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
{connectedPrinters.map((printer) => (
<option key={printer.id} value={printer.id}>
{printer.name}
</option>
))}
</select>
</div>
<div className="w-32">
<label className="block text-sm text-bambu-gray mb-1">Nozzle</label>
<select
value={nozzleDiameter}
onChange={(e) => setNozzleDiameter(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="0.2">0.2mm</option>
<option value="0.4">0.4mm</option>
<option value="0.6">0.6mm</option>
<option value="0.8">0.8mm</option>
</select>
</div>
<div className="flex items-end gap-2">
<Button
variant="secondary"
onClick={() => refetchProfiles()}
disabled={kprofilesLoading}
>
<RefreshCw className={`w-4 h-4 ${kprofilesLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4" />
Add Profile
</Button>
</div>
</div>
{/* Search & Filter Row */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex-1 min-w-48 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by name or filament..."
className="w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
{isDualNozzle && (
<div className="w-36">
<select
value={extruderFilter}
onChange={(e) => setExtruderFilter(e.target.value as ExtruderFilter)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="all">All Extruders</option>
<option value="left">Left Only</option>
<option value="right">Right Only</option>
</select>
</div>
)}
<div className="w-32">
<select
value={flowTypeFilter}
onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="all">All Flow</option>
<option value="hf">HF Only</option>
<option value="s">S Only</option>
</select>
</div>
</div>
{/* K-Profiles Grid */}
{kprofilesLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
) : isOfflineError ? (
<Card>
<CardContent className="py-12 text-center">
<WifiOff className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">Printer Offline</h3>
<p className="text-bambu-gray mb-4">
The selected printer is not connected. Power it on to view K-profiles.
</p>
<Button variant="secondary" onClick={() => refetchProfiles()}>
<RefreshCw className="w-4 h-4" />
Retry
</Button>
</CardContent>
</Card>
) : filteredProfiles.length > 0 ? (
isDualNozzle ? (
// Dual-nozzle: show Left/Right columns
<div className="grid grid-cols-2 gap-4">
{/* Left Extruder (extruder_id 1 on Bambu) */}
<div>
<h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">Left Extruder</h3>
<div className="space-y-1">
{filteredProfiles
.filter((p) => p.extruder_id === 1)
.map((profile) => (
<KProfileCard
key={profile.slot_id}
profile={profile}
onEdit={() => setEditingProfile(profile)}
/>
))}
</div>
</div>
{/* Right Extruder (extruder_id 0 on Bambu) */}
<div>
<h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">Right Extruder</h3>
<div className="space-y-1">
{filteredProfiles
.filter((p) => p.extruder_id === 0)
.map((profile) => (
<KProfileCard
key={profile.slot_id}
profile={profile}
onEdit={() => setEditingProfile(profile)}
/>
))}
</div>
</div>
</div>
) : (
// Single-nozzle: show all profiles in one list
<div className="space-y-1">
{filteredProfiles.map((profile) => (
<KProfileCard
key={profile.slot_id}
profile={profile}
onEdit={() => setEditingProfile(profile)}
/>
))}
</div>
)
) : searchQuery || extruderFilter !== 'all' || flowTypeFilter !== 'all' ? (
<Card>
<CardContent className="py-12 text-center">
<Search className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No Matching Profiles</h3>
<p className="text-bambu-gray">
No profiles match your search criteria
</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="py-12 text-center">
<Gauge className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No K-Profiles</h3>
<p className="text-bambu-gray mb-4">
No pressure advance profiles found for {nozzleDiameter}mm nozzle
</p>
<Button onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4" />
Create First Profile
</Button>
</CardContent>
</Card>
)}
{/* Edit Modal */}
{editingProfile && selectedPrinter && (
<KProfileModal
profile={editingProfile}
printerId={selectedPrinter}
nozzleDiameter={nozzleDiameter}
existingProfiles={kprofiles?.profiles}
isDualNozzle={isDualNozzle}
onClose={() => setEditingProfile(null)}
onSave={() => setEditingProfile(null)}
/>
)}
{/* Add Modal */}
{showAddModal && selectedPrinter && (
<KProfileModal
printerId={selectedPrinter}
nozzleDiameter={nozzleDiameter}
existingProfiles={kprofiles?.profiles}
isDualNozzle={isDualNozzle}
onClose={() => setShowAddModal(false)}
onSave={() => setShowAddModal(false)}
/>
)}
</>
);
}
@@ -1,29 +1,46 @@
import { useEffect } from 'react';
import { X, Keyboard } from 'lucide-react';
import { Card, CardContent } from './Card';
interface KeyboardShortcutsModalProps {
onClose: () => void;
interface NavItem {
id: string;
to: string;
label: string;
}
const shortcuts = [
{ category: 'Navigation', items: [
{ keys: ['1'], description: 'Go to Printers' },
{ keys: ['2'], description: 'Go to Archives' },
{ keys: ['3'], description: 'Go to Queue' },
{ keys: ['4'], description: 'Go to Statistics' },
{ keys: ['5'], description: 'Go to Cloud Profiles' },
{ keys: ['6'], description: 'Go to Settings' },
]},
{ category: 'Archives', items: [
{ keys: ['/'], description: 'Focus search' },
{ keys: ['U'], description: 'Open upload modal' },
{ keys: ['Esc'], description: 'Clear selection / blur input' },
{ keys: ['Right-click'], description: 'Context menu on cards' },
]},
{ category: 'General', items: [
{ keys: ['?'], description: 'Show this help' },
]},
];
interface KeyboardShortcutsModalProps {
onClose: () => void;
navItems?: NavItem[];
}
function getShortcuts(navItems?: NavItem[]) {
const navShortcuts = navItems
? navItems.map((item, index) => ({
keys: [String(index + 1)],
description: `Go to ${item.label}`,
}))
: [
{ keys: ['1'], description: 'Go to Printers' },
{ keys: ['2'], description: 'Go to Archives' },
{ keys: ['3'], description: 'Go to Queue' },
{ keys: ['4'], description: 'Go to Statistics' },
{ keys: ['5'], description: 'Go to Cloud Profiles' },
{ keys: ['6'], description: 'Go to Settings' },
];
return [
{ category: 'Navigation', items: navShortcuts },
{ category: 'Archives', items: [
{ keys: ['/'], description: 'Focus search' },
{ keys: ['U'], description: 'Open upload modal' },
{ keys: ['Esc'], description: 'Clear selection / blur input' },
{ keys: ['Right-click'], description: 'Context menu on cards' },
]},
{ category: 'General', items: [
{ keys: ['?'], description: 'Show this help' },
]},
];
}
function KeyBadge({ children }: { children: string }) {
return (
@@ -33,7 +50,18 @@ function KeyBadge({ children }: { children: string }) {
);
}
export function KeyboardShortcutsModal({ onClose }: KeyboardShortcutsModalProps) {
export function KeyboardShortcutsModal({ onClose, navItems }: KeyboardShortcutsModalProps) {
const shortcuts = getShortcuts(navItems);
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-md" onClick={(e) => e.stopPropagation()}>
+147 -41
View File
@@ -1,31 +1,136 @@
import { useState, useEffect, useCallback } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github } from 'lucide-react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, type LucideIcon } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
const navItems = [
{ to: '/', icon: Printer, label: 'Printers' },
{ to: '/archives', icon: Archive, label: 'Archives' },
{ to: '/queue', icon: Calendar, label: 'Queue' },
{ to: '/stats', icon: BarChart3, label: 'Statistics' },
{ to: '/cloud', icon: Cloud, label: 'Cloud Profiles' },
{ to: '/settings', icon: Settings, label: 'Settings' },
interface NavItem {
id: string;
to: string;
icon: LucideIcon;
label: string;
}
export const defaultNavItems: NavItem[] = [
{ id: 'printers', to: '/', icon: Printer, label: 'Printers' },
{ id: 'archives', to: '/archives', icon: Archive, label: 'Archives' },
{ id: 'queue', to: '/queue', icon: Calendar, label: 'Queue' },
{ id: 'stats', to: '/stats', icon: BarChart3, label: 'Statistics' },
{ id: 'profiles', to: '/profiles', icon: Cloud, label: 'Profiles' },
{ id: 'settings', to: '/settings', icon: Settings, label: 'Settings' },
];
// Get ordered nav items from localStorage
function getOrderedNavItems(): NavItem[] {
const stored = localStorage.getItem('sidebarOrder');
if (stored) {
try {
const order: string[] = JSON.parse(stored);
const itemMap = new Map(defaultNavItems.map(item => [item.id, item]));
const ordered: NavItem[] = [];
for (const id of order) {
const item = itemMap.get(id);
if (item) {
ordered.push(item);
itemMap.delete(id);
}
}
// Add any new items that weren't in the stored order
for (const item of itemMap.values()) {
ordered.push(item);
}
return ordered;
} catch {
return defaultNavItems;
}
}
return defaultNavItems;
}
// Save nav item order to localStorage
function saveNavOrder(items: NavItem[]) {
localStorage.setItem('sidebarOrder', JSON.stringify(items.map(i => i.id)));
}
// Get default view from localStorage
export function getDefaultView(): string {
return localStorage.getItem('defaultView') || '/';
}
// Save default view to localStorage
export function setDefaultView(path: string) {
localStorage.setItem('defaultView', path);
}
export function Layout() {
const navigate = useNavigate();
const location = useLocation();
const { theme, toggleTheme } = useTheme();
const [sidebarExpanded, setSidebarExpanded] = useState(() => {
const stored = localStorage.getItem('sidebarExpanded');
return stored !== 'false';
});
const [showShortcuts, setShowShortcuts] = useState(false);
const [navItems, setNavItems] = useState<NavItem[]>(getOrderedNavItems);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const hasRedirected = useRef(false);
// Redirect to default view on initial load
useEffect(() => {
if (!hasRedirected.current && location.pathname === '/') {
const defaultView = getDefaultView();
if (defaultView !== '/') {
hasRedirected.current = true;
navigate(defaultView, { replace: true });
}
}
}, [location.pathname, navigate]);
useEffect(() => {
localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
}, [sidebarExpanded]);
// Drag and drop handlers
const handleDragStart = (e: React.DragEvent, index: number) => {
setDraggedIndex(index);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(index));
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverIndex(index);
};
const handleDragLeave = () => {
setDragOverIndex(null);
};
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === dropIndex) {
setDraggedIndex(null);
setDragOverIndex(null);
return;
}
const newItems = [...navItems];
const [draggedItem] = newItems.splice(draggedIndex, 1);
newItems.splice(dropIndex, 0, draggedItem);
setNavItems(newItems);
saveNavOrder(newItems);
setDraggedIndex(null);
setDragOverIndex(null);
};
const handleDragEnd = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
// Global keyboard shortcuts for navigation
const handleKeyDown = useCallback((e: KeyboardEvent) => {
const target = e.target as HTMLElement;
@@ -34,33 +139,16 @@ export function Layout() {
return;
}
// Number keys for navigation (1-4)
// Number keys for navigation (1-6) - follows sidebar order
if (!e.metaKey && !e.ctrlKey && !e.altKey) {
const keyNum = parseInt(e.key);
if (keyNum >= 1 && keyNum <= navItems.length) {
e.preventDefault();
navigate(navItems[keyNum - 1].to);
return;
}
switch (e.key) {
case '1':
e.preventDefault();
navigate('/');
break;
case '2':
e.preventDefault();
navigate('/archives');
break;
case '3':
e.preventDefault();
navigate('/queue');
break;
case '4':
e.preventDefault();
navigate('/stats');
break;
case '5':
e.preventDefault();
navigate('/cloud');
break;
case '6':
e.preventDefault();
navigate('/settings');
break;
case '?':
e.preventDefault();
setShowShortcuts(true);
@@ -70,7 +158,7 @@ export function Layout() {
break;
}
}
}, [navigate]);
}, [navigate, navItems]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
@@ -95,12 +183,27 @@ export function Layout() {
{/* Navigation */}
<nav className="flex-1 p-2">
<ul className="space-y-2">
{navItems.map(({ to, icon: Icon, label }) => (
<li key={to}>
{navItems.map(({ id, to, icon: Icon, label }, index) => (
<li
key={id}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
className={`relative ${
draggedIndex === index ? 'opacity-50' : ''
} ${
dragOverIndex === index && draggedIndex !== index
? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
: ''
}`}
>
<NavLink
to={to}
className={({ isActive }) =>
`flex items-center ${sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors ${
`flex items-center ${sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
isActive
? 'bg-bambu-green text-white'
: 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
@@ -108,6 +211,9 @@ export function Layout() {
}
title={!sidebarExpanded ? label : undefined}
>
{sidebarExpanded && (
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
)}
<Icon className="w-5 h-5 flex-shrink-0" />
{sidebarExpanded && <span>{label}</span>}
</NavLink>
@@ -133,7 +239,7 @@ export function Layout() {
<div className="p-2 border-t border-bambu-dark-tertiary">
{sidebarExpanded ? (
<div className="flex items-center justify-between px-2">
<span className="text-sm text-bambu-gray">v0.1.2</span>
<span className="text-sm text-bambu-gray">v0.1.3</span>
<div className="flex items-center gap-1">
<a
href="https://github.com/maziggy/bambusy"
@@ -196,7 +302,7 @@ export function Layout() {
</main>
{/* Keyboard Shortcuts Modal */}
{showShortcuts && <KeyboardShortcutsModal onClose={() => setShowShortcuts(false)} />}
{showShortcuts && <KeyboardShortcutsModal onClose={() => setShowShortcuts(false)} navItems={navItems} />}
</div>
);
}
@@ -43,6 +43,15 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
},
});
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
// Auto-scroll to bottom when new logs arrive
useEffect(() => {
if (autoScroll && logContainerRef.current) {
@@ -0,0 +1,408 @@
import { useState } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { Bell, Trash2, Settings2, Edit2, Send, Loader2, CheckCircle, XCircle, Moon, Clock, ChevronDown, ChevronUp } from 'lucide-react';
import { api } from '../api/client';
import type { NotificationProvider, NotificationProviderUpdate } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { ConfirmModal } from './ConfirmModal';
interface NotificationProviderCardProps {
provider: NotificationProvider;
onEdit: (provider: NotificationProvider) => void;
}
const PROVIDER_LABELS: Record<string, string> = {
callmebot: 'CallMeBot/WhatsApp',
ntfy: 'ntfy',
pushover: 'Pushover',
telegram: 'Telegram',
email: 'Email',
};
export function NotificationProviderCard({ provider, onEdit }: NotificationProviderCardProps) {
const queryClient = useQueryClient();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
// Fetch printers for linking
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
const linkedPrinter = printers?.find(p => p.id === provider.printer_id);
// Update mutation
const updateMutation = useMutation({
mutationFn: (data: NotificationProviderUpdate) => api.updateNotificationProvider(provider.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
},
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: () => api.deleteNotificationProvider(provider.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
},
});
// Test mutation
const testMutation = useMutation({
mutationFn: () => api.testNotificationProvider(provider.id),
onSuccess: (result) => {
setTestResult(result);
queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
},
onError: (err: Error) => {
setTestResult({ success: false, message: err.message });
},
});
// Format time for display
const formatTime = (time: string | null) => {
if (!time) return '';
return time;
};
return (
<>
<Card className="relative">
<CardContent className="p-4">
{/* Header Row */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${provider.enabled ? 'bg-bambu-green/20' : 'bg-bambu-dark'}`}>
<Bell className={`w-5 h-5 ${provider.enabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
</div>
<div>
<h3 className="font-medium text-white">{provider.name}</h3>
<p className="text-sm text-bambu-gray">{PROVIDER_LABELS[provider.provider_type] || provider.provider_type}</p>
</div>
</div>
{/* Status indicator */}
<div className="flex items-center gap-2">
{provider.last_success && (
<span className="text-xs text-bambu-green">Last sent: {new Date(provider.last_success).toLocaleDateString()}</span>
)}
{provider.last_error && (
<span className="text-xs text-red-400" title={provider.last_error}>Error</span>
)}
</div>
</div>
{/* Linked Printer */}
{linkedPrinter && (
<div className="mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg">
<span className="text-xs text-bambu-gray">Printer: </span>
<span className="text-sm text-white">{linkedPrinter.name}</span>
</div>
)}
{!linkedPrinter && !provider.printer_id && (
<div className="mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg">
<span className="text-xs text-bambu-gray">All printers</span>
</div>
)}
{/* Event summary - show all event tags */}
<div className="mb-3 flex flex-wrap gap-1">
{provider.on_print_start && (
<span className="px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded">Start</span>
)}
{provider.on_print_complete && (
<span className="px-2 py-0.5 bg-bambu-green/20 text-bambu-green text-xs rounded">Complete</span>
)}
{provider.on_print_failed && (
<span className="px-2 py-0.5 bg-red-500/20 text-red-400 text-xs rounded">Failed</span>
)}
{provider.on_print_stopped && (
<span className="px-2 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded">Stopped</span>
)}
{provider.on_print_progress && (
<span className="px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded">Progress</span>
)}
{provider.on_printer_offline && (
<span className="px-2 py-0.5 bg-gray-500/20 text-gray-400 text-xs rounded">Offline</span>
)}
{provider.on_printer_error && (
<span className="px-2 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded">Error</span>
)}
{provider.on_filament_low && (
<span className="px-2 py-0.5 bg-amber-500/20 text-amber-400 text-xs rounded">Low Filament</span>
)}
{provider.quiet_hours_enabled && (
<span className="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded flex items-center gap-1">
<Moon className="w-3 h-3" />
Quiet
</span>
)}
</div>
{/* Test Button */}
<div className="mb-3">
<Button
size="sm"
variant="secondary"
disabled={testMutation.isPending}
onClick={() => {
setTestResult(null);
testMutation.mutate();
}}
className="w-full"
>
{testMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
Send Test Notification
</Button>
</div>
{/* Test Result */}
{testResult && (
<div className={`mb-3 p-2 rounded-lg flex items-center gap-2 text-sm ${
testResult.success
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-red-500/20 text-red-400'
}`}>
{testResult.success ? (
<CheckCircle className="w-4 h-4" />
) : (
<XCircle className="w-4 h-4" />
)}
<span>{testResult.message}</span>
</div>
)}
{/* Toggle Settings Panel */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between py-2 text-sm text-bambu-gray hover:text-white transition-colors border-t border-bambu-dark-tertiary"
>
<span className="flex items-center gap-2">
<Settings2 className="w-4 h-4" />
Event Settings
</span>
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{/* Expanded Settings */}
{isExpanded && (
<div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
{/* Enabled Toggle */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Enabled</p>
<p className="text-xs text-bambu-gray">Send notifications from this provider</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.enabled}
onChange={(e) => updateMutation.mutate({ enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
{/* Print Lifecycle Events */}
<div className="space-y-2">
<p className="text-xs text-bambu-gray uppercase tracking-wide">Print Events</p>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Print Started</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_print_start}
onChange={(e) => updateMutation.mutate({ on_print_start: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Print Completed</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_print_complete}
onChange={(e) => updateMutation.mutate({ on_print_complete: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Print Failed</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_print_failed}
onChange={(e) => updateMutation.mutate({ on_print_failed: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Print Stopped</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_print_stopped}
onChange={(e) => updateMutation.mutate({ on_print_stopped: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Progress Milestones</p>
<p className="text-xs text-bambu-gray">Notify at 25%, 50%, 75%</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_print_progress}
onChange={(e) => updateMutation.mutate({ on_print_progress: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
</div>
{/* Printer Status Events */}
<div className="space-y-2">
<p className="text-xs text-bambu-gray uppercase tracking-wide">Printer Status</p>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Printer Offline</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_printer_offline}
onChange={(e) => updateMutation.mutate({ on_printer_offline: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Printer Error</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_printer_error}
onChange={(e) => updateMutation.mutate({ on_printer_error: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Low Filament</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_filament_low}
onChange={(e) => updateMutation.mutate({ on_filament_low: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
</div>
{/* Quiet Hours */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Moon className="w-4 h-4 text-purple-400" />
<p className="text-sm text-white">Quiet Hours</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.quiet_hours_enabled}
onChange={(e) => updateMutation.mutate({ quiet_hours_enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
{provider.quiet_hours_enabled && (
<div className="pl-4 border-l-2 border-bambu-dark-tertiary space-y-2">
<p className="text-xs text-bambu-gray">No notifications during these hours</p>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-bambu-gray" />
<span className="text-sm text-white">
{formatTime(provider.quiet_hours_start) || '22:00'} - {formatTime(provider.quiet_hours_end) || '07:00'}
</span>
</div>
<p className="text-xs text-bambu-gray">Edit provider to change quiet hours</p>
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex gap-2 pt-2">
<Button
size="sm"
variant="secondary"
onClick={() => onEdit(provider)}
className="flex-1"
>
<Edit2 className="w-4 h-4" />
Edit
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => setShowDeleteConfirm(true)}
className="text-red-400 hover:text-red-300"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Delete Confirmation */}
{showDeleteConfirm && (
<ConfirmModal
title="Delete Notification Provider"
message={`Are you sure you want to delete "${provider.name}"? This cannot be undone.`}
confirmText="Delete"
variant="danger"
onConfirm={() => {
deleteMutation.mutate();
setShowDeleteConfirm(false);
}}
onCancel={() => setShowDeleteConfirm(false)}
/>
)}
</>
);
}
+10
View File
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { X, Download } from 'lucide-react';
import { Button } from './Button';
import { api } from '../api/client';
@@ -11,6 +12,15 @@ interface QRCodeModalProps {
export function QRCodeModal({ archiveId, archiveName, onClose }: QRCodeModalProps) {
const qrCodeUrl = api.getArchiveQRCodeUrl(archiveId, 300);
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const handleDownload = () => {
const link = document.createElement('a');
link.href = qrCodeUrl;
+10 -1
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { X, Printer, Loader2 } from 'lucide-react';
import { api } from '../api/client';
@@ -15,6 +15,15 @@ interface ReprintModalProps {
export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: ReprintModalProps) {
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const { data: printers, isLoading: loadingPrinters } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
+5 -1
View File
@@ -47,6 +47,10 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
// Also invalidate printer-specific smart plug queries to keep PrintersPage in sync
if (plug.printer_id) {
queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', plug.printer_id] });
}
},
});
@@ -181,7 +185,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Auto Off</p>
<p className="text-xs text-bambu-gray">Turn off when print completes</p>
<p className="text-xs text-bambu-gray">Turn off when print completes (one-shot)</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
@@ -25,6 +25,17 @@ export function TimelapseViewer({ src, title, downloadFilename, onClose }: Timel
}
}, [playbackRate]);
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
+10 -1
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import { api } from '../api/client';
@@ -30,6 +30,15 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
const [selectedPrinter, setSelectedPrinter] = useState<number | undefined>();
const [uploadResult, setUploadResult] = useState<BulkUploadResult | null>(null);
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
+75
View File
@@ -97,6 +97,8 @@ function ArchiveCard({
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showEdit, setShowEdit] = useState(false);
const [showTimelapse, setShowTimelapse] = useState(false);
const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
const [availableTimelapses, setAvailableTimelapses] = useState<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);
const [showQRCode, setShowQRCode] = useState(false);
const [showPhotos, setShowPhotos] = useState(false);
const [showProjectPage, setShowProjectPage] = useState(false);
@@ -111,6 +113,10 @@ function ArchiveCard({
showToast(`Timelapse attached: ${data.filename}`);
} else if (data.status === 'exists') {
showToast('Timelapse already attached');
} else if (data.status === 'not_found' && data.available_files && data.available_files.length > 0) {
// Show selection dialog
setAvailableTimelapses(data.available_files);
setShowTimelapseSelect(true);
} else {
showToast(data.message || 'No matching timelapse found', 'warning');
}
@@ -120,6 +126,19 @@ function ArchiveCard({
},
});
const timelapseSelectMutation = useMutation({
mutationFn: (filename: string) => api.selectArchiveTimelapse(archive.id, filename),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['archives'] });
showToast(`Timelapse attached: ${data.filename}`);
setShowTimelapseSelect(false);
setAvailableTimelapses([]);
},
onError: (error: Error) => {
showToast(error.message || 'Failed to attach timelapse', 'error');
},
});
const deleteMutation = useMutation({
mutationFn: () => api.deleteArchive(archive.id),
onSuccess: () => {
@@ -584,6 +603,62 @@ function ArchiveCard({
/>
)}
{/* Timelapse Selection Modal */}
{showTimelapseSelect && availableTimelapses.length > 0 && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
<div className="bg-card-dark rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<div>
<h3 className="text-lg font-semibold text-white">Select Timelapse</h3>
<p className="text-sm text-gray-400 mt-1">
No auto-match found. Select the timelapse for this print:
</p>
</div>
<button
onClick={() => {
setShowTimelapseSelect(false);
setAvailableTimelapses([]);
}}
className="text-gray-400 hover:text-white p-1"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="overflow-y-auto flex-1 p-2">
{availableTimelapses.map((file) => (
<button
key={file.name}
onClick={() => timelapseSelectMutation.mutate(file.name)}
disabled={timelapseSelectMutation.isPending}
className="w-full text-left p-3 rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-3 disabled:opacity-50"
>
<Film className="w-8 h-8 text-bambu-green flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">{file.name}</p>
<p className="text-sm text-gray-400">
{formatFileSize(file.size)}
{file.mtime && ` • ${formatDate(file.mtime)}`}
</p>
</div>
</button>
))}
</div>
<div className="p-4 border-t border-gray-700">
<Button
variant="secondary"
onClick={() => {
setShowTimelapseSelect(false);
setAvailableTimelapses([]);
}}
className="w-full"
>
Cancel
</Button>
</div>
</div>
</div>
)}
{/* QR Code Modal */}
{showQRCode && (
<QRCodeModal
+11 -5
View File
@@ -143,6 +143,8 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
smartPlug ? api.updateSmartPlug(smartPlug.id, { auto_off: enabled }) : Promise.reject('No plug'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', printer.id] });
// Also invalidate the smart-plugs list to keep Settings page in sync
queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
},
});
@@ -413,18 +415,22 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
{/* Auto-off toggle */}
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-xs text-bambu-gray hidden sm:inline">Auto-off</span>
<span className={`text-xs hidden sm:inline ${smartPlug.auto_off_executed ? 'text-bambu-green' : 'text-bambu-gray'}`}>
{smartPlug.auto_off_executed ? 'Auto-off done' : 'Auto-off'}
</span>
<button
onClick={() => toggleAutoOffMutation.mutate(!smartPlug.auto_off)}
disabled={toggleAutoOffMutation.isPending}
title="Auto power-off after print"
disabled={toggleAutoOffMutation.isPending || smartPlug.auto_off_executed}
title={smartPlug.auto_off_executed ? 'Auto-off was executed - turn printer on to reset' : 'Auto power-off after print'}
className={`relative w-9 h-5 rounded-full transition-colors flex-shrink-0 ${
smartPlug.auto_off ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
smartPlug.auto_off_executed
? 'bg-bambu-green/50 cursor-not-allowed'
: smartPlug.auto_off ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
}`}
>
<span
className={`absolute top-[2px] left-[2px] w-4 h-4 bg-white rounded-full transition-transform ${
smartPlug.auto_off ? 'translate-x-4' : 'translate-x-0'
smartPlug.auto_off || smartPlug.auto_off_executed ? 'translate-x-4' : 'translate-x-0'
}`}
/>
</button>
@@ -13,12 +13,16 @@ import {
X,
Key,
RefreshCw,
Gauge,
} from 'lucide-react';
import { api } from '../api/client';
import type { SlicerSetting, SlicerSettingsResponse } from '../api/client';
import { Card, CardContent, CardHeader } from '../components/Card';
import { Button } from '../components/Button';
import { useToast } from '../contexts/ToastContext';
import { KProfilesView } from '../components/KProfilesView';
type ProfileTab = 'cloud' | 'kprofiles';
type LoginStep = 'email' | 'code' | 'token';
@@ -398,9 +402,10 @@ function ProfilesView({ settings }: { settings: SlicerSettingsResponse }) {
);
}
export function CloudProfilesPage() {
export function ProfilesPage() {
const queryClient = useQueryClient();
const { showToast } = useToast();
const [activeTab, setActiveTab] = useState<ProfileTab>('kprofiles');
const { data: status, isLoading: statusLoading } = useQuery({
queryKey: ['cloudStatus'],
@@ -438,58 +443,95 @@ export function CloudProfilesPage() {
return (
<div className="p-8">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Cloud className="w-6 h-6 text-bambu-green" />
Cloud Profiles
</h1>
<p className="text-bambu-gray">
{status?.is_authenticated
? `Connected as ${status.email}`
: 'Manage your Bambu Cloud slicer presets'}
</p>
</div>
{status?.is_authenticated && (
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => refetchSettings()}
disabled={settingsLoading}
>
<RefreshCw className={`w-4 h-4 ${settingsLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button
variant="secondary"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
>
<LogOut className="w-4 h-4" />
Logout
</Button>
</div>
)}
{/* Page Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Profiles</h1>
<p className="text-bambu-gray">
Manage your slicer presets and pressure advance calibrations
</p>
</div>
{!status?.is_authenticated ? (
<LoginForm onSuccess={handleLoginSuccess} />
) : settingsLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
) : settings ? (
<ProfilesView settings={settings} />
) : (
<Card>
<CardContent className="py-8 text-center">
<p className="text-bambu-gray">Failed to load profiles</p>
<Button className="mt-4" onClick={() => refetchSettings()}>
Retry
</Button>
</CardContent>
</Card>
{/* Tab Navigation */}
<div className="flex border-b border-bambu-dark-tertiary mb-6">
<button
onClick={() => setActiveTab('cloud')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === 'cloud'
? 'text-bambu-green border-bambu-green'
: 'text-bambu-gray hover:text-white border-transparent'
}`}
>
<Cloud className="w-4 h-4" />
Cloud Profiles
</button>
<button
onClick={() => setActiveTab('kprofiles')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === 'kprofiles'
? 'text-bambu-green border-bambu-green'
: 'text-bambu-gray hover:text-white border-transparent'
}`}
>
<Gauge className="w-4 h-4" />
K-Profiles
</button>
</div>
{/* Cloud Profiles Tab */}
{activeTab === 'cloud' && (
<>
{/* Cloud Status Header */}
<div className="mb-6 flex items-center justify-between">
<p className="text-bambu-gray">
{status?.is_authenticated
? `Connected as ${status.email}`
: 'Connect to Bambu Cloud to access your slicer presets'}
</p>
{status?.is_authenticated && (
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => refetchSettings()}
disabled={settingsLoading}
>
<RefreshCw className={`w-4 h-4 ${settingsLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button
variant="secondary"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
>
<LogOut className="w-4 h-4" />
Logout
</Button>
</div>
)}
</div>
{!status?.is_authenticated ? (
<LoginForm onSuccess={handleLoginSuccess} />
) : settingsLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
) : settings ? (
<ProfilesView settings={settings} />
) : (
<Card>
<CardContent className="py-8 text-center">
<p className="text-bambu-gray">Failed to load profiles</p>
<Button className="mt-4" onClick={() => refetchSettings()}>
Retry
</Button>
</CardContent>
</Card>
)}
</>
)}
{/* K-Profiles Tab */}
{activeTab === 'kprofiles' && <KProfilesView />}
</div>
);
}
+151 -5
View File
@@ -1,11 +1,14 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Save, Loader2, Check, Plus, Plug, AlertTriangle } from 'lucide-react';
import { Save, Loader2, Check, Plus, Plug, AlertTriangle, RotateCcw, Bell } from 'lucide-react';
import { api } from '../api/client';
import type { AppSettings, SmartPlug } from '../api/client';
import type { AppSettings, SmartPlug, NotificationProvider } from '../api/client';
import { Card, CardContent, CardHeader } from '../components/Card';
import { Button } from '../components/Button';
import { SmartPlugCard } from '../components/SmartPlugCard';
import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
import { NotificationProviderCard } from '../components/NotificationProviderCard';
import { AddNotificationModal } from '../components/AddNotificationModal';
import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
import { useState, useEffect } from 'react';
export function SettingsPage() {
@@ -15,6 +18,19 @@ export function SettingsPage() {
const [showSaved, setShowSaved] = useState(false);
const [showPlugModal, setShowPlugModal] = useState(false);
const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
const [showNotificationModal, setShowNotificationModal] = useState(false);
const [editingProvider, setEditingProvider] = useState<NotificationProvider | null>(null);
const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
const handleDefaultViewChange = (path: string) => {
setDefaultViewState(path);
setDefaultView(path);
};
const handleResetSidebarOrder = () => {
localStorage.removeItem('sidebarOrder');
window.location.reload();
};
const { data: settings, isLoading } = useQuery({
queryKey: ['settings'],
@@ -26,6 +42,11 @@ export function SettingsPage() {
queryFn: api.getSmartPlugs,
});
const { data: notificationProviders, isLoading: providersLoading } = useQuery({
queryKey: ['notification-providers'],
queryFn: api.getNotificationProviders,
});
const { data: ffmpegStatus } = useQuery({
queryKey: ['ffmpeg-status'],
queryFn: api.checkFfmpeg,
@@ -47,7 +68,8 @@ export function SettingsPage() {
settings.capture_finish_photo !== localSettings.capture_finish_photo ||
settings.default_filament_cost !== localSettings.default_filament_cost ||
settings.currency !== localSettings.currency ||
settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh;
settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
settings.energy_tracking_mode !== localSettings.energy_tracking_mode;
setHasChanges(changed);
}
}, [settings, localSettings]);
@@ -60,6 +82,8 @@ export function SettingsPage() {
setHasChanges(false);
setShowSaved(true);
setTimeout(() => setShowSaved(false), 2000);
// Invalidate archive stats to reflect energy tracking mode change
queryClient.invalidateQueries({ queryKey: ['archiveStats'] });
},
});
@@ -237,13 +261,71 @@ export function SettingsPage() {
}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">
Energy display mode
</label>
<select
value={localSettings.energy_tracking_mode || 'total'}
onChange={(e) => updateSetting('energy_tracking_mode', e.target.value as 'print' | 'total')}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="print">Prints Only</option>
<option value="total">Total Consumption</option>
</select>
<p className="text-xs text-bambu-gray mt-1">
Used for tracking energy costs per print via smart plugs
{localSettings.energy_tracking_mode === 'print'
? 'Dashboard shows sum of energy used during prints'
: 'Dashboard shows lifetime energy from smart plugs'}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">Interface</h2>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm text-bambu-gray mb-1">
Default view on startup
</label>
<select
value={defaultView}
onChange={(e) => handleDefaultViewChange(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
{defaultNavItems.map((item) => (
<option key={item.id} value={item.to}>
{item.label}
</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
Page to show when opening the app
</p>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-white">Sidebar order</p>
<p className="text-sm text-bambu-gray">
Drag items in the sidebar to reorder. Reset to default order here.
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleResetSidebarOrder}
>
<RotateCcw className="w-4 h-4" />
Reset
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">About</h2>
@@ -262,7 +344,7 @@ export function SettingsPage() {
</Card>
</div>
{/* Right Column - Smart Plugs */}
{/* Middle Column - Smart Plugs */}
<div className="w-96 flex-shrink-0">
<Card>
<CardHeader>
@@ -314,6 +396,59 @@ export function SettingsPage() {
</CardContent>
</Card>
</div>
{/* Right Column - Notifications */}
<div className="w-96 flex-shrink-0">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Notifications</h2>
</div>
<Button
size="sm"
onClick={() => {
setEditingProvider(null);
setShowNotificationModal(true);
}}
>
<Plus className="w-4 h-4" />
Add
</Button>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-bambu-gray mb-4">
Get notified about print events via WhatsApp, Telegram, Email, and more.
</p>
{providersLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
) : notificationProviders && notificationProviders.length > 0 ? (
<div className="space-y-4">
{notificationProviders.map((provider) => (
<NotificationProviderCard
key={provider.id}
provider={provider}
onEdit={(p) => {
setEditingProvider(p);
setShowNotificationModal(true);
}}
/>
))}
</div>
) : (
<div className="text-center py-8 text-bambu-gray">
<Bell className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>No notification providers configured</p>
<p className="text-sm mt-1">Add a provider to get started</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Smart Plug Modal */}
@@ -326,6 +461,17 @@ export function SettingsPage() {
}}
/>
)}
{/* Notification Modal */}
{showNotificationModal && (
<AddNotificationModal
provider={editingProvider}
onClose={() => {
setShowNotificationModal(false);
setEditingProvider(null);
}}
/>
)}
</div>
);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
<script type="module" crossorigin src="/assets/index-BS0vyekL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CJ8fGWbx.css">
<script type="module" crossorigin src="/assets/index-C2974G18.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJNRCg8M.css">
</head>
<body>
<div id="root"></div>