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
@@ -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
|
||||
@@ -41,3 +41,4 @@ archive/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
Before Width: | Height: | Size: 647 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 526 KiB After Width: | Height: | Size: 746 KiB |
|
Before Width: | Height: | Size: 636 KiB |
|
After Width: | Height: | Size: 800 KiB |
|
Before Width: | Height: | Size: 359 KiB After Width: | Height: | Size: 377 KiB |
|
Before Width: | Height: | Size: 349 KiB After Width: | Height: | Size: 337 KiB |
|
Before Width: | Height: | Size: 546 KiB After Width: | Height: | Size: 587 KiB |
|
After Width: | Height: | Size: 646 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||