Merge branch '0.1.6-final' into feature/auth_details

This commit is contained in:
MartinNYHC
2026-01-31 15:15:39 +01:00
committed by GitHub
40 changed files with 22570 additions and 298 deletions
+19 -18
View File
@@ -21,6 +21,25 @@ All notable changes to Bambuddy will be documented in this file.
- Uses trimesh and matplotlib for 3D rendering with Bambu green color theme
- Thumbnails auto-refresh in UI after generation
- Graceful handling of complex/invalid STL files
- **Streaming Overlay for OBS** - Embeddable overlay page for live streaming with camera and print status (Issue #164):
- All-in-one page at `/overlay/:printerId` combining camera feed with status overlay
- Real-time print progress, ETA, layer count, and filename display
- Bambuddy logo branding (links to GitHub)
- Customizable via query parameters: `?size=small|medium|large` and `?show=progress,layers,eta,filename,status,printer`
- No authentication required - designed for OBS browser source embedding
- Gradient overlay at bottom for readable text over camera feed
- Auto-reconnect on camera stream errors
- **MQTT Smart Plug Support** - Add smart plugs that subscribe to MQTT topics for energy monitoring (Issue #173):
- New "MQTT" plug type alongside Tasmota and Home Assistant
- Subscribe to any MQTT topic (Zigbee2MQTT, Shelly, Tasmota discovery, etc.)
- **Separate topics per data type**: Configure different MQTT topics for power, energy, and state
- Configurable JSON paths for data extraction (e.g., `power_l1`, `data.power`)
- **Separate multipliers**: Individual multiplier for power and energy (e.g., mW→W, Wh→kWh)
- **Custom ON value**: Configure what value means "ON" for state (e.g., "ON", "true", "1")
- Monitor-only: displays power/energy data without control capabilities
- Reuses existing MQTT broker settings from Settings → Network
- Energy data included in statistics and per-print tracking
- Full backup/restore support for MQTT plug configurations
- **Disable Printer Firmware Checks** - New toggle in Settings → General → Updates to disable printer firmware update checks:
- Prevents Bambuddy from checking Bambu Lab servers for firmware updates
- Useful for users who prefer to manage firmware manually or have network restrictions
@@ -106,24 +125,6 @@ All notable changes to Bambuddy will be documented in this file.
- Bulk edit: printer assignment, print options, queue options
- Bulk cancel selected items
- Tri-state toggles: unchanged / on / off for each setting
- **Model-Based Queue Assignment** - Queue prints to "any printer of matching model" for load balancing (Issue #162):
- Extract printer model from sliced 3MF files (e.g., "X1C", "P1S")
- Display sliced-for model in archive view
- New queue mode: assign to model instead of specific printer
- Scheduler auto-assigns to first idle printer of matching model
- Filament validation: only assign to printers with required filament types loaded
- Waiting reason display: shows why jobs are waiting (e.g., "Waiting for filament: Printer1 (needs PLA)")
- "Waiting" status badge (purple) distinguishes from regular "Pending"
- Compatibility warnings when file/printer model mismatch
- **Queue Notifications** - Get notified about print queue events:
- Job Added: When a job is added to the queue
- Job Assigned: When a model-based job is assigned to a printer
- Job Started: When a queue job starts printing
- Job Waiting: When a job is waiting for filament (enabled by default)
- Job Skipped: When a job is skipped due to previous failure (enabled by default)
- Job Failed: When a job fails to start (enabled by default)
- Queue Complete: When all queued jobs finish
- New "Print Queue" section in notification provider settings
### Fixes
- **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166):
+4 -1
View File
@@ -54,10 +54,12 @@
- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support)
- Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
- Archive comparison (side-by-side diff)
- Tag management (rename/delete across all archives)
### 📊 Monitoring & Control
- Real-time printer status via WebSocket
- Live camera streaming (MJPEG) & snapshots with multi-viewer support
- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`)
- External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
- **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
- Fan status monitoring (part cooling, auxiliary, chamber)
@@ -80,7 +82,8 @@
- Per-printer AMS mapping (individual slot configuration for print farms)
- Scheduled prints (date/time)
- Queue Only mode (stage without auto-start)
- Smart plug integration (Tasmota, Home Assistant)
- Smart plug integration (Tasmota, Home Assistant, MQTT)
- MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
- Energy consumption tracking (per-print kWh and cost)
- HA energy sensor support (for plugs with separate power/energy sensors)
- Auto power-on before print
+112 -3
View File
@@ -515,6 +515,8 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
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.homeassistant import homeassistant_service
from backend.app.services.mqtt_relay import mqtt_relay
from backend.app.services.tasmota import tasmota_service
plugs_result = await db.execute(select(SmartPlug))
@@ -522,9 +524,19 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
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"]
if plug.plug_type == "tasmota":
energy = await tasmota_service.get_energy(plug)
if energy and energy.get("total") is not None:
total_energy_kwh += energy["total"]
elif plug.plug_type == "homeassistant":
energy = await homeassistant_service.get_energy(plug)
if energy and energy.get("total") is not None:
total_energy_kwh += energy["total"]
elif plug.plug_type == "mqtt":
# MQTT plugs report "today" energy, not lifetime total
mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)
if mqtt_data and mqtt_data.energy is not None:
total_energy_kwh += mqtt_data.energy
total_energy_kwh = round(total_energy_kwh, 3)
total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 2)
@@ -552,6 +564,103 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
)
@router.get("/tags")
async def get_all_tags(db: AsyncSession = Depends(get_db)):
"""List all unique tags with usage counts.
Returns a list of tags sorted by count (descending), then by name.
"""
# Query all archives with non-null tags
result = await db.execute(select(PrintArchive.tags).where(PrintArchive.tags.isnot(None)))
all_tags_rows = result.all()
# Count occurrences of each tag
tag_counts: dict[str, int] = {}
for (tags_str,) in all_tags_rows:
if tags_str:
for tag in tags_str.split(","):
tag = tag.strip()
if tag:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
# Convert to list and sort by count (desc), then name (asc)
tags_list = [{"name": name, "count": count} for name, count in tag_counts.items()]
tags_list.sort(key=lambda x: (-x["count"], x["name"].lower()))
return tags_list
@router.put("/tags/{tag_name}")
async def rename_tag(
tag_name: str,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""Rename a tag across all archives.
Request body should contain {"new_name": "new tag name"}.
Returns the count of affected archives.
"""
body = await request.json()
new_name = body.get("new_name", "").strip()
if not new_name:
raise HTTPException(400, "new_name is required")
if new_name == tag_name:
return {"affected": 0}
# Find all archives containing the old tag
result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))
archives = list(result.scalars().all())
affected = 0
for archive in archives:
if not archive.tags:
continue
tags = [t.strip() for t in archive.tags.split(",")]
if tag_name in tags:
# Replace old tag with new tag
new_tags = [new_name if t == tag_name else t for t in tags]
# Remove duplicates while preserving order
seen = set()
unique_tags = []
for t in new_tags:
if t not in seen:
seen.add(t)
unique_tags.append(t)
archive.tags = ", ".join(unique_tags)
affected += 1
await db.commit()
return {"affected": affected}
@router.delete("/tags/{tag_name}")
async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)):
"""Delete a tag from all archives.
Returns the count of affected archives.
"""
# Find all archives containing the tag
result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))
archives = list(result.scalars().all())
affected = 0
for archive in archives:
if not archive.tags:
continue
tags = [t.strip() for t in archive.tags.split(",")]
if tag_name in tags:
# Remove the tag
new_tags = [t for t in tags if t != tag_name]
archive.tags = ", ".join(new_tags) if new_tags else None
affected += 1
await db.commit()
return {"affected": affected}
@router.get("/{archive_id}", response_model=ArchiveResponse)
async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
"""Get a specific archive."""
+60 -1
View File
@@ -369,6 +369,21 @@ async def export_backup(
"ha_power_entity": plug.ha_power_entity,
"ha_energy_today_entity": plug.ha_energy_today_entity,
"ha_energy_total_entity": plug.ha_energy_total_entity,
# MQTT plug fields (legacy)
"mqtt_topic": plug.mqtt_topic,
"mqtt_multiplier": plug.mqtt_multiplier,
# MQTT power fields
"mqtt_power_topic": plug.mqtt_power_topic,
"mqtt_power_path": plug.mqtt_power_path,
"mqtt_power_multiplier": plug.mqtt_power_multiplier,
# MQTT energy fields
"mqtt_energy_topic": plug.mqtt_energy_topic,
"mqtt_energy_path": plug.mqtt_energy_path,
"mqtt_energy_multiplier": plug.mqtt_energy_multiplier,
# MQTT state fields
"mqtt_state_topic": plug.mqtt_state_topic,
"mqtt_state_path": plug.mqtt_state_path,
"mqtt_state_on_value": plug.mqtt_state_on_value,
"printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None,
"enabled": plug.enabled,
"auto_on": plug.auto_on,
@@ -385,6 +400,7 @@ async def export_backup(
"schedule_on_time": plug.schedule_on_time,
"schedule_off_time": plug.schedule_off_time,
"show_in_switchbar": plug.show_in_switchbar,
"show_on_printer_card": plug.show_on_printer_card,
}
)
backup["included"].append("smart_plugs")
@@ -1300,12 +1316,23 @@ async def import_backup(
# Determine plug type (default to tasmota for backwards compatibility)
plug_type = plug_data.get("plug_type", "tasmota")
# Find existing plug by IP (Tasmota) or entity_id (Home Assistant)
# Find existing plug by IP (Tasmota), entity_id (Home Assistant), or mqtt_topic (MQTT)
existing = None
plug_identifier = None
if plug_type == "homeassistant" and plug_data.get("ha_entity_id"):
result = await db.execute(select(SmartPlug).where(SmartPlug.ha_entity_id == plug_data["ha_entity_id"]))
existing = result.scalar_one_or_none()
plug_identifier = plug_data["ha_entity_id"]
elif plug_type == "mqtt" and (plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic")):
# Check by mqtt_power_topic first (new format), fall back to mqtt_topic (legacy)
power_topic = plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic")
result = await db.execute(
select(SmartPlug).where(
(SmartPlug.mqtt_power_topic == power_topic) | (SmartPlug.mqtt_topic == power_topic)
)
)
existing = result.scalar_one_or_none()
plug_identifier = power_topic
elif plug_data.get("ip_address"):
result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
existing = result.scalar_one_or_none()
@@ -1322,6 +1349,21 @@ async def import_backup(
existing.ha_power_entity = plug_data.get("ha_power_entity")
existing.ha_energy_today_entity = plug_data.get("ha_energy_today_entity")
existing.ha_energy_total_entity = plug_data.get("ha_energy_total_entity")
# MQTT fields (legacy)
existing.mqtt_topic = plug_data.get("mqtt_topic")
existing.mqtt_multiplier = plug_data.get("mqtt_multiplier", 1.0)
# MQTT power fields
existing.mqtt_power_topic = plug_data.get("mqtt_power_topic")
existing.mqtt_power_path = plug_data.get("mqtt_power_path")
existing.mqtt_power_multiplier = plug_data.get("mqtt_power_multiplier", 1.0)
# MQTT energy fields
existing.mqtt_energy_topic = plug_data.get("mqtt_energy_topic")
existing.mqtt_energy_path = plug_data.get("mqtt_energy_path")
existing.mqtt_energy_multiplier = plug_data.get("mqtt_energy_multiplier", 1.0)
# MQTT state fields
existing.mqtt_state_topic = plug_data.get("mqtt_state_topic")
existing.mqtt_state_path = plug_data.get("mqtt_state_path")
existing.mqtt_state_on_value = plug_data.get("mqtt_state_on_value")
existing.printer_id = printer_id
existing.enabled = plug_data.get("enabled", True)
existing.auto_on = plug_data.get("auto_on", True)
@@ -1338,6 +1380,7 @@ async def import_backup(
existing.schedule_on_time = plug_data.get("schedule_on_time")
existing.schedule_off_time = plug_data.get("schedule_off_time")
existing.show_in_switchbar = plug_data.get("show_in_switchbar", False)
existing.show_on_printer_card = plug_data.get("show_on_printer_card", True)
restored["smart_plugs"] += 1
else:
skipped["smart_plugs"] += 1
@@ -1351,6 +1394,21 @@ async def import_backup(
ha_power_entity=plug_data.get("ha_power_entity"),
ha_energy_today_entity=plug_data.get("ha_energy_today_entity"),
ha_energy_total_entity=plug_data.get("ha_energy_total_entity"),
# MQTT fields (legacy)
mqtt_topic=plug_data.get("mqtt_topic"),
mqtt_multiplier=plug_data.get("mqtt_multiplier", 1.0),
# MQTT power fields
mqtt_power_topic=plug_data.get("mqtt_power_topic"),
mqtt_power_path=plug_data.get("mqtt_power_path"),
mqtt_power_multiplier=plug_data.get("mqtt_power_multiplier", 1.0),
# MQTT energy fields
mqtt_energy_topic=plug_data.get("mqtt_energy_topic"),
mqtt_energy_path=plug_data.get("mqtt_energy_path"),
mqtt_energy_multiplier=plug_data.get("mqtt_energy_multiplier", 1.0),
# MQTT state fields
mqtt_state_topic=plug_data.get("mqtt_state_topic"),
mqtt_state_path=plug_data.get("mqtt_state_path"),
mqtt_state_on_value=plug_data.get("mqtt_state_on_value"),
printer_id=printer_id,
enabled=plug_data.get("enabled", True),
auto_on=plug_data.get("auto_on", True),
@@ -1367,6 +1425,7 @@ async def import_backup(
schedule_on_time=plug_data.get("schedule_on_time"),
schedule_off_time=plug_data.get("schedule_off_time"),
show_in_switchbar=plug_data.get("show_in_switchbar", False),
show_on_printer_card=plug_data.get("show_on_printer_card", True),
)
db.add(plug)
restored["smart_plugs"] += 1
+286 -15
View File
@@ -27,6 +27,7 @@ from backend.app.schemas.smart_plug import (
)
from backend.app.services.discovery import tasmota_scanner
from backend.app.services.homeassistant import homeassistant_service
from backend.app.services.mqtt_relay import mqtt_relay
from backend.app.services.notification_service import notification_service
from backend.app.services.printer_manager import printer_manager
from backend.app.services.tasmota import tasmota_service
@@ -56,16 +57,84 @@ async def create_smart_plug(
raise HTTPException(400, "Printer not found")
# Check if printer already has a plug assigned
result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
if result.scalar_one_or_none():
raise HTTPException(400, "This printer already has a smart plug assigned")
# Scripts can coexist with other plugs (they're for multi-device control, not power on/off)
is_script = data.plug_type == "homeassistant" and data.ha_entity_id and data.ha_entity_id.startswith("script.")
if not is_script:
# For non-script plugs, check there's no other non-script plug assigned
result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
existing = result.scalar_one_or_none()
if existing:
# Allow if existing plug is a script
existing_is_script = (
existing.plug_type == "homeassistant"
and existing.ha_entity_id
and existing.ha_entity_id.startswith("script.")
)
if not existing_is_script:
raise HTTPException(400, "This printer already has a smart plug assigned")
# For MQTT plugs, ensure MQTT broker is configured and service is connected
if data.plug_type == "mqtt":
# Try to configure the smart plug service if not already configured
if not mqtt_relay.smart_plug_service.is_configured():
# Get MQTT broker settings from database
mqtt_broker = await get_setting(db, "mqtt_broker") or ""
if not mqtt_broker:
raise HTTPException(
400,
"MQTT broker not configured. Please set MQTT broker address in Settings → Network → MQTT Publishing.",
)
# Configure the smart plug service with broker settings
mqtt_settings = {
"mqtt_enabled": True, # Enable for smart plug subscription
"mqtt_broker": mqtt_broker,
"mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"),
"mqtt_username": await get_setting(db, "mqtt_username") or "",
"mqtt_password": await get_setting(db, "mqtt_password") or "",
"mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true",
}
await mqtt_relay.smart_plug_service.configure(mqtt_settings)
# Check if connection succeeded
if not mqtt_relay.smart_plug_service.is_configured():
raise HTTPException(
400,
f"Failed to connect to MQTT broker at {mqtt_broker}. Please check your MQTT settings.",
)
plug = SmartPlug(**data.model_dump())
db.add(plug)
await db.commit()
await db.refresh(plug)
if plug.plug_type == "homeassistant":
# Subscribe MQTT plugs to their topics
if plug.plug_type == "mqtt":
# Determine effective topics (new fields take priority, fall back to legacy)
power_topic = plug.mqtt_power_topic or plug.mqtt_topic
energy_topic = plug.mqtt_energy_topic
state_topic = plug.mqtt_state_topic
# Only subscribe if at least one topic is configured
if power_topic or energy_topic or state_topic:
mqtt_relay.smart_plug_service.subscribe(
plug_id=plug.id,
# Power source (path is optional)
power_topic=power_topic,
power_path=plug.mqtt_power_path,
power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0,
# Energy source (path is optional)
energy_topic=energy_topic,
energy_path=plug.mqtt_energy_path,
energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0,
# State source (path is optional)
state_topic=state_topic,
state_path=plug.mqtt_state_path,
state_on_value=plug.mqtt_state_on_value,
)
topics = [t for t in [power_topic, energy_topic, state_topic] if t]
logger.info(f"Created MQTT plug '{plug.name}' subscribed to {', '.join(set(topics))}")
elif plug.plug_type == "homeassistant":
logger.info(f"Created Home Assistant plug '{plug.name}' ({plug.ha_entity_id})")
else:
logger.info(f"Created Tasmota plug '{plug.name}' at {plug.ip_address}")
@@ -74,12 +143,48 @@ async def create_smart_plug(
@router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
"""Get the smart plug assigned to a printer."""
"""Get the main smart plug assigned to a printer.
When multiple plugs are assigned (e.g., a regular plug + script),
returns the main (non-script) plug for power control.
"""
result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
plug = result.scalar_one_or_none()
if not plug:
plugs = result.scalars().all()
if not plugs:
return None
return plug
# If multiple plugs, prefer the non-script one (main power plug)
for plug in plugs:
is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
if not is_script:
return plug
# All are scripts, return the first one
return plugs[0]
@router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
async def get_script_plugs_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
"""Get all HA script plugs assigned to a printer.
Returns only script entities (script.*) for the printer that have
show_on_printer_card enabled.
Used to display "Run Script" buttons alongside the main power plug.
"""
result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
plugs = result.scalars().all()
# Filter to only scripts with show_on_printer_card enabled
scripts = [
plug
for plug in plugs
if plug.plug_type == "homeassistant"
and plug.ha_entity_id
and plug.ha_entity_id.startswith("script.")
and plug.show_on_printer_card
]
return scripts
# Tasmota Discovery Endpoints
@@ -287,14 +392,43 @@ async def update_smart_plug(
raise HTTPException(400, "Printer not found")
# Check if that printer already has a different plug assigned
result = await db.execute(
select(SmartPlug).where(
SmartPlug.printer_id == new_printer_id,
SmartPlug.id != plug_id,
# Scripts can coexist with other plugs
# Determine if the plug being updated is/will be a script
new_entity_id = update_data.get("ha_entity_id", plug.ha_entity_id)
new_plug_type = update_data.get("plug_type", plug.plug_type)
is_script = new_plug_type == "homeassistant" and new_entity_id and new_entity_id.startswith("script.")
if not is_script:
result = await db.execute(
select(SmartPlug).where(
SmartPlug.printer_id == new_printer_id,
SmartPlug.id != plug_id,
)
)
)
if result.scalar_one_or_none():
raise HTTPException(400, "This printer already has a smart plug assigned")
existing = result.scalar_one_or_none()
if existing:
# Allow if existing plug is a script
existing_is_script = (
existing.plug_type == "homeassistant"
and existing.ha_entity_id
and existing.ha_entity_id.startswith("script.")
)
if not existing_is_script:
raise HTTPException(400, "This printer already has a smart plug assigned")
# Track old MQTT settings for comparison
old_plug_type = plug.plug_type
old_mqtt_config = {
"power_topic": plug.mqtt_power_topic or plug.mqtt_topic,
"power_path": plug.mqtt_power_path,
"power_multiplier": plug.mqtt_power_multiplier,
"energy_topic": plug.mqtt_energy_topic or plug.mqtt_topic,
"energy_path": plug.mqtt_energy_path,
"energy_multiplier": plug.mqtt_energy_multiplier,
"state_topic": plug.mqtt_state_topic or plug.mqtt_topic,
"state_path": plug.mqtt_state_path,
"state_on_value": plug.mqtt_state_on_value,
}
for field, value in update_data.items():
setattr(plug, field, value)
@@ -302,6 +436,54 @@ async def update_smart_plug(
await db.commit()
await db.refresh(plug)
# Handle MQTT subscription changes
if old_plug_type == "mqtt" and plug.plug_type != "mqtt":
# Changed away from MQTT - unsubscribe
mqtt_relay.smart_plug_service.unsubscribe(plug.id)
elif plug.plug_type == "mqtt":
# Check if any MQTT config changed
new_mqtt_config = {
"power_topic": plug.mqtt_power_topic or plug.mqtt_topic,
"power_path": plug.mqtt_power_path,
"power_multiplier": plug.mqtt_power_multiplier,
"energy_topic": plug.mqtt_energy_topic or plug.mqtt_topic,
"energy_path": plug.mqtt_energy_path,
"energy_multiplier": plug.mqtt_energy_multiplier,
"state_topic": plug.mqtt_state_topic or plug.mqtt_topic,
"state_path": plug.mqtt_state_path,
"state_on_value": plug.mqtt_state_on_value,
}
mqtt_changed = old_plug_type != "mqtt" or old_mqtt_config != new_mqtt_config
if mqtt_changed:
# Unsubscribe from old topics first
if old_plug_type == "mqtt":
mqtt_relay.smart_plug_service.unsubscribe(plug.id)
# Subscribe to new topics
power_topic = plug.mqtt_power_topic or plug.mqtt_topic
energy_topic = plug.mqtt_energy_topic
state_topic = plug.mqtt_state_topic
# Only subscribe if at least one topic is configured
if power_topic or energy_topic or state_topic:
mqtt_relay.smart_plug_service.subscribe(
plug_id=plug.id,
# Power source (path is optional)
power_topic=power_topic,
power_path=plug.mqtt_power_path,
power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0,
# Energy source (path is optional)
energy_topic=energy_topic,
energy_path=plug.mqtt_energy_path,
energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0,
# State source (path is optional)
state_topic=state_topic,
state_path=plug.mqtt_state_path,
state_on_value=plug.mqtt_state_on_value,
)
logger.info(f"Updated smart plug '{plug.name}'")
return plug
@@ -315,6 +497,12 @@ async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
raise HTTPException(404, "Smart plug not found")
plug_name = plug.name
plug_type = plug.plug_type
# Unsubscribe MQTT plug before deletion
if plug_type == "mqtt":
mqtt_relay.smart_plug_service.unsubscribe(plug_id)
await db.delete(plug)
await db.commit()
@@ -348,6 +536,13 @@ async def control_smart_plug(
if not plug:
raise HTTPException(404, "Smart plug not found")
# MQTT plugs are monitor-only - cannot control them
if plug.plug_type == "mqtt":
raise HTTPException(
400,
"MQTT plugs are monitor-only. Use your MQTT broker or home automation system to control them.",
)
service = await _get_service_for_plug(plug, db)
if control.action == "on":
@@ -376,6 +571,13 @@ async def control_smart_plug(
plug.last_checked = datetime.utcnow()
await db.commit()
# Trigger associated scripts if this is a main (non-script) plug
is_main_plug = not (
plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
)
if is_main_plug and plug.printer_id and expected_state:
await trigger_associated_scripts(plug.printer_id, expected_state, db)
# MQTT relay - publish smart plug state change
if expected_state:
try:
@@ -401,6 +603,37 @@ async def control_smart_plug(
return {"success": True, "action": control.action}
async def trigger_associated_scripts(printer_id: int, plug_state: str, db: AsyncSession):
"""Trigger scripts linked to a printer based on main plug state change.
When the main plug turns ON, triggers scripts with auto_on=True.
When the main plug turns OFF, triggers scripts with auto_off=True.
"""
result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
plugs = result.scalars().all()
# Find scripts that should be triggered
for plug in plugs:
is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
if not is_script:
continue
should_trigger = False
if plug_state == "ON" and plug.auto_on:
should_trigger = True
logger.info(f"Auto-triggering script '{plug.name}' on printer power-on")
elif plug_state == "OFF" and plug.auto_off:
should_trigger = True
logger.info(f"Auto-triggering script '{plug.name}' on printer power-off")
if should_trigger:
try:
service = await _get_service_for_plug(plug, db)
await service.turn_on(plug) # Scripts are triggered by calling turn_on
except Exception as e:
logger.error(f"Failed to trigger script '{plug.name}': {e}")
@router.get("/{plug_id}/status", response_model=SmartPlugStatus)
async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
"""Get current plug status from device including energy data."""
@@ -409,6 +642,44 @@ async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
if not plug:
raise HTTPException(404, "Smart plug not found")
# Handle MQTT plugs - get data from subscription service
if plug.plug_type == "mqtt":
data = mqtt_relay.smart_plug_service.get_plug_data(plug_id)
is_reachable = mqtt_relay.smart_plug_service.is_reachable(plug_id)
if data:
# Update last state in database
if is_reachable and data.state:
plug.last_state = data.state
plug.last_checked = datetime.utcnow()
await db.commit()
energy_data = None
if data.power is not None or data.energy is not None:
energy_data = SmartPlugEnergy(
power=data.power,
today=data.energy,
)
# Check power alerts
if data.power is not None:
await check_power_alerts(plug, data.power, db)
return SmartPlugStatus(
state=data.state,
reachable=is_reachable,
device_name=None,
energy=energy_data,
)
# No data received yet
return SmartPlugStatus(
state=None,
reachable=False,
device_name=None,
energy=None,
)
# Handle Tasmota/HomeAssistant plugs
service = await _get_service_for_plug(plug, db)
status = await service.get_status(plug)
+117 -23
View File
@@ -764,45 +764,139 @@ async def run_migrations(conn):
except Exception:
pass
# Migration: Add sliced_for_model column to print_archives for printer model from 3MF
# Migration: Remove UNIQUE constraint from smart_plugs.printer_id
# This allows HA scripts to coexist with regular plugs (scripts are for multi-device control)
# SQLite requires table recreation to drop constraints
try:
await conn.execute(text("ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)"))
# Check if we need to migrate (if UNIQUE constraint exists)
result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'"))
row = result.fetchone()
if row and "printer_id INTEGER UNIQUE" in (row[0] or ""):
# Create new table without UNIQUE constraint on printer_id
await conn.execute(
text("""
CREATE TABLE smart_plugs_temp (
id INTEGER PRIMARY KEY,
name VARCHAR(100) NOT NULL,
ip_address VARCHAR(45),
plug_type VARCHAR(20) DEFAULT 'tasmota',
ha_entity_id VARCHAR(100),
ha_power_entity VARCHAR(100),
ha_energy_today_entity VARCHAR(100),
ha_energy_total_entity VARCHAR(100),
printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
enabled BOOLEAN NOT NULL DEFAULT 1,
auto_on BOOLEAN NOT NULL DEFAULT 1,
auto_off BOOLEAN NOT NULL DEFAULT 1,
off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
off_delay_minutes INTEGER NOT NULL DEFAULT 5,
off_temp_threshold INTEGER NOT NULL DEFAULT 70,
username VARCHAR(50),
password VARCHAR(100),
power_alert_enabled BOOLEAN NOT NULL DEFAULT 0,
power_alert_high FLOAT,
power_alert_low FLOAT,
power_alert_last_triggered DATETIME,
schedule_enabled BOOLEAN NOT NULL DEFAULT 0,
schedule_on_time VARCHAR(5),
schedule_off_time VARCHAR(5),
show_in_switchbar BOOLEAN DEFAULT 0,
last_state VARCHAR(10),
last_checked DATETIME,
auto_off_executed BOOLEAN NOT NULL DEFAULT 0,
auto_off_pending BOOLEAN DEFAULT 0,
auto_off_pending_since DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
)
""")
)
# Copy data
await conn.execute(
text("""
INSERT INTO smart_plugs_temp
SELECT id, name, ip_address, plug_type, ha_entity_id, ha_power_entity,
ha_energy_today_entity, ha_energy_total_entity, printer_id, enabled,
auto_on, auto_off, off_delay_mode, off_delay_minutes, off_temp_threshold,
username, password, power_alert_enabled, power_alert_high, power_alert_low,
power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
show_in_switchbar, last_state, last_checked, auto_off_executed,
auto_off_pending, auto_off_pending_since, created_at, updated_at
FROM smart_plugs
""")
)
# Drop old table and rename new one
await conn.execute(text("DROP TABLE smart_plugs"))
await conn.execute(text("ALTER TABLE smart_plugs_temp RENAME TO smart_plugs"))
except Exception:
pass
# Migration: Add target_model column to print_queue for model-based assignment
# Migration: Add show_on_printer_card column to smart_plugs
try:
await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_model VARCHAR(50)"))
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1"))
except Exception:
pass
# Migration: Add required_filament_types column to print_queue for filament validation
# Migration: Add MQTT smart plug fields (legacy)
try:
await conn.execute(text("ALTER TABLE print_queue ADD COLUMN required_filament_types TEXT"))
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_topic VARCHAR(200)"))
except Exception:
pass
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_path VARCHAR(100)"))
except Exception:
pass
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_path VARCHAR(100)"))
except Exception:
pass
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_path VARCHAR(100)"))
except Exception:
pass
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_multiplier REAL DEFAULT 1.0"))
except Exception:
pass
# Migration: Add waiting_reason column to print_queue for status feedback
# Migration: Add enhanced MQTT smart plug fields (separate topics and multipliers)
try:
await conn.execute(text("ALTER TABLE print_queue ADD COLUMN waiting_reason TEXT"))
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_topic VARCHAR(200)"))
except Exception:
pass
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_multiplier REAL DEFAULT 1.0"))
except Exception:
pass
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_topic VARCHAR(200)"))
except Exception:
pass
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_multiplier REAL DEFAULT 1.0"))
except Exception:
pass
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_topic VARCHAR(200)"))
except Exception:
pass
try:
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_on_value VARCHAR(50)"))
except Exception:
pass
# Migration: Add queue notification event columns to notification_providers
queue_notification_columns = [
("on_queue_job_added", "BOOLEAN DEFAULT 0"),
("on_queue_job_assigned", "BOOLEAN DEFAULT 0"),
("on_queue_job_started", "BOOLEAN DEFAULT 0"),
("on_queue_job_waiting", "BOOLEAN DEFAULT 1"),
("on_queue_job_skipped", "BOOLEAN DEFAULT 1"),
("on_queue_job_failed", "BOOLEAN DEFAULT 1"),
("on_queue_completed", "BOOLEAN DEFAULT 0"),
]
for col_name, col_def in queue_notification_columns:
try:
await conn.execute(text(f"ALTER TABLE notification_providers ADD COLUMN {col_name} {col_def}"))
except Exception:
pass
# Migration: Copy existing mqtt_topic to mqtt_power_topic for backward compatibility
try:
await conn.execute(
text("""
UPDATE smart_plugs
SET mqtt_power_topic = mqtt_topic,
mqtt_power_multiplier = mqtt_multiplier
WHERE mqtt_topic IS NOT NULL AND mqtt_power_topic IS NULL
""")
)
except Exception:
pass
# Migration: Create groups table for permission-based access control
try:
+34 -1
View File
@@ -249,9 +249,10 @@ _notified_hms_errors: dict[int, set[str]] = {}
async def _get_plug_energy(plug, db) -> dict | None:
"""Get energy from plug regardless of type (Tasmota or Home Assistant).
"""Get energy from plug regardless of type (Tasmota, Home Assistant, or MQTT).
For HA plugs, configures the service with current settings from DB.
For MQTT plugs, returns data from the subscription service.
"""
if plug.plug_type == "homeassistant":
from backend.app.api.routes.settings import get_setting
@@ -260,6 +261,17 @@ async def _get_plug_energy(plug, db) -> dict | None:
ha_token = await get_setting(db, "ha_token") or ""
homeassistant_service.configure(ha_url, ha_token)
return await homeassistant_service.get_energy(plug)
elif plug.plug_type == "mqtt":
# MQTT plugs report "today" energy, not lifetime total
# For per-print tracking, we use "today" as the counter (resets at midnight)
mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)
if mqtt_data:
return {
"power": mqtt_data.power,
"today": mqtt_data.energy,
"total": mqtt_data.energy, # Use today as total for per-print calculations
}
return None
else:
return await tasmota_service.get_energy(plug)
@@ -2396,6 +2408,27 @@ async def lifespan(app: FastAPI):
}
await mqtt_relay.configure(mqtt_settings)
# Restore MQTT smart plug subscriptions
if mqtt_settings.get("mqtt_enabled"):
from sqlalchemy import select
from backend.app.models.smart_plug import SmartPlug
result = await db.execute(select(SmartPlug).where(SmartPlug.plug_type == "mqtt"))
mqtt_plugs = result.scalars().all()
for plug in mqtt_plugs:
if plug.mqtt_topic:
mqtt_relay.smart_plug_service.subscribe(
plug_id=plug.id,
topic=plug.mqtt_topic,
power_path=plug.mqtt_power_path,
energy_path=plug.mqtt_energy_path,
state_path=plug.mqtt_state_path,
multiplier=plug.mqtt_multiplier or 1.0,
)
if mqtt_plugs:
logging.info(f"Restored {len(mqtt_plugs)} MQTT smart plug subscriptions")
# Connect to all active printers
async with async_session() as db:
await init_printer_connections(db)
+31 -2
View File
@@ -7,7 +7,7 @@ from backend.app.core.database import Base
class SmartPlug(Base):
"""Smart plug for printer power control (Tasmota or Home Assistant)."""
"""Smart plug for printer power control (Tasmota, Home Assistant, or MQTT)."""
__tablename__ = "smart_plugs"
@@ -15,7 +15,7 @@ class SmartPlug(Base):
name: Mapped[str] = mapped_column(String(100))
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) # IPv4/IPv6 (required for Tasmota)
# Plug type: "tasmota" (default) or "homeassistant"
# Plug type: "tasmota" (default), "homeassistant", or "mqtt"
plug_type: Mapped[str] = mapped_column(String(20), default="tasmota")
# Home Assistant entity ID (e.g., "switch.printer_plug")
ha_entity_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
@@ -24,6 +24,32 @@ class SmartPlug(Base):
ha_energy_today_entity: Mapped[str | None] = mapped_column(String(100), nullable=True) # sensor.xxx_today
ha_energy_total_entity: Mapped[str | None] = mapped_column(String(100), nullable=True) # sensor.xxx_total
# MQTT plug fields (required when plug_type="mqtt")
# Legacy field - kept for backward compatibility, now use mqtt_power_topic
mqtt_topic: Mapped[str | None] = mapped_column(
String(200), nullable=True
) # e.g., "zigbee2mqtt/shelly-working-room" (deprecated, use mqtt_power_topic)
# Power monitoring
mqtt_power_topic: Mapped[str | None] = mapped_column(String(200), nullable=True) # Topic for power data
mqtt_power_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # e.g., "power_l1" or "data.power"
mqtt_power_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Unit conversion for power
# Energy monitoring
mqtt_energy_topic: Mapped[str | None] = mapped_column(String(200), nullable=True) # Topic for energy data
mqtt_energy_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # e.g., "energy_l1"
mqtt_energy_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Unit conversion for energy
# State monitoring
mqtt_state_topic: Mapped[str | None] = mapped_column(String(200), nullable=True) # Topic for state data
mqtt_state_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # e.g., "state_l1" for ON/OFF
mqtt_state_on_value: Mapped[str | None] = mapped_column(
String(50), nullable=True
) # What value means "ON" (e.g., "ON", "true", "1")
# Legacy multiplier - kept for backward compatibility
mqtt_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Deprecated, use mqtt_power_multiplier
# Link to printer (1:1)
printer_id: Mapped[int | None] = mapped_column(
ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True
@@ -57,6 +83,9 @@ class SmartPlug(Base):
# Switchbar visibility
show_in_switchbar: Mapped[bool] = mapped_column(Boolean, default=False)
# Printer card visibility (for scripts)
show_on_printer_card: Mapped[bool] = mapped_column(Boolean, default=True)
# 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)
+58 -6
View File
@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, model_validator
class SmartPlugBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
plug_type: Literal["tasmota", "homeassistant"] = "tasmota"
plug_type: Literal["tasmota", "homeassistant", "mqtt"] = "tasmota"
# Tasmota fields (required when plug_type="tasmota")
ip_address: str | None = Field(default=None, pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
@@ -14,12 +14,36 @@ class SmartPlugBase(BaseModel):
password: str | None = None
# Home Assistant fields (required when plug_type="homeassistant")
ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean)\.[a-z0-9_]+$")
ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean|script)\.[a-z0-9_]+$")
# Home Assistant energy sensor entities (optional, for separate energy sensors)
ha_power_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
ha_energy_today_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
ha_energy_total_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
# MQTT fields (required when plug_type="mqtt")
# Legacy field - kept for backward compatibility
mqtt_topic: str | None = Field(default=None, max_length=200) # Deprecated, use mqtt_power_topic
# Power monitoring
mqtt_power_topic: str | None = Field(default=None, max_length=200) # Topic for power data
mqtt_power_path: str | None = Field(default=None, max_length=100) # e.g., "power_l1" or "data.power"
mqtt_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Unit conversion for power
# Energy monitoring
mqtt_energy_topic: str | None = Field(default=None, max_length=200) # Topic for energy data
mqtt_energy_path: str | None = Field(default=None, max_length=100) # e.g., "energy_l1"
mqtt_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Unit conversion for energy
# State monitoring
mqtt_state_topic: str | None = Field(default=None, max_length=200) # Topic for state data
mqtt_state_path: str | None = Field(default=None, max_length=100) # e.g., "state_l1" for ON/OFF
mqtt_state_on_value: str | None = Field(
default=None, max_length=50
) # What value means "ON" (e.g., "ON", "true", "1")
# Legacy multiplier - kept for backward compatibility
mqtt_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Deprecated, use mqtt_power_multiplier
printer_id: int | None = None
enabled: bool = True
auto_on: bool = True
@@ -35,8 +59,9 @@ class SmartPlugBase(BaseModel):
schedule_enabled: bool = False
schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format
schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format
# Switchbar visibility
# Visibility options
show_in_switchbar: bool = False
show_on_printer_card: bool = True # For scripts: show on printer card
@model_validator(mode="after")
def validate_plug_type_fields(self) -> "SmartPlugBase":
@@ -44,6 +69,17 @@ class SmartPlugBase(BaseModel):
raise ValueError("ip_address is required for Tasmota plugs")
if self.plug_type == "homeassistant" and not self.ha_entity_id:
raise ValueError("ha_entity_id is required for Home Assistant plugs")
if self.plug_type == "mqtt":
# Determine the effective power topic (new field takes priority, fall back to legacy)
power_topic = self.mqtt_power_topic or self.mqtt_topic
# Path is optional - if not set, raw MQTT payload value will be used
has_power = bool(power_topic)
has_energy = bool(self.mqtt_energy_topic)
has_state = bool(self.mqtt_state_topic)
# At least one data source must be configured (path is optional)
if not has_power and not has_energy and not has_state:
raise ValueError("At least one MQTT topic must be configured for power, energy, or state monitoring")
return self
@@ -53,13 +89,28 @@ class SmartPlugCreate(SmartPlugBase):
class SmartPlugUpdate(BaseModel):
name: str | None = None
plug_type: Literal["tasmota", "homeassistant"] | None = None
plug_type: Literal["tasmota", "homeassistant", "mqtt"] | None = None
ip_address: str | None = None
ha_entity_id: str | None = None
# Home Assistant energy sensor entities (optional)
ha_power_entity: str | None = None
ha_energy_today_entity: str | None = None
ha_energy_total_entity: str | None = None
# MQTT fields (legacy)
mqtt_topic: str | None = None
mqtt_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
# MQTT power fields
mqtt_power_topic: str | None = None
mqtt_power_path: str | None = None
mqtt_power_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
# MQTT energy fields
mqtt_energy_topic: str | None = None
mqtt_energy_path: str | None = None
mqtt_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
# MQTT state fields
mqtt_state_topic: str | None = None
mqtt_state_path: str | None = None
mqtt_state_on_value: str | None = None
printer_id: int | None = None
enabled: bool | None = None
auto_on: bool | None = None
@@ -77,8 +128,9 @@ class SmartPlugUpdate(BaseModel):
schedule_enabled: bool | None = None
schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
# Switchbar visibility
# Visibility options
show_in_switchbar: bool | None = None
show_on_printer_card: bool | None = None
class SmartPlugResponse(SmartPlugBase):
@@ -147,7 +199,7 @@ class HAEntity(BaseModel):
entity_id: str
friendly_name: str
state: str | None = None
domain: str # "switch", "light", "input_boolean"
domain: str # "switch", "light", "input_boolean", "script"
class HASensorEntity(BaseModel):
+1 -1
View File
@@ -228,7 +228,7 @@ class HomeAssistantService:
- domain: str
"""
# Default domains for smart plug control
default_domains = {"switch", "light", "input_boolean"}
default_domains = {"switch", "light", "input_boolean", "script"}
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
+32 -1
View File
@@ -34,6 +34,8 @@ class MQTTRelayService:
self._broker = ""
self._port = 1883
self._last_printer_status: dict[int, float] = {} # printer_id -> last publish timestamp
self._smart_plug_service = None # Lazy import to avoid circular dependency
self._settings: dict = {} # Store settings for smart plug service
async def configure(self, settings: dict) -> bool:
"""Configure MQTT connection from settings.
@@ -41,9 +43,12 @@ class MQTTRelayService:
Returns True if connection was successful or MQTT is disabled.
"""
self.enabled = settings.get("mqtt_enabled", False)
self._settings = settings # Store for smart plug service
if not self.enabled:
await self.disconnect()
# Also configure smart plug service (will disable it)
await self._configure_smart_plug_service(settings)
logger.info("MQTT relay disabled")
return True
@@ -67,7 +72,33 @@ class MQTTRelayService:
await self.disconnect()
# Create and connect client
return await self._connect(broker, port, username, password, use_tls)
result = await self._connect(broker, port, username, password, use_tls)
# Configure smart plug service with same settings
await self._configure_smart_plug_service(settings)
return result
async def _configure_smart_plug_service(self, settings: dict):
"""Configure the MQTT smart plug service with the same broker settings."""
try:
if self._smart_plug_service is None:
from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
self._smart_plug_service = mqtt_smart_plug_service
await self._smart_plug_service.configure(settings)
except Exception as e:
logger.error(f"Failed to configure MQTT smart plug service: {e}")
@property
def smart_plug_service(self):
"""Get the MQTT smart plug service instance."""
if self._smart_plug_service is None:
from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
self._smart_plug_service = mqtt_smart_plug_service
return self._smart_plug_service
async def _connect(self, broker: str, port: int, username: str, password: str, use_tls: bool) -> bool:
"""Establish MQTT connection."""
+488
View File
@@ -0,0 +1,488 @@
"""MQTT Smart Plug Service for subscribing to external MQTT topics and extracting power/energy data.
This service enables integration with Shelly, Zigbee2MQTT, and other MQTT-based energy monitoring devices.
"""
import json
import logging
import threading
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any
import paho.mqtt.client as mqtt
logger = logging.getLogger(__name__)
@dataclass
class SmartPlugMQTTData:
"""Latest data received from an MQTT smart plug."""
plug_id: int
power: float | None = None # Current power in watts
energy: float | None = None # Energy in kWh (today)
state: str | None = None # "ON" or "OFF"
last_seen: datetime = field(default_factory=datetime.utcnow)
@dataclass
class MQTTDataSourceConfig:
"""Configuration for a single MQTT data source (power, energy, or state)."""
topic: str
path: str
multiplier: float = 1.0 # For power/energy
on_value: str | None = None # For state (what value means "ON")
class MQTTSmartPlugService:
"""Subscribes to MQTT topics for smart plug energy monitoring."""
# Consider plug unreachable if no message received in this time
REACHABLE_TIMEOUT_MINUTES = 5
def __init__(self):
self.client: mqtt.Client | None = None
self.connected = False
self._lock = threading.Lock()
# topic -> list of (plug_id, data_type) where data_type is "power", "energy", or "state"
self.subscriptions: dict[str, list[tuple[int, str]]] = {}
# plug_id -> {data_type: MQTTDataSourceConfig}
self.plug_configs: dict[int, dict[str, MQTTDataSourceConfig]] = {}
# plug_id -> latest data
self.plug_data: dict[int, SmartPlugMQTTData] = {}
self._configured = False
self._broker = ""
self._port = 1883
self._username = ""
self._password = ""
self._use_tls = False
def is_configured(self) -> bool:
"""Check if the MQTT service is configured and connected."""
return self._configured and self.connected
def has_broker_settings(self) -> bool:
"""Check if broker settings are available (even if not connected yet)."""
return bool(self._broker)
async def configure(self, settings: dict) -> bool:
"""Configure MQTT connection from settings.
Uses the same broker settings as the MQTT relay service.
Returns True if connection was successful or MQTT is disabled.
"""
enabled = settings.get("mqtt_enabled", False)
if not enabled:
await self.disconnect()
self._configured = False
logger.debug("MQTT smart plug service disabled (MQTT relay not enabled)")
return True
broker = settings.get("mqtt_broker", "")
port = settings.get("mqtt_port", 1883)
username = settings.get("mqtt_username", "")
password = settings.get("mqtt_password", "")
use_tls = settings.get("mqtt_use_tls", False)
if not broker:
logger.warning("MQTT smart plug service: no broker configured")
self._configured = False
return False
# Check if settings changed
settings_changed = (
self._broker != broker
or self._port != port
or self._username != username
or self._password != password
or self._use_tls != use_tls
)
self._broker = broker
self._port = port
self._username = username
self._password = password
self._use_tls = use_tls
self._configured = True
# Disconnect and reconnect if settings changed
if settings_changed and self.client:
await self.disconnect()
# Connect if not already connected
if not self.client or not self.connected:
return await self._connect()
return True
async def _connect(self) -> bool:
"""Establish MQTT connection."""
import asyncio
import ssl
try:
# Create client with callback API version 2
self.client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=f"bambuddy-smartplug-{id(self)}",
protocol=mqtt.MQTTv311,
)
# Set up callbacks
self.client.on_connect = self._on_connect
self.client.on_disconnect = self._on_disconnect
self.client.on_message = self._on_message
# Configure authentication
if self._username:
self.client.username_pw_set(self._username, self._password)
# Configure TLS
if self._use_tls:
self.client.tls_set(cert_reqs=ssl.CERT_NONE)
self.client.tls_insecure_set(True)
# Connect with timeout
try:
await asyncio.wait_for(
asyncio.to_thread(self.client.connect_async, self._broker, self._port, 60),
timeout=3.0,
)
except TimeoutError:
logger.warning(f"MQTT smart plug connection to {self._broker}:{self._port} timed out")
return False
self.client.loop_start()
# Wait briefly for connection
await asyncio.sleep(1.0)
if self.connected:
logger.info(f"MQTT smart plug service connected to {self._broker}:{self._port}")
# Resubscribe to all topics
self._resubscribe_all()
return True
else:
logger.warning(f"MQTT smart plug connection pending to {self._broker}:{self._port}")
return True # Connection is async
except Exception as e:
logger.error(f"MQTT smart plug connection failed: {e}")
self.connected = False
return False
def _on_connect(
self,
client: mqtt.Client,
userdata: Any,
flags: dict,
reason_code: int | mqtt.ReasonCode,
properties: mqtt.Properties | None = None,
):
"""Callback when connected to broker."""
rc = reason_code if isinstance(reason_code, int) else reason_code.value
if rc == 0:
self.connected = True
logger.info("MQTT smart plug service connected successfully")
# Resubscribe to all topics
self._resubscribe_all()
else:
self.connected = False
logger.error(f"MQTT smart plug connection failed: {reason_code}")
def _on_disconnect(
self,
client: mqtt.Client,
userdata: Any,
flags_or_rc: dict | int | mqtt.ReasonCode,
reason_code: int | mqtt.ReasonCode | None = None,
properties: mqtt.Properties | None = None,
):
"""Callback when disconnected from broker."""
self.connected = False
rc = reason_code if reason_code is not None else flags_or_rc
rc_val = rc if isinstance(rc, int) else getattr(rc, "value", 0)
if rc_val != 0:
logger.warning(f"MQTT smart plug service disconnected: {rc}")
else:
logger.info("MQTT smart plug service disconnected cleanly")
def _on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage):
"""Handle incoming MQTT message, extract data using JSON path."""
topic = msg.topic
with self._lock:
subscriptions = self.subscriptions.get(topic, [])
if not subscriptions:
return
# Parse JSON payload (or treat as raw value)
try:
payload = json.loads(msg.payload.decode("utf-8"))
is_json = True
except (json.JSONDecodeError, UnicodeDecodeError):
# Not JSON - treat the whole payload as a raw value
payload = msg.payload.decode("utf-8").strip()
is_json = False
# Process for each subscribed (plug_id, data_type)
for plug_id, data_type in subscriptions:
configs = self.plug_configs.get(plug_id, {})
config = configs.get(data_type)
if not config:
continue
# Extract value using path (or use raw payload if no path)
if is_json and config.path:
raw_value = self._extract_json_path(payload, config.path)
elif is_json and not config.path:
# JSON but no path - if it's a simple value use it, otherwise skip
if isinstance(payload, (int, float, str, bool)):
raw_value = payload
else:
# Can't use a dict/list as a value
logger.debug(f"MQTT plug {plug_id}: JSON payload is object/array but no path configured")
continue
else:
# Raw value (non-JSON)
raw_value = payload
if raw_value is None:
continue
# Initialize plug data if needed
if plug_id not in self.plug_data:
self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id)
data = self.plug_data[plug_id]
data.last_seen = datetime.utcnow()
# Process based on data type
if data_type == "power":
try:
data.power = float(raw_value) * config.multiplier
logger.debug(f"MQTT smart plug {plug_id}: power={data.power}")
except (ValueError, TypeError):
pass
elif data_type == "energy":
try:
data.energy = float(raw_value) * config.multiplier
logger.debug(f"MQTT smart plug {plug_id}: energy={data.energy}")
except (ValueError, TypeError):
pass
elif data_type == "state":
state_str = str(raw_value)
# Check against configured ON value if set
if config.on_value:
# Case-insensitive comparison
if state_str.lower() == config.on_value.lower():
data.state = "ON"
else:
data.state = "OFF"
else:
# Default behavior: normalize common values
upper_state = state_str.upper()
if upper_state in ("ON", "1", "TRUE"):
data.state = "ON"
elif upper_state in ("OFF", "0", "FALSE"):
data.state = "OFF"
else:
data.state = state_str
logger.debug(f"MQTT smart plug {plug_id}: state={data.state}")
def _extract_json_path(self, data: dict, path: str) -> Any:
"""Extract value using dot notation (e.g., 'power_l1' or 'data.power').
Supports simple dot notation for nested objects.
"""
if not path:
return None
parts = path.split(".")
current = data
for part in parts:
if isinstance(current, dict) and part in current:
current = current[part]
else:
return None
return current
def _resubscribe_all(self):
"""Resubscribe to all registered topics after reconnection."""
if not self.client or not self.connected:
return
with self._lock:
for topic in self.subscriptions:
if self.subscriptions[topic]: # Only if there are subscribers
try:
self.client.subscribe(topic, qos=1)
logger.debug(f"MQTT smart plug: resubscribed to {topic}")
except Exception as e:
logger.error(f"MQTT smart plug: failed to resubscribe to {topic}: {e}")
def subscribe(
self,
plug_id: int,
# Power source
power_topic: str | None = None,
power_path: str | None = None,
power_multiplier: float = 1.0,
# Energy source
energy_topic: str | None = None,
energy_path: str | None = None,
energy_multiplier: float = 1.0,
# State source
state_topic: str | None = None,
state_path: str | None = None,
state_on_value: str | None = None,
# Legacy: single topic/path/multiplier (for backward compatibility)
topic: str | None = None,
multiplier: float = 1.0,
):
"""Subscribe to MQTT topics for a plug.
Each data type (power, energy, state) can have its own topic.
For backward compatibility, if power_topic is not set but topic is,
topic will be used for all data types that have paths configured.
"""
with self._lock:
# Initialize config for this plug
self.plug_configs[plug_id] = {}
# Determine topics (new fields take priority, fall back to legacy)
effective_power_topic = power_topic or topic
effective_energy_topic = energy_topic or topic
effective_state_topic = state_topic or topic
# Use new multipliers or fall back to legacy
effective_power_mult = power_multiplier if power_multiplier != 1.0 else multiplier
effective_energy_mult = energy_multiplier if energy_multiplier != 1.0 else multiplier
# Configure power subscription (path is optional - empty means use raw payload)
if effective_power_topic:
config = MQTTDataSourceConfig(
topic=effective_power_topic,
path=power_path or "",
multiplier=effective_power_mult,
)
self.plug_configs[plug_id]["power"] = config
self._add_subscription(plug_id, effective_power_topic, "power")
# Configure energy subscription (path is optional - empty means use raw payload)
if effective_energy_topic:
config = MQTTDataSourceConfig(
topic=effective_energy_topic,
path=energy_path or "",
multiplier=effective_energy_mult,
)
self.plug_configs[plug_id]["energy"] = config
self._add_subscription(plug_id, effective_energy_topic, "energy")
# Configure state subscription (path is optional - empty means use raw payload)
if effective_state_topic:
config = MQTTDataSourceConfig(
topic=effective_state_topic,
path=state_path or "",
on_value=state_on_value,
)
self.plug_configs[plug_id]["state"] = config
self._add_subscription(plug_id, effective_state_topic, "state")
# Initialize data entry
if plug_id not in self.plug_data:
self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id)
logger.info(
f"MQTT smart plug {plug_id}: configured with "
f"power={effective_power_topic if power_path else None}, "
f"energy={effective_energy_topic if energy_path else None}, "
f"state={effective_state_topic if state_path else None}"
)
def _add_subscription(self, plug_id: int, topic: str, data_type: str):
"""Add a subscription for a plug/data_type to a topic."""
if topic not in self.subscriptions:
self.subscriptions[topic] = []
# Actually subscribe if connected
if self.client and self.connected:
try:
self.client.subscribe(topic, qos=1)
logger.info(f"MQTT smart plug: subscribed to {topic}")
except Exception as e:
logger.error(f"MQTT smart plug: failed to subscribe to {topic}: {e}")
entry = (plug_id, data_type)
if entry not in self.subscriptions[topic]:
self.subscriptions[topic].append(entry)
def unsubscribe(self, plug_id: int):
"""Unsubscribe when plug is deleted/updated."""
with self._lock:
# Get all configs for this plug
configs = self.plug_configs.pop(plug_id, {})
if not configs:
# Still clean up any stray subscriptions
pass
# Collect all topics this plug was subscribed to
topics_to_check = set()
for _data_type, config in configs.items():
topics_to_check.add(config.topic)
# Also scan subscriptions to remove any entries for this plug
for topic in list(self.subscriptions.keys()):
# Remove all entries for this plug_id
self.subscriptions[topic] = [(pid, dtype) for pid, dtype in self.subscriptions[topic] if pid != plug_id]
topics_to_check.add(topic)
# Unsubscribe from topics with no more subscribers
for topic in topics_to_check:
if topic in self.subscriptions and not self.subscriptions[topic]:
del self.subscriptions[topic]
if self.client and self.connected:
try:
self.client.unsubscribe(topic)
logger.info(f"MQTT smart plug: unsubscribed from {topic}")
except Exception as e:
logger.error(f"MQTT smart plug: failed to unsubscribe from {topic}: {e}")
# Remove data
self.plug_data.pop(plug_id, None)
def get_plug_data(self, plug_id: int) -> SmartPlugMQTTData | None:
"""Get latest data for a plug (called by status endpoint)."""
with self._lock:
return self.plug_data.get(plug_id)
def is_reachable(self, plug_id: int) -> bool:
"""Check if a plug has received data recently."""
data = self.get_plug_data(plug_id)
if not data:
return False
timeout = timedelta(minutes=self.REACHABLE_TIMEOUT_MINUTES)
return datetime.utcnow() - data.last_seen < timeout
async def disconnect(self):
"""Disconnect from MQTT broker."""
if self.client:
try:
self.client.loop_stop()
self.client.disconnect()
except Exception as e:
logger.debug(f"MQTT smart plug disconnect error (ignored): {e}")
finally:
self.client = None
self.connected = False
# Global instance
mqtt_smart_plug_service = MQTTSmartPlugService()
+8 -4
View File
@@ -17,7 +17,7 @@ from backend.app.models.smart_plug import SmartPlug
from backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry
from backend.app.services.notification_service import notification_service
from backend.app.services.printer_manager import printer_manager
from backend.app.services.tasmota import tasmota_service
from backend.app.services.smart_plug_manager import smart_plug_manager
from backend.app.utils.printer_models import normalize_printer_model
logger = logging.getLogger(__name__)
@@ -348,15 +348,18 @@ class PrintScheduler:
Returns True if printer connected successfully within timeout.
"""
# Get the appropriate service for the plug type (Tasmota or Home Assistant)
service = await smart_plug_manager.get_service_for_plug(plug, db)
# Check current plug state
status = await tasmota_service.get_status(plug)
status = await service.get_status(plug)
if not status.get("reachable"):
logger.warning(f"Smart plug '{plug.name}' is not reachable")
return False
# Turn on if not already on
if status.get("state") != "ON":
success = await tasmota_service.turn_on(plug)
success = await service.turn_on(plug)
if not success:
logger.warning(f"Failed to turn on smart plug '{plug.name}'")
return False
@@ -425,7 +428,8 @@ class PrintScheduler:
# Wait for cooldown (up to 10 minutes)
await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600)
logger.info(f"Auto-off: Powering off printer {item.printer_id}")
await tasmota_service.turn_off(plug)
service = await smart_plug_manager.get_service_for_plug(plug, db)
await service.turn_off(plug)
async def _get_job_name(self, db: AsyncSession, item: PrintQueueItem) -> str:
"""Get a human-readable name for a queue item."""
+11 -6
View File
@@ -27,7 +27,7 @@ class SmartPlugManager:
self._scheduler_task: asyncio.Task | None = None
self._last_schedule_check: dict[int, str] = {} # plug_id -> "HH:MM" last executed
async def _get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
async def get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
"""Get the appropriate service for the plug type.
For HA plugs, configures the service with current settings from DB.
@@ -110,7 +110,7 @@ class SmartPlugManager:
plugs = result.scalars().all()
for plug in plugs:
service = await self._get_service_for_plug(plug, db)
service = await self.get_service_for_plug(plug, db)
# Check if we should turn on
if plug.schedule_on_time == current_time:
@@ -166,7 +166,7 @@ class SmartPlugManager:
# Turn on the plug
logger.info(f"Print started on printer {printer_id}, turning on plug '{plug.name}'")
service = await self._get_service_for_plug(plug, db)
service = await self.get_service_for_plug(plug, db)
success = await service.turn_on(plug)
if success:
@@ -195,6 +195,11 @@ class SmartPlugManager:
logger.debug(f"Smart plug '{plug.name}' auto_off is disabled")
return
# Skip auto-off for HA script entities (scripts can only be triggered, not turned off)
if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
logger.debug(f"Smart plug '{plug.name}' is a HA script entity, skipping auto-off")
return
# Only auto-off on successful completion, not on failures
# This allows the user to investigate errors before power-off
if status != "completed":
@@ -261,7 +266,7 @@ class SmartPlugManager:
self.name = f"plug_{plug_id}"
plug_info = PlugInfo()
service = await self._get_service_for_plug(plug_info)
service = await self.get_service_for_plug(plug_info)
success = await service.turn_off(plug_info)
logger.info(f"Turned off plug {plug_id} after time delay")
@@ -353,7 +358,7 @@ class SmartPlugManager:
self.name = f"plug_{plug_id}"
plug_info = PlugInfo()
service = await self._get_service_for_plug(plug_info)
service = await self.get_service_for_plug(plug_info)
success = await service.turn_off(plug_info)
logger.info(
f"Turned off plug {plug_id} after nozzle temp dropped to "
@@ -474,7 +479,7 @@ class SmartPlugManager:
# For time mode, just turn off immediately since delay already passed
logger.info(f"Time-based auto-off was pending, turning off plug '{plug.name}' now")
service = await self._get_service_for_plug(plug, db)
service = await self.get_service_for_plug(plug, db)
success = await service.turn_off(plug)
if success:
await self._mark_auto_off_executed(plug.id)
+34
View File
@@ -222,6 +222,24 @@ def mock_mqtt_client():
yield mock
@pytest.fixture
def mock_mqtt_smart_plug_service():
"""Mock the MQTT smart plug service for MQTT plug tests."""
with patch("backend.app.api.routes.smart_plugs.mqtt_relay") as mock:
# Create a mock smart_plug_service
mock_service = MagicMock()
mock_service.is_configured = MagicMock(return_value=True)
mock_service.has_broker_settings = MagicMock(return_value=True)
mock_service.configure = AsyncMock(return_value=True)
mock_service.subscribe = MagicMock()
mock_service.unsubscribe = MagicMock()
mock_service.get_plug_data = MagicMock(return_value=None)
mock_service.is_reachable = MagicMock(return_value=False)
mock.smart_plug_service = mock_service
yield mock
@pytest.fixture
def mock_ftp_client():
"""Mock the FTP client for file transfer tests."""
@@ -302,6 +320,22 @@ def smart_plug_factory(db_session):
if plug_type == "homeassistant":
defaults["ha_entity_id"] = "switch.test"
defaults["ip_address"] = None
elif plug_type == "mqtt":
# Legacy fields (for backward compatibility tests)
defaults["mqtt_topic"] = kwargs.get("mqtt_topic", "test/topic")
defaults["mqtt_multiplier"] = kwargs.get("mqtt_multiplier", 1.0)
# New separate topic/path/multiplier fields
defaults["mqtt_power_topic"] = kwargs.get("mqtt_power_topic")
defaults["mqtt_power_path"] = kwargs.get("mqtt_power_path", "power")
defaults["mqtt_power_multiplier"] = kwargs.get("mqtt_power_multiplier", 1.0)
defaults["mqtt_energy_topic"] = kwargs.get("mqtt_energy_topic")
defaults["mqtt_energy_path"] = kwargs.get("mqtt_energy_path")
defaults["mqtt_energy_multiplier"] = kwargs.get("mqtt_energy_multiplier", 1.0)
defaults["mqtt_state_topic"] = kwargs.get("mqtt_state_topic")
defaults["mqtt_state_path"] = kwargs.get("mqtt_state_path")
defaults["mqtt_state_on_value"] = kwargs.get("mqtt_state_on_value")
defaults["ip_address"] = None
defaults["ha_entity_id"] = None
else:
defaults["ip_address"] = "192.168.1.100"
defaults["ha_entity_id"] = None
@@ -434,3 +434,127 @@ class TestArchiveF3DEndpoints:
"""Verify filament-requirements with plate_id returns 404 for non-existent archive."""
response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1")
assert response.status_code == 404
# ========================================================================
# Tag Management endpoints (Issue #183)
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_tags_empty(self, async_client: AsyncClient):
"""Verify empty list when no tags exist."""
response = await async_client.get("/api/v1/archives/tags")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) == 0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_tags_with_data(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
"""Verify tags are returned with counts."""
printer = await printer_factory()
await archive_factory(printer.id, print_name="Archive 1", tags="functional, test")
await archive_factory(printer.id, print_name="Archive 2", tags="functional, calibration")
await archive_factory(printer.id, print_name="Archive 3", tags="test")
response = await async_client.get("/api/v1/archives/tags")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
# Convert to dict for easier lookup
tags_dict = {t["name"]: t["count"] for t in data}
assert tags_dict.get("functional") == 2
assert tags_dict.get("test") == 2
assert tags_dict.get("calibration") == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_tags_sorted_by_count(
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Verify tags are sorted by count descending, then by name."""
printer = await printer_factory()
await archive_factory(printer.id, tags="alpha")
await archive_factory(printer.id, tags="beta, alpha")
await archive_factory(printer.id, tags="gamma, beta, alpha")
response = await async_client.get("/api/v1/archives/tags")
assert response.status_code == 200
data = response.json()
# alpha=3, beta=2, gamma=1
assert data[0]["name"] == "alpha"
assert data[0]["count"] == 3
assert data[1]["name"] == "beta"
assert data[1]["count"] == 2
assert data[2]["name"] == "gamma"
assert data[2]["count"] == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rename_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
"""Verify renaming a tag updates all archives."""
printer = await printer_factory()
a1 = await archive_factory(printer.id, print_name="Archive 1", tags="old-tag, other")
a2 = await archive_factory(printer.id, print_name="Archive 2", tags="old-tag")
await archive_factory(printer.id, print_name="Archive 3", tags="different")
response = await async_client.put("/api/v1/archives/tags/old-tag", json={"new_name": "new-tag"})
assert response.status_code == 200
data = response.json()
assert data["affected"] == 2
# Verify the archives were updated
response = await async_client.get(f"/api/v1/archives/{a1.id}")
assert "new-tag" in response.json()["tags"]
assert "old-tag" not in response.json()["tags"]
response = await async_client.get(f"/api/v1/archives/{a2.id}")
assert response.json()["tags"] == "new-tag"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rename_tag_no_change(self, async_client: AsyncClient):
"""Verify renaming to same name returns 0 affected."""
response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": "some-tag"})
assert response.status_code == 200
assert response.json()["affected"] == 0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rename_tag_empty_name_error(self, async_client: AsyncClient):
"""Verify renaming to empty name returns error."""
response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": ""})
assert response.status_code == 400
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
"""Verify deleting a tag removes it from all archives."""
printer = await printer_factory()
a1 = await archive_factory(printer.id, print_name="Archive 1", tags="delete-me, keep")
a2 = await archive_factory(printer.id, print_name="Archive 2", tags="delete-me")
await archive_factory(printer.id, print_name="Archive 3", tags="different")
response = await async_client.delete("/api/v1/archives/tags/delete-me")
assert response.status_code == 200
data = response.json()
assert data["affected"] == 2
# Verify the archives were updated
response = await async_client.get(f"/api/v1/archives/{a1.id}")
assert response.json()["tags"] == "keep"
response = await async_client.get(f"/api/v1/archives/{a2.id}")
# Should be None or empty when last tag is removed
assert response.json()["tags"] is None or response.json()["tags"] == ""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_tag_not_found(self, async_client: AsyncClient):
"""Verify deleting non-existent tag returns 0 affected."""
response = await async_client.delete("/api/v1/archives/tags/nonexistent-tag")
assert response.status_code == 200
assert response.json()["affected"] == 0
@@ -573,3 +573,255 @@ class TestSmartPlugsAPI:
assert response.status_code == 400
assert "not configured" in response.json()["detail"].lower()
# ========================================================================
# MQTT Integration tests
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_mqtt_plug(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
"""Verify MQTT plug can be created with topic and JSON paths."""
data = {
"name": "MQTT Energy Monitor",
"plug_type": "mqtt",
"mqtt_topic": "zigbee2mqtt/shelly-working-room",
"mqtt_power_path": "power_l1",
"mqtt_energy_path": "energy_l1",
"mqtt_state_path": "state_l1",
"mqtt_multiplier": 1.0,
"enabled": True,
}
response = await async_client.post("/api/v1/smart-plugs/", json=data)
assert response.status_code == 200
result = response.json()
assert result["name"] == "MQTT Energy Monitor"
assert result["plug_type"] == "mqtt"
assert result["mqtt_topic"] == "zigbee2mqtt/shelly-working-room"
assert result["mqtt_power_path"] == "power_l1"
assert result["mqtt_energy_path"] == "energy_l1"
assert result["mqtt_state_path"] == "state_l1"
assert result["mqtt_multiplier"] == 1.0
assert result["ip_address"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient):
"""Verify creating MQTT plug without topic fails."""
data = {
"name": "MQTT Plug",
"plug_type": "mqtt",
# Missing mqtt_topic
"mqtt_power_path": "power",
"enabled": True,
}
response = await async_client.post("/api/v1/smart-plugs/", json=data)
assert response.status_code == 422 # Validation error
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient):
"""Verify creating MQTT plug without any topic fails."""
data = {
"name": "MQTT Plug",
"plug_type": "mqtt",
# No topic configured at all
"enabled": True,
}
response = await async_client.post("/api/v1/smart-plugs/", json=data)
assert response.status_code == 422 # Validation error
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_mqtt_plug_with_multiplier(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
"""Verify MQTT plug can use multiplier for unit conversion."""
data = {
"name": "MQTT mW to W",
"plug_type": "mqtt",
"mqtt_topic": "sensors/power",
"mqtt_power_path": "power_mw",
"mqtt_multiplier": 0.001, # Convert mW to W
"enabled": True,
}
response = await async_client.post("/api/v1/smart-plugs/", json=data)
assert response.status_code == 200
result = response.json()
assert result["mqtt_multiplier"] == 0.001
@pytest.mark.asyncio
@pytest.mark.integration
async def test_control_mqtt_plug_returns_error(self, async_client: AsyncClient, smart_plug_factory, db_session):
"""Verify MQTT plugs cannot be controlled (monitor-only)."""
plug = await smart_plug_factory(
plug_type="mqtt",
mqtt_topic="test/topic",
mqtt_power_path="power",
)
response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "on"})
assert response.status_code == 400
assert "monitor-only" in response.json()["detail"].lower()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_mqtt_plug_topic(self, async_client: AsyncClient, smart_plug_factory, db_session):
"""Verify MQTT plug topic can be updated."""
plug = await smart_plug_factory(
plug_type="mqtt",
mqtt_topic="old/topic",
mqtt_power_path="power",
)
response = await async_client.patch(
f"/api/v1/smart-plugs/{plug.id}",
json={
"mqtt_topic": "new/topic",
"mqtt_power_path": "new_power",
},
)
assert response.status_code == 200
result = response.json()
assert result["mqtt_topic"] == "new/topic"
assert result["mqtt_power_path"] == "new_power"
# ========================================================================
# Enhanced MQTT Integration tests (separate topics per data type)
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_mqtt_plug_with_separate_topics(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
"""Verify MQTT plug can be created with separate topics for power, energy, and state."""
data = {
"name": "MQTT Separate Topics",
"plug_type": "mqtt",
"mqtt_power_topic": "zigbee/power",
"mqtt_power_path": "power_l1",
"mqtt_power_multiplier": 0.001,
"mqtt_energy_topic": "zigbee/energy",
"mqtt_energy_path": "energy_total",
"mqtt_energy_multiplier": 1.0,
"mqtt_state_topic": "zigbee/state",
"mqtt_state_path": "state",
"mqtt_state_on_value": "ON",
"enabled": True,
}
response = await async_client.post("/api/v1/smart-plugs/", json=data)
assert response.status_code == 200
result = response.json()
assert result["name"] == "MQTT Separate Topics"
assert result["plug_type"] == "mqtt"
# Power fields
assert result["mqtt_power_topic"] == "zigbee/power"
assert result["mqtt_power_path"] == "power_l1"
assert result["mqtt_power_multiplier"] == 0.001
# Energy fields
assert result["mqtt_energy_topic"] == "zigbee/energy"
assert result["mqtt_energy_path"] == "energy_total"
assert result["mqtt_energy_multiplier"] == 1.0
# State fields
assert result["mqtt_state_topic"] == "zigbee/state"
assert result["mqtt_state_path"] == "state"
assert result["mqtt_state_on_value"] == "ON"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_mqtt_plug_energy_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
"""Verify MQTT plug can be created with only energy monitoring."""
data = {
"name": "Energy Only Monitor",
"plug_type": "mqtt",
"mqtt_energy_topic": "sensors/energy",
"mqtt_energy_path": "kwh",
"mqtt_energy_multiplier": 0.001, # Wh to kWh
"enabled": True,
}
response = await async_client.post("/api/v1/smart-plugs/", json=data)
assert response.status_code == 200
result = response.json()
assert result["mqtt_energy_topic"] == "sensors/energy"
assert result["mqtt_energy_path"] == "kwh"
assert result["mqtt_energy_multiplier"] == 0.001
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_mqtt_plug_state_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
"""Verify MQTT plug can be created with only state monitoring."""
data = {
"name": "State Only Monitor",
"plug_type": "mqtt",
"mqtt_state_topic": "switches/outlet",
"mqtt_state_path": "state",
"mqtt_state_on_value": "true",
"enabled": True,
}
response = await async_client.post("/api/v1/smart-plugs/", json=data)
assert response.status_code == 200
result = response.json()
assert result["mqtt_state_topic"] == "switches/outlet"
assert result["mqtt_state_path"] == "state"
assert result["mqtt_state_on_value"] == "true"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_mqtt_plug_topic_only_succeeds(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
"""Verify creating MQTT plug with topic only (no path) succeeds for raw values."""
data = {
"name": "Raw MQTT Plug",
"plug_type": "mqtt",
# Topic only, no path - valid for raw numeric MQTT values
"mqtt_power_topic": "zigbee/power",
"enabled": True,
}
response = await async_client.post("/api/v1/smart-plugs/", json=data)
assert response.status_code == 200 # Should succeed
result = response.json()
assert result["mqtt_power_topic"] == "zigbee/power"
assert result["mqtt_power_path"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_mqtt_plug_separate_multipliers(
self, async_client: AsyncClient, smart_plug_factory, db_session, mock_mqtt_smart_plug_service
):
"""Verify MQTT plug multipliers can be updated separately."""
plug = await smart_plug_factory(
plug_type="mqtt",
mqtt_power_topic="test/power",
mqtt_power_path="power",
mqtt_power_multiplier=1.0,
mqtt_energy_topic="test/energy",
mqtt_energy_path="energy",
mqtt_energy_multiplier=1.0,
)
response = await async_client.patch(
f"/api/v1/smart-plugs/{plug.id}",
json={
"mqtt_power_multiplier": 0.001, # Change power multiplier only
"mqtt_energy_multiplier": 0.001, # Change energy multiplier only
},
)
assert response.status_code == 200
result = response.json()
assert result["mqtt_power_multiplier"] == 0.001
assert result["mqtt_energy_multiplier"] == 0.001
+48 -18
View File
@@ -1,31 +1,61 @@
# BamBuddy Systemd Service Template
#
# INSTALLATION:
# 1. Copy this file to /etc/systemd/system/bambuddy.service
# 2. Replace placeholders:
# - INSTALL_PATH: Where BamBuddy is installed (e.g., /opt/bambuddy)
# - SERVICE_USER: User to run as (e.g., bambuddy)
# - DATA_DIR: Data directory (e.g., /opt/bambuddy/data)
# - LOG_DIR: Log directory (e.g., /opt/bambuddy/logs)
# 3. Run: sudo systemctl daemon-reload
# 4. Run: sudo systemctl enable bambuddy
# 5. Run: sudo systemctl start bambuddy
#
# Or use the install script: ./install/install.sh
#
[Unit]
Description=BamBuddy Print Archive
Description=BamBuddy - Bambu Lab Print Management
Documentation=https://github.com/maziggy/bambuddy
After=network.target
[Service]
Type=simple
User=claude
Group=claude
WorkingDirectory=<dir>/bambuddy
Environment="PATH=<dir/bambuddy/venv/bin"
User=SERVICE_USER
Group=SERVICE_USER
WorkingDirectory=INSTALL_PATH
# Force kill after 10 seconds if graceful shutdown fails
# Environment file (optional - created by install script)
EnvironmentFile=-INSTALL_PATH/.env
# Use virtual environment
Environment="PATH=INSTALL_PATH/venv/bin:/usr/local/bin:/usr/bin:/bin"
# Server configuration
ExecStart=INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000}
# Restart policy
Restart=on-failure
RestartSec=5
# Graceful shutdown
TimeoutStopSec=10
# Kill any zombie ffmpeg processes before starting/after stopping
ExecStartPre=-/usr/bin/pkill -9 ffmpeg
ExecStopPost=-/usr/bin/pkill -9 ffmpeg
# Kill zombie ffmpeg processes (timelapse processing)
ExecStartPre=-/usr/bin/pkill -9 -f "ffmpeg.*bambuddy"
ExecStopPost=-/usr/bin/pkill -9 -f "ffmpeg.*bambuddy"
# Ensure directories exist and have correct permissions before starting
# The + prefix runs the command as root even though User=claude
ExecStartPre=+/bin/mkdir -p <dir>/bambuddy/logs
ExecStartPre=+/bin/mkdir -p <dir>/bambuddy/archive
ExecStartPre=+/bin/chown -R <user>:<user> <dir>/bambuddy/logs
ExecStartPre=+/bin/chown -R <user>:<user> <dir>/bambuddy/archive
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=bambuddy
ExecStart=<dir>/bambuddy/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=10
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=DATA_DIR LOG_DIR INSTALL_PATH
[Install]
WantedBy=multi-user.target
+4
View File
@@ -12,6 +12,7 @@ import { ProjectsPage } from './pages/ProjectsPage';
import { ProjectDetailPage } from './pages/ProjectDetailPage';
import { FileManagerPage } from './pages/FileManagerPage';
import { CameraPage } from './pages/CameraPage';
import { StreamOverlayPage } from './pages/StreamOverlayPage';
import { ExternalLinkPage } from './pages/ExternalLinkPage';
import { SystemInfoPage } from './pages/SystemInfoPage';
import { LoginPage } from './pages/LoginPage';
@@ -108,6 +109,9 @@ function App() {
{/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
<Route path="/camera/:printerId" element={<CameraPage />} />
{/* Stream overlay page - standalone for OBS/streaming embeds, no auth required */}
<Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
{/* Main app with WebSocket for real-time updates */}
<Route element={<ProtectedRoute><WebSocketProvider><Layout /></WebSocketProvider></ProtectedRoute>}>
<Route index element={<PrintersPage />} />
@@ -38,7 +38,11 @@ describe('EditArchiveModal', () => {
return HttpResponse.json(mockProjects);
}),
http.get('/api/v1/archives/tags', () => {
return HttpResponse.json(['test', 'calibration', 'functional']);
return HttpResponse.json([
{ name: 'test', count: 2 },
{ name: 'calibration', count: 1 },
{ name: 'functional', count: 3 },
]);
}),
http.patch('/api/v1/archives/:id', async ({ request }) => {
const body = await request.json();
@@ -21,6 +21,24 @@ const createMockPlug = (overrides: Partial<SmartPlug> = {}): SmartPlug => ({
plug_type: 'tasmota',
ip_address: '192.168.1.100',
ha_entity_id: null,
ha_power_entity: null,
ha_energy_today_entity: null,
ha_energy_total_entity: null,
// MQTT fields (legacy)
mqtt_topic: null,
mqtt_multiplier: 1.0,
// MQTT power fields
mqtt_power_topic: null,
mqtt_power_path: null,
mqtt_power_multiplier: 1.0,
// MQTT energy fields
mqtt_energy_topic: null,
mqtt_energy_path: null,
mqtt_energy_multiplier: 1.0,
// MQTT state fields
mqtt_state_topic: null,
mqtt_state_path: null,
mqtt_state_on_value: null,
printer_id: 1,
enabled: true,
auto_on: true,
@@ -272,4 +290,73 @@ describe('SmartPlugCard', () => {
expect(buttons.length).toBeGreaterThan(0);
});
});
describe('MQTT plugs', () => {
it('renders MQTT plug with topic instead of IP', () => {
const plug = createMockPlug({
plug_type: 'mqtt',
ip_address: null,
mqtt_topic: 'zigbee2mqtt/shelly-power',
mqtt_power_path: 'power_l1',
});
render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
// Should show topic, not IP
expect(screen.getByText('zigbee2mqtt/shelly-power')).toBeInTheDocument();
expect(screen.queryByText('192.168.1.100')).not.toBeInTheDocument();
});
it('renders MQTT plug name correctly', () => {
const plug = createMockPlug({
name: 'MQTT Energy Monitor',
plug_type: 'mqtt',
ip_address: null,
mqtt_topic: 'sensors/power',
mqtt_power_path: 'power',
});
render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
expect(screen.getByText('MQTT Energy Monitor')).toBeInTheDocument();
});
it('shows Monitor Only badge for MQTT plug', () => {
const plug = createMockPlug({
plug_type: 'mqtt',
ip_address: null,
mqtt_topic: 'test/topic',
mqtt_power_path: 'power',
});
render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
expect(screen.getByText('Monitor Only')).toBeInTheDocument();
});
it('does not show power control buttons for MQTT plug', () => {
const plug = createMockPlug({
plug_type: 'mqtt',
ip_address: null,
mqtt_topic: 'test/topic',
mqtt_power_path: 'power',
});
render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
// On/Off buttons should not be present for monitor-only plugs
expect(screen.queryByRole('button', { name: /^on$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^off$/i })).not.toBeInTheDocument();
});
it('shows Settings instead of Automation Settings for MQTT plug', async () => {
const plug = createMockPlug({
plug_type: 'mqtt',
ip_address: null,
mqtt_topic: 'test/topic',
mqtt_power_path: 'power',
});
render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
// Should show "Settings" not "Automation Settings"
expect(screen.getByText('Settings')).toBeInTheDocument();
expect(screen.queryByText('Automation Settings')).not.toBeInTheDocument();
});
});
});
@@ -0,0 +1,300 @@
/**
* Tests for the TagManagementModal component.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { TagManagementModal } from '../../components/TagManagementModal';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const mockTags = [
{ name: 'functional', count: 5 },
{ name: 'calibration', count: 3 },
{ name: 'test', count: 2 },
{ name: 'art', count: 1 },
];
describe('TagManagementModal', () => {
const mockOnClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
server.use(
http.get('/api/v1/archives/tags', () => {
return HttpResponse.json(mockTags);
}),
http.put('/api/v1/archives/tags/:tagName', async () => {
return HttpResponse.json({ affected: 2 });
}),
http.delete('/api/v1/archives/tags/:tagName', () => {
return HttpResponse.json({ affected: 1 });
})
);
});
describe('rendering', () => {
it('renders the modal title', async () => {
render(<TagManagementModal onClose={mockOnClose} />);
expect(screen.getByText('Manage Tags')).toBeInTheDocument();
});
it('shows loading state initially', () => {
render(<TagManagementModal onClose={mockOnClose} />);
// Should show loading spinner before data loads
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
});
it('displays tags with counts', async () => {
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('functional')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('calibration')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
});
});
it('shows total tag count and usage', async () => {
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
// 4 tags, 11 total usages
expect(screen.getByText(/4 tags across 11 usages/i)).toBeInTheDocument();
});
});
});
describe('search functionality', () => {
it('filters tags by search input', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('functional')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText('Search tags...');
await user.type(searchInput, 'cal');
await waitFor(() => {
expect(screen.getByText('calibration')).toBeInTheDocument();
expect(screen.queryByText('functional')).not.toBeInTheDocument();
expect(screen.queryByText('art')).not.toBeInTheDocument();
});
});
it('shows no results message when search has no matches', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('functional')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText('Search tags...');
await user.type(searchInput, 'nonexistent');
await waitFor(() => {
expect(screen.getByText('No tags match your search')).toBeInTheDocument();
});
});
});
describe('sorting', () => {
it('sorts by count by default', async () => {
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
const tagElements = screen.getAllByText(/functional|calibration|test|art/);
// First should be functional (count 5)
expect(tagElements[0]).toHaveTextContent('functional');
});
});
it('can sort by name', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('functional')).toBeInTheDocument();
});
const sortSelect = screen.getByDisplayValue('Sort by Count');
await user.selectOptions(sortSelect, 'name');
await waitFor(() => {
const tagElements = screen.getAllByText(/functional|calibration|test|art/);
// First should be 'art' alphabetically
expect(tagElements[0]).toHaveTextContent('art');
});
});
});
describe('rename functionality', () => {
it('enters edit mode when clicking edit button', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('functional')).toBeInTheDocument();
});
// Find the tag row and click its edit button
const tagRow = screen.getByText('functional').closest('div');
const editButton = within(tagRow!).getByTitle('Rename tag');
await user.click(editButton);
// Should show input with current value
await waitFor(() => {
expect(screen.getByDisplayValue('functional')).toBeInTheDocument();
});
});
it('submits rename on Enter key', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('functional')).toBeInTheDocument();
});
const tagRow = screen.getByText('functional').closest('div');
const editButton = within(tagRow!).getByTitle('Rename tag');
await user.click(editButton);
const input = screen.getByDisplayValue('functional');
await user.clear(input);
await user.type(input, 'new-name{Enter}');
// Should show success (mutation called)
await waitFor(() => {
// After successful rename, edit mode should close
expect(screen.queryByDisplayValue('new-name')).not.toBeInTheDocument();
});
});
it('cancels edit on Escape key', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('functional')).toBeInTheDocument();
});
const tagRow = screen.getByText('functional').closest('div');
const editButton = within(tagRow!).getByTitle('Rename tag');
await user.click(editButton);
const input = screen.getByDisplayValue('functional');
await user.type(input, '-modified{Escape}');
// Should exit edit mode without saving
await waitFor(() => {
expect(screen.queryByDisplayValue('functional-modified')).not.toBeInTheDocument();
expect(screen.getByText('functional')).toBeInTheDocument();
});
});
});
describe('delete functionality', () => {
it('shows delete confirmation when clicking delete button', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('functional')).toBeInTheDocument();
});
const tagRow = screen.getByText('functional').closest('div');
const deleteButton = within(tagRow!).getByTitle('Delete tag');
await user.click(deleteButton);
await waitFor(() => {
expect(screen.getByText(/Delete "functional" from 5 archives?/i)).toBeInTheDocument();
});
});
it('cancels delete confirmation on X button', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('functional')).toBeInTheDocument();
});
const tagRow = screen.getByText('functional').closest('div');
const deleteButton = within(tagRow!).getByTitle('Delete tag');
await user.click(deleteButton);
await waitFor(() => {
expect(screen.getByText(/Delete "functional"/i)).toBeInTheDocument();
});
// Find the confirmation row and click the cancel (X) button within it
const confirmationText = screen.getByText(/Delete "functional"/i);
const confirmationRow = confirmationText.closest('div');
// The X button is the last button in the confirmation row
const buttons = within(confirmationRow!.parentElement!).getAllByRole('button');
const cancelButton = buttons[buttons.length - 1]; // X button is last
await user.click(cancelButton);
await waitFor(() => {
// Should return to normal display - the tag name should be visible again
expect(screen.getByText('functional')).toBeInTheDocument();
});
});
});
describe('modal behavior', () => {
it('calls onClose when close button is clicked', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('Manage Tags')).toBeInTheDocument();
});
// Find close button in header (X icon)
const headerCloseButton = screen.getAllByRole('button')[0];
await user.click(headerCloseButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('calls onClose when Close button is clicked', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('Manage Tags')).toBeInTheDocument();
});
const closeButton = screen.getByRole('button', { name: /close/i });
await user.click(closeButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});
describe('empty state', () => {
it('shows empty message when no tags exist', async () => {
server.use(
http.get('/api/v1/archives/tags', () => {
return HttpResponse.json([]);
})
);
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('No tags found')).toBeInTheDocument();
});
});
});
});
@@ -0,0 +1,230 @@
/**
* Tests for the StreamOverlayPage component.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { screen, waitFor, render as rtlRender } from '@testing-library/react';
import { StreamOverlayPage } from '../../pages/StreamOverlayPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '../../contexts/ThemeContext';
import { ToastProvider } from '../../contexts/ToastContext';
const mockPrinter = {
id: 1,
name: 'X1 Carbon',
ip_address: '192.168.1.100',
serial_number: '00M09A350100001',
access_code: '12345678',
model: 'X1C',
enabled: true,
};
const mockStatusIdle = {
id: 1,
name: 'X1 Carbon',
connected: true,
state: 'IDLE',
progress: 0,
current_print: null,
remaining_time: null,
layer_num: null,
total_layers: null,
stg_cur_name: null,
};
const mockStatusPrinting = {
id: 1,
name: 'X1 Carbon',
connected: true,
state: 'RUNNING',
progress: 45,
current_print: 'Benchy.gcode.3mf',
remaining_time: 82,
layer_num: 150,
total_layers: 300,
stg_cur_name: null,
};
// Custom render for StreamOverlayPage
function renderOverlayPage(printerId: number, queryParams = '') {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
return rtlRender(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`/overlay/${printerId}${queryParams}`]}>
<ThemeProvider>
<ToastProvider>
<Routes>
<Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
</Routes>
</ToastProvider>
</ThemeProvider>
</MemoryRouter>
</QueryClientProvider>
);
}
describe('StreamOverlayPage', () => {
const originalTitle = document.title;
beforeEach(() => {
// Mock WebSocket
vi.stubGlobal('WebSocket', vi.fn().mockImplementation(() => ({
close: vi.fn(),
onmessage: null,
onerror: null,
})));
server.use(
http.get('/api/v1/printers/:id', () => {
return HttpResponse.json(mockPrinter);
}),
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json(mockStatusIdle);
})
);
});
afterEach(() => {
document.title = originalTitle;
vi.unstubAllGlobals();
});
describe('rendering', () => {
it('renders overlay page for printer', async () => {
renderOverlayPage(1);
await waitFor(() => {
expect(screen.getByText('Printer is idle')).toBeInTheDocument();
});
});
it('shows Bambuddy logo', async () => {
renderOverlayPage(1);
await waitFor(() => {
expect(screen.getByAltText('Bambuddy')).toBeInTheDocument();
});
});
it('logo links to GitHub', async () => {
renderOverlayPage(1);
await waitFor(() => {
const logo = screen.getByAltText('Bambuddy');
const link = logo.closest('a');
expect(link).toHaveAttribute('href', 'https://github.com/maziggy/bambuddy');
});
});
});
describe('printing state', () => {
beforeEach(() => {
server.use(
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json(mockStatusPrinting);
})
);
});
it('shows filename when printing', async () => {
renderOverlayPage(1);
await waitFor(() => {
expect(screen.getByText('Benchy')).toBeInTheDocument();
});
});
it('shows progress percentage', async () => {
renderOverlayPage(1);
await waitFor(() => {
expect(screen.getByText('45%')).toBeInTheDocument();
});
});
it('shows layer count', async () => {
renderOverlayPage(1);
await waitFor(() => {
expect(screen.getByText('150')).toBeInTheDocument();
expect(screen.getByText('300')).toBeInTheDocument();
});
});
it('shows status text', async () => {
renderOverlayPage(1);
await waitFor(() => {
expect(screen.getByText('Printing')).toBeInTheDocument();
});
});
});
describe('invalid printer', () => {
it('shows invalid printer message for ID 0', async () => {
renderOverlayPage(0);
await waitFor(() => {
expect(screen.getByText('Invalid printer ID')).toBeInTheDocument();
});
});
});
describe('query parameters', () => {
it('respects size parameter', async () => {
renderOverlayPage(1, '?size=large');
await waitFor(() => {
// Just verify it renders without error
expect(screen.getByAltText('Bambuddy')).toBeInTheDocument();
});
});
it('respects show parameter to hide elements', async () => {
server.use(
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json(mockStatusPrinting);
})
);
renderOverlayPage(1, '?show=progress');
await waitFor(() => {
// Progress should be visible
expect(screen.getByText('45%')).toBeInTheDocument();
// Status text should be hidden when not in show list
expect(screen.queryByText('Printing')).not.toBeInTheDocument();
});
});
});
describe('offline state', () => {
beforeEach(() => {
server.use(
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json({
...mockStatusIdle,
connected: false,
});
})
);
});
it('shows offline message when printer disconnected', async () => {
renderOverlayPage(1);
await waitFor(() => {
expect(screen.getByText('Printer offline')).toBeInTheDocument();
});
});
});
});
+75 -8
View File
@@ -341,6 +341,11 @@ export interface ArchiveStats {
total_energy_cost: number;
}
export interface TagInfo {
name: string;
count: number;
}
export interface FailureAnalysis {
period_days: number;
total_prints: number;
@@ -847,13 +852,29 @@ export interface CloudDevice {
export interface SmartPlug {
id: number;
name: string;
plug_type: 'tasmota' | 'homeassistant';
plug_type: 'tasmota' | 'homeassistant' | 'mqtt';
ip_address: string | null; // Required for Tasmota
ha_entity_id: string | null; // Required for Home Assistant (e.g., "switch.printer_plug")
ha_entity_id: string | null; // Required for Home Assistant (e.g., "switch.printer_plug", "script.turn_on_printer")
// Home Assistant energy sensor entities (optional)
ha_power_entity: string | null;
ha_energy_today_entity: string | null;
ha_energy_total_entity: string | null;
// MQTT fields (required when plug_type="mqtt")
// Legacy field - kept for backward compatibility
mqtt_topic: string | null; // Deprecated, use mqtt_power_topic
mqtt_multiplier: number; // Deprecated, use mqtt_power_multiplier
// Power monitoring
mqtt_power_topic: string | null; // Topic for power data
mqtt_power_path: string | null; // e.g., "power_l1" or "data.power"
mqtt_power_multiplier: number; // Unit conversion for power
// Energy monitoring
mqtt_energy_topic: string | null; // Topic for energy data
mqtt_energy_path: string | null; // e.g., "energy_l1"
mqtt_energy_multiplier: number; // Unit conversion for energy
// State monitoring
mqtt_state_topic: string | null; // Topic for state data
mqtt_state_path: string | null; // e.g., "state_l1" for ON/OFF
mqtt_state_on_value: string | null; // What value means "ON" (e.g., "ON", "true", "1")
printer_id: number | null;
enabled: boolean;
auto_on: boolean;
@@ -872,8 +893,9 @@ export interface SmartPlug {
schedule_enabled: boolean;
schedule_on_time: string | null;
schedule_off_time: string | null;
// Switchbar visibility
// Visibility options
show_in_switchbar: boolean;
show_on_printer_card: boolean; // For scripts: show on printer card
// Status
last_state: string | null;
last_checked: string | null;
@@ -884,13 +906,29 @@ export interface SmartPlug {
export interface SmartPlugCreate {
name: string;
plug_type?: 'tasmota' | 'homeassistant';
plug_type?: 'tasmota' | 'homeassistant' | 'mqtt';
ip_address?: string | null; // Required for Tasmota
ha_entity_id?: string | null; // Required for Home Assistant
// Home Assistant energy sensor entities (optional)
ha_power_entity?: string | null;
ha_energy_today_entity?: string | null;
ha_energy_total_entity?: string | null;
// MQTT fields (required when plug_type="mqtt")
// Legacy fields - kept for backward compatibility
mqtt_topic?: string | null;
mqtt_multiplier?: number;
// Power monitoring
mqtt_power_topic?: string | null;
mqtt_power_path?: string | null;
mqtt_power_multiplier?: number;
// Energy monitoring
mqtt_energy_topic?: string | null;
mqtt_energy_path?: string | null;
mqtt_energy_multiplier?: number;
// State monitoring
mqtt_state_topic?: string | null;
mqtt_state_path?: string | null;
mqtt_state_on_value?: string | null;
printer_id?: number | null;
enabled?: boolean;
auto_on?: boolean;
@@ -908,19 +946,35 @@ export interface SmartPlugCreate {
schedule_enabled?: boolean;
schedule_on_time?: string | null;
schedule_off_time?: string | null;
// Switchbar visibility
// Visibility options
show_in_switchbar?: boolean;
show_on_printer_card?: boolean;
}
export interface SmartPlugUpdate {
name?: string;
plug_type?: 'tasmota' | 'homeassistant';
plug_type?: 'tasmota' | 'homeassistant' | 'mqtt';
ip_address?: string | null;
ha_entity_id?: string | null;
// Home Assistant energy sensor entities (optional)
ha_power_entity?: string | null;
ha_energy_today_entity?: string | null;
ha_energy_total_entity?: string | null;
// MQTT fields (legacy)
mqtt_topic?: string | null;
mqtt_multiplier?: number;
// MQTT power fields
mqtt_power_topic?: string | null;
mqtt_power_path?: string | null;
mqtt_power_multiplier?: number;
// MQTT energy fields
mqtt_energy_topic?: string | null;
mqtt_energy_path?: string | null;
mqtt_energy_multiplier?: number;
// MQTT state fields
mqtt_state_topic?: string | null;
mqtt_state_path?: string | null;
mqtt_state_on_value?: string | null;
printer_id?: number | null;
enabled?: boolean;
auto_on?: boolean;
@@ -938,8 +992,9 @@ export interface SmartPlugUpdate {
schedule_enabled?: boolean;
schedule_on_time?: string | null;
schedule_off_time?: string | null;
// Switchbar visibility
// Visibility options
show_in_switchbar?: boolean;
show_on_printer_card?: boolean;
}
// Home Assistant entity for smart plug selection
@@ -947,7 +1002,7 @@ export interface HAEntity {
entity_id: string;
friendly_name: string;
state: string | null;
domain: string; // "switch", "light", "input_boolean"
domain: string; // "switch", "light", "input_boolean", "script"
}
// Home Assistant sensor entity for energy monitoring
@@ -2050,6 +2105,17 @@ export const api = {
deleteArchive: (id: number) =>
request<void>(`/archives/${id}`, { method: 'DELETE' }),
getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
// Tag management
getTags: () => request<TagInfo[]>('/archives/tags'),
renameTag: (oldName: string, newName: string) =>
request<{ affected: number }>(`/archives/tags/${encodeURIComponent(oldName)}`, {
method: 'PUT',
body: JSON.stringify({ new_name: newName }),
}),
deleteTag: (name: string) =>
request<{ affected: number }>(`/archives/tags/${encodeURIComponent(name)}`, {
method: 'DELETE',
}),
recalculateCosts: () =>
request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => {
@@ -2543,6 +2609,7 @@ export const api = {
getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
getSmartPlugByPrinter: (printerId: number) => request<SmartPlug | null>(`/smart-plugs/by-printer/${printerId}`),
getScriptPlugsByPrinter: (printerId: number) => request<SmartPlug[]>(`/smart-plugs/by-printer/${printerId}/scripts`),
createSmartPlug: (data: SmartPlugCreate) =>
request<SmartPlug>('/smart-plugs/', {
method: 'POST',
+276 -67
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home } from 'lucide-react';
import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio } from 'lucide-react';
import { api } from '../api/client';
import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
import { Button } from './Button';
@@ -15,7 +15,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
const isEditing = !!plug;
// Plug type selection
const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant'>(plug?.plug_type || 'tasmota');
const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant' | 'mqtt'>(plug?.plug_type || 'tasmota');
const [name, setName] = useState(plug?.name || '');
// Tasmota fields
@@ -24,6 +24,22 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
const [password, setPassword] = useState(plug?.password || '');
// Home Assistant fields
const [haEntityId, setHaEntityId] = useState(plug?.ha_entity_id || '');
// MQTT fields - Power
const [mqttPowerTopic, setMqttPowerTopic] = useState(plug?.mqtt_power_topic || plug?.mqtt_topic || '');
const [mqttPowerPath, setMqttPowerPath] = useState(plug?.mqtt_power_path || '');
const [mqttPowerMultiplier, setMqttPowerMultiplier] = useState<string>(
(plug?.mqtt_power_multiplier ?? plug?.mqtt_multiplier ?? 1).toString()
);
// MQTT fields - Energy
const [mqttEnergyTopic, setMqttEnergyTopic] = useState(plug?.mqtt_energy_topic || '');
const [mqttEnergyPath, setMqttEnergyPath] = useState(plug?.mqtt_energy_path || '');
const [mqttEnergyMultiplier, setMqttEnergyMultiplier] = useState<string>(
(plug?.mqtt_energy_multiplier ?? 1).toString()
);
// MQTT fields - State
const [mqttStateTopic, setMqttStateTopic] = useState(plug?.mqtt_state_topic || '');
const [mqttStatePath, setMqttStatePath] = useState(plug?.mqtt_state_path || '');
const [mqttStateOnValue, setMqttStateOnValue] = useState(plug?.mqtt_state_on_value || '');
// HA energy sensor entities (optional)
const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');
const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || '');
@@ -279,6 +295,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
return;
}
if (plugType === 'mqtt') {
// Check that at least one topic is configured (path is optional)
const hasPower = mqttPowerTopic.trim();
const hasEnergy = mqttEnergyTopic.trim();
const hasState = mqttStateTopic.trim();
if (!hasPower && !hasEnergy && !hasState) {
setError('At least one MQTT topic must be configured for power, energy, or state monitoring');
return;
}
}
const data = {
name: name.trim(),
plug_type: plugType,
@@ -288,6 +316,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
ha_power_entity: plugType === 'homeassistant' ? (haPowerEntity || null) : null,
ha_energy_today_entity: plugType === 'homeassistant' ? (haEnergyTodayEntity || null) : null,
ha_energy_total_entity: plugType === 'homeassistant' ? (haEnergyTotalEntity || null) : null,
// MQTT power fields
mqtt_power_topic: plugType === 'mqtt' ? (mqttPowerTopic.trim() || null) : null,
mqtt_power_path: plugType === 'mqtt' ? (mqttPowerPath.trim() || null) : null,
mqtt_power_multiplier: plugType === 'mqtt' ? (parseFloat(mqttPowerMultiplier) || 1) : 1,
// MQTT energy fields
mqtt_energy_topic: plugType === 'mqtt' ? (mqttEnergyTopic.trim() || null) : null,
mqtt_energy_path: plugType === 'mqtt' ? (mqttEnergyPath.trim() || null) : null,
mqtt_energy_multiplier: plugType === 'mqtt' ? (parseFloat(mqttEnergyMultiplier) || 1) : 1,
// MQTT state fields
mqtt_state_topic: plugType === 'mqtt' ? (mqttStateTopic.trim() || null) : null,
mqtt_state_path: plugType === 'mqtt' ? (mqttStatePath.trim() || null) : null,
mqtt_state_on_value: plugType === 'mqtt' ? (mqttStateOnValue.trim() || null) : null,
username: plugType === 'tasmota' ? (username.trim() || null) : null,
password: plugType === 'tasmota' ? (password.trim() || null) : null,
printer_id: printerId,
@@ -352,7 +392,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
setTestResult(null);
setError(null);
}}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
plugType === 'tasmota'
? 'bg-bambu-green text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
@@ -368,14 +408,30 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
setTestResult(null);
setError(null);
}}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
plugType === 'homeassistant'
? 'bg-bambu-green text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
}`}
>
<Home className="w-4 h-4" />
Home Assistant
HA
</button>
<button
type="button"
onClick={() => {
setPlugType('mqtt');
setTestResult(null);
setError(null);
}}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
plugType === 'mqtt'
? 'bg-bambu-green text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
}`}
>
<Radio className="w-4 h-4" />
MQTT
</button>
</div>
)}
@@ -857,6 +913,155 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
</div>
)}
{/* MQTT Configuration - only show when MQTT is selected */}
{plugType === 'mqtt' && (
<div className="space-y-3">
{/* MQTT broker not configured */}
{!settings?.mqtt_broker && (
<div className="p-3 bg-yellow-500/20 border border-yellow-500/50 rounded-lg text-sm text-yellow-400">
MQTT broker not configured. Set broker address in{' '}
<span className="font-medium">Settings → Network → MQTT Publishing</span>
{' '}(you don't need to enable publishing, just fill in the broker details).
</div>
)}
{/* MQTT broker configured - show fields */}
{settings?.mqtt_broker && (
<>
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg text-sm text-blue-300">
<p className="font-medium mb-1">Monitor Only</p>
<p className="text-xs opacity-80">
MQTT plugs receive power/energy data via MQTT subscription. On/off control is not available - use your MQTT broker or home automation system.
</p>
</div>
{/* Power Section */}
<div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-white font-medium text-sm">Power Monitoring</p>
<div>
<label className="block text-sm text-bambu-gray mb-1">Topic</label>
<input
type="text"
value={mqttPowerTopic}
onChange={(e) => setMqttPowerTopic(e.target.value)}
placeholder="zigbee2mqtt/shelly-working-room"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">JSON Path</label>
<input
type="text"
value={mqttPowerPath}
onChange={(e) => setMqttPowerPath(e.target.value)}
placeholder="power_l1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Multiplier</label>
<input
type="text"
value={mqttPowerMultiplier}
onChange={(e) => setMqttPowerMultiplier(e.target.value)}
placeholder="1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<p className="text-xs text-bambu-gray">
JSON path extracts value from JSON payload (e.g., "power_l1"). Leave empty if topic publishes raw numeric values.<br/>
Use multiplier 0.001 for mW→W, 1000 for kW→W.
</p>
</div>
{/* Energy Section */}
<div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-white font-medium text-sm">Energy Monitoring <span className="text-bambu-gray font-normal">(optional)</span></p>
<div>
<label className="block text-sm text-bambu-gray mb-1">Topic</label>
<input
type="text"
value={mqttEnergyTopic}
onChange={(e) => setMqttEnergyTopic(e.target.value)}
placeholder="Same as power topic, or different"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">JSON Path</label>
<input
type="text"
value={mqttEnergyPath}
onChange={(e) => setMqttEnergyPath(e.target.value)}
placeholder="energy_l1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Multiplier</label>
<input
type="text"
value={mqttEnergyMultiplier}
onChange={(e) => setMqttEnergyMultiplier(e.target.value)}
placeholder="1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<p className="text-xs text-bambu-gray">
JSON path extracts value from JSON payload. Leave empty for raw values.<br/>
Use multiplier 0.001 for Wh→kWh, 1000 for MWh→kWh.
</p>
</div>
{/* State Section */}
<div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-white font-medium text-sm">State Monitoring <span className="text-bambu-gray font-normal">(optional)</span></p>
<div>
<label className="block text-sm text-bambu-gray mb-1">Topic</label>
<input
type="text"
value={mqttStateTopic}
onChange={(e) => setMqttStateTopic(e.target.value)}
placeholder="Same as power topic, or different"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">JSON Path</label>
<input
type="text"
value={mqttStatePath}
onChange={(e) => setMqttStatePath(e.target.value)}
placeholder="state_l1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">ON Value</label>
<input
type="text"
value={mqttStateOnValue}
onChange={(e) => setMqttStateOnValue(e.target.value)}
placeholder="ON, true, 1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<p className="text-xs text-bambu-gray">
JSON path extracts value from JSON payload. Leave empty for raw values.<br/>
ON value: the exact string that means "ON". Leave empty for auto-detect (ON, true, 1).
</p>
</div>
</>
)}
</div>
)}
{/* IP Address - only show for Tasmota */}
{plugType === 'tasmota' && (
<div>
@@ -959,25 +1164,27 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
</>
)}
{/* Link to Printer */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Link to Printer</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="">No printer (manual control only)</option>
{availablePrinters?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
Linking enables automatic on/off when prints start/complete
</p>
</div>
{/* Link to Printer - not shown for MQTT plugs (monitor-only) */}
{plugType !== 'mqtt' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">Link to Printer</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="">No printer (manual control only)</option>
{availablePrinters?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
Linking enables automatic on/off when prints start/complete
</p>
</div>
)}
{/* Power Alerts */}
<div className="border-t border-bambu-dark-tertiary pt-4">
@@ -1031,51 +1238,53 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
)}
</div>
{/* Schedule */}
<div className="border-t border-bambu-dark-tertiary pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-bambu-green" />
<span className="text-white font-medium">Daily Schedule</span>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={scheduleEnabled}
onChange={(e) => setScheduleEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
{scheduleEnabled && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">Turn On at</label>
<input
type="time"
value={scheduleOnTime}
onChange={(e) => setScheduleOnTime(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-sm text-bambu-gray mb-1">Turn Off at</label>
<input
type="time"
value={scheduleOffTime}
onChange={(e) => setScheduleOffTime(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>
{/* Schedule - not shown for MQTT plugs (monitor-only) */}
{plugType !== 'mqtt' && (
<div className="border-t border-bambu-dark-tertiary pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-bambu-green" />
<span className="text-white font-medium">Daily Schedule</span>
</div>
<p className="text-xs text-bambu-gray">
Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={scheduleEnabled}
onChange={(e) => setScheduleEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
)}
</div>
{scheduleEnabled && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">Turn On at</label>
<input
type="time"
value={scheduleOnTime}
onChange={(e) => setScheduleOnTime(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-sm text-bambu-gray mb-1">Turn Off at</label>
<input
type="time"
value={scheduleOffTime}
onChange={(e) => setScheduleOffTime(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>
<p className="text-xs text-bambu-gray">
Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
</p>
</div>
)}
</div>
)}
{/* Switchbar Visibility */}
<div className="border-t border-bambu-dark-tertiary pt-4">
+33 -14
View File
@@ -68,30 +68,49 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
queryFn: () => api.getProjects(),
});
// Get all archives to extract existing tags if not provided
const { data: archives } = useQuery({
queryKey: ['archives'],
queryFn: () => api.getArchives(undefined, 1000, 0),
// Fetch all tags using the dedicated API
const { data: tagsData } = useQuery({
queryKey: ['tags'],
queryFn: api.getTags,
enabled: existingTags.length === 0,
});
// Extract unique tags from all archives
// Use existing tags prop if provided, otherwise use fetched tags
const allTags = existingTags.length > 0
? existingTags
: [...new Set(
archives?.flatMap(a => a.tags?.split(',').map(t => t.trim()) || []).filter(Boolean) || []
)].sort();
: (tagsData?.map(t => t.name) || []);
// Get current tags as array
const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean);
// Filter suggestions based on what's not already added
const tagSuggestions = allTags.filter(t => !currentTags.includes(t));
// Get the text being typed after the last comma (for autocomplete filtering)
const currentInput = tags.includes(',')
? tags.substring(tags.lastIndexOf(',') + 1).trim().toLowerCase()
: tags.trim().toLowerCase();
// Add a tag
// Filter suggestions: not already added AND matches current input (if any)
const tagSuggestions = allTags.filter(t =>
!currentTags.includes(t) &&
(currentInput === '' || t.toLowerCase().includes(currentInput))
);
// Add a tag (replaces any partial input with the selected tag)
const addTag = (tag: string) => {
if (!currentTags.includes(tag)) {
const newTags = [...currentTags, tag].join(', ');
// If there's partial input being typed, replace it with the selected tag
// Otherwise, just append the tag
let baseTags: string[];
if (currentInput && !allTags.includes(currentInput)) {
// User is typing a partial tag - replace it with the selected one
baseTags = tags.includes(',')
? tags.substring(0, tags.lastIndexOf(',')).split(',').map(t => t.trim()).filter(Boolean)
: [];
} else {
// No partial input or input is already a complete tag - append
baseTags = currentTags;
}
if (!baseTags.includes(tag)) {
const newTags = [...baseTags, tag].join(', ');
setTags(newTags);
}
// Clear any pending blur timeout to prevent hiding suggestions
@@ -342,7 +361,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
{showTagSuggestions && tagSuggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
<div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
Existing tags (click to add)
{currentInput ? `Matching "${currentInput}"` : 'Existing tags'} (click to add)
</div>
<div className="p-2 flex flex-wrap gap-1.5">
{tagSuggestions.map((tag) => (
+126 -71
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home } from 'lucide-react';
import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home, Radio, Eye } from 'lucide-react';
import { api } from '../api/client';
import type { SmartPlug, SmartPlugUpdate } from '../api/client';
import { Card, CardContent } from './Card';
@@ -92,7 +92,9 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
});
const isOn = status?.state === 'ON';
const isReachable = status?.reachable ?? false;
// For MQTT plugs, consider reachable if we have power data (even if backend says not reachable)
const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power !== null && status?.energy?.power !== undefined);
const isReachable = (status?.reachable ?? false) || hasMqttData;
const isPending = controlMutation.isPending;
// Generate admin URL with auto-login credentials (Tasmota only)
@@ -113,27 +115,49 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
<Card className="relative">
<CardContent className="p-4">
{/* Header Row */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-start justify-between gap-2 mb-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className={`p-2 rounded-lg flex-shrink-0 ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
{plug.plug_type === 'homeassistant' ? (
<div className={`p-2 rounded-lg flex-shrink-0 ${
plug.plug_type === 'mqtt'
? (isReachable ? 'bg-teal-500/20' : 'bg-red-500/20')
: (isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20')
}`}>
{plug.plug_type === 'mqtt' ? (
<Radio className={`w-5 h-5 ${isReachable ? 'text-teal-400' : 'text-red-400'}`} />
) : plug.plug_type === 'homeassistant' ? (
<Home className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
) : (
<Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
)}
</div>
<div className="min-w-0">
<h3 className="font-medium text-white truncate" title={plug.name}>{plug.name}</h3>
<p className="text-sm text-bambu-gray truncate">
{plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}
<h3 className="font-medium text-white truncate">{plug.name}</h3>
<p
className="text-sm text-bambu-gray truncate"
title={plug.plug_type === 'mqtt' ? plug.mqtt_topic ?? undefined : plug.plug_type === 'homeassistant' ? plug.ha_entity_id ?? undefined : plug.ip_address ?? undefined}
>
{plug.plug_type === 'mqtt' ? plug.mqtt_topic : plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}
</p>
</div>
</div>
{/* Status indicator */}
<div className="flex flex-col items-end gap-1">
<div className="flex flex-col items-end gap-1 flex-shrink-0">
{statusLoading ? (
<Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
) : plug.plug_type === 'mqtt' ? (
/* MQTT plugs - show badge and checkmark when receiving data */
<div className="flex items-center gap-1.5 text-sm whitespace-nowrap">
<span className="px-1.5 py-0.5 bg-teal-500/20 text-teal-400 text-[10px] font-medium rounded flex-shrink-0">MQTT</span>
{isReachable && <span className="text-status-ok">✓</span>}
</div>
) : plug.plug_type === 'homeassistant' ? (
<div className="flex items-center gap-1 text-sm">
<span className="px-1 py-0.5 bg-blue-500/20 text-blue-400 text-[10px] font-medium rounded">HA</span>
<span className={isReachable ? (isOn ? 'text-status-ok' : 'text-bambu-gray') : 'text-status-error'}>
{isReachable ? (status?.state || '?') : 'Offline'}
</span>
</div>
) : isReachable ? (
<div className="flex items-center gap-1 text-sm">
<Wifi className="w-4 h-4 text-status-ok" />
@@ -170,8 +194,14 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
)}
{/* Feature Badges */}
{(plug.power_alert_enabled || plug.schedule_enabled) && (
{(plug.power_alert_enabled || plug.schedule_enabled || plug.plug_type === 'mqtt') && (
<div className="flex flex-wrap gap-1.5 mb-3">
{plug.plug_type === 'mqtt' && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-teal-500/20 text-teal-400 text-xs rounded-full">
<Eye className="w-3 h-3" />
Monitor Only
</span>
)}
{plug.power_alert_enabled && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded-full">
<Bell className="w-3 h-3" />
@@ -191,29 +221,49 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
</div>
)}
{/* Quick Controls */}
<div className="flex gap-2 mb-3">
<Button
size="sm"
variant={isOn ? 'primary' : 'secondary'}
disabled={!isReachable || isPending}
onClick={() => setShowPowerOnConfirm(true)}
className="flex-1"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
On
</Button>
<Button
size="sm"
variant={!isOn ? 'primary' : 'secondary'}
disabled={!isReachable || isPending}
onClick={() => setShowPowerOffConfirm(true)}
className="flex-1"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
Off
</Button>
</div>
{/* Quick Controls - hidden for MQTT plugs (monitor-only) */}
{plug.plug_type !== 'mqtt' && (
<div className="flex gap-2 mb-3">
<Button
size="sm"
variant={isOn ? 'primary' : 'secondary'}
disabled={!isReachable || isPending}
onClick={() => setShowPowerOnConfirm(true)}
className="flex-1"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
On
</Button>
<Button
size="sm"
variant={!isOn ? 'primary' : 'secondary'}
disabled={!isReachable || isPending}
onClick={() => setShowPowerOffConfirm(true)}
className="flex-1"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
Off
</Button>
</div>
)}
{/* Energy display for MQTT plugs */}
{plug.plug_type === 'mqtt' && status?.energy && (
<div className="flex gap-2 mb-3 px-3 py-2 bg-bambu-dark rounded-lg">
{status.energy.power !== null && status.energy.power !== undefined && (
<div className="flex-1 text-center">
<p className="text-lg font-semibold text-white">{Math.round(status.energy.power)}W</p>
<p className="text-xs text-bambu-gray">Power</p>
</div>
)}
{status.energy.today !== null && status.energy.today !== undefined && (
<div className="flex-1 text-center border-l border-bambu-dark-tertiary">
<p className="text-lg font-semibold text-white">{status.energy.today.toFixed(2)}</p>
<p className="text-xs text-bambu-gray">kWh Today</p>
</div>
)}
</div>
)}
{/* Toggle Settings Panel */}
<button
@@ -222,7 +272,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
>
<span className="flex items-center gap-2">
<Settings2 className="w-4 h-4" />
Automation Settings
{plug.plug_type === 'mqtt' ? 'Settings' : 'Automation Settings'}
</span>
<span>{isExpanded ? '-' : '+'}</span>
</button>
@@ -250,45 +300,48 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
</label>
</div>
{/* 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">Enable automation for this plug</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={plug.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>
{/* Automation controls - only for controllable plugs (not MQTT) */}
{plug.plug_type !== 'mqtt' && (
<>
{/* 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">Enable automation for this plug</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={plug.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>
{/* Auto On */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Auto On</p>
<p className="text-xs text-bambu-gray">Turn on when print starts</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={plug.auto_on}
onChange={(e) => updateMutation.mutate({ auto_on: 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>
{/* Auto On */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Auto On</p>
<p className="text-xs text-bambu-gray">Turn on when print starts</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={plug.auto_on}
onChange={(e) => updateMutation.mutate({ auto_on: 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>
{/* Auto Off */}
<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 (one-shot)</p>
{/* Auto Off */}
<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 (one-shot)</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
@@ -360,6 +413,8 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
)}
</div>
)}
</>
)}
{/* Action Buttons */}
<div className="flex gap-2 pt-2">
+60 -30
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plug, Power, PowerOff, Loader2, Wifi, WifiOff, Zap } from 'lucide-react';
import { Plug, Power, PowerOff, Loader2, Wifi, WifiOff, Zap, Radio, Eye } from 'lucide-react';
import { api } from '../api/client';
import type { SmartPlug } from '../api/client';
import { ConfirmModal } from './ConfirmModal';
@@ -29,8 +29,11 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
});
const isOn = status?.state === 'ON';
const isReachable = status?.reachable ?? false;
// For MQTT plugs, consider reachable if we have power data
const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power !== null && status?.energy?.power !== undefined);
const isReachable = (status?.reachable ?? false) || hasMqttData;
const isPending = controlMutation.isPending;
const isMqtt = plug.plug_type === 'mqtt';
const handleConfirm = () => {
if (confirmAction) {
@@ -43,14 +46,38 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
<>
<div className="flex items-center justify-between py-2 px-3 hover:bg-bambu-dark-tertiary rounded-lg transition-colors">
<div className="flex items-center gap-2">
<div className={`p-1.5 rounded ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
<Plug className={`w-4 h-4 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
<div className={`p-1.5 rounded ${
isMqtt
? (isReachable ? 'bg-teal-500/20' : 'bg-red-500/20')
: (isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20')
}`}>
{isMqtt ? (
<Radio className={`w-4 h-4 ${isReachable ? 'text-teal-400' : 'text-red-400'}`} />
) : (
<Plug className={`w-4 h-4 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
)}
</div>
<div>
<p className="text-sm text-white font-medium">{plug.name}</p>
<div className="flex items-center gap-1 text-xs">
{statusLoading ? (
<Loader2 className="w-3 h-3 text-bambu-gray animate-spin" />
) : isMqtt ? (
/* MQTT plugs show power and monitor-only indicator */
isReachable ? (
<>
<Zap className="w-3 h-3 text-teal-400" />
<span className="text-teal-400">{Math.round(status?.energy?.power ?? 0)}W</span>
<span className="text-bambu-gray mx-1">|</span>
<Eye className="w-3 h-3 text-bambu-gray" />
<span className="text-bambu-gray">Monitor</span>
</>
) : (
<>
<WifiOff className="w-3 h-3 text-status-error" />
<span className="text-status-error">Waiting</span>
</>
)
) : isReachable ? (
<>
<Wifi className="w-3 h-3 text-status-ok" />
@@ -73,32 +100,35 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => setConfirmAction('on')}
disabled={!isReachable || isPending}
className={`p-1.5 rounded transition-colors ${
isOn
? 'bg-bambu-green text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white'
} disabled:opacity-50 disabled:cursor-not-allowed`}
title="Turn On"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
</button>
<button
onClick={() => setConfirmAction('off')}
disabled={!isReachable || isPending}
className={`p-1.5 rounded transition-colors ${
!isOn && isReachable
? 'bg-bambu-dark-tertiary text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white'
} disabled:opacity-50 disabled:cursor-not-allowed`}
title="Turn Off"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
</button>
</div>
{/* Hide on/off buttons for MQTT plugs (monitor-only) */}
{!isMqtt && (
<div className="flex gap-1">
<button
onClick={() => setConfirmAction('on')}
disabled={!isReachable || isPending}
className={`p-1.5 rounded transition-colors ${
isOn
? 'bg-bambu-green text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white'
} disabled:opacity-50 disabled:cursor-not-allowed`}
title="Turn On"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
</button>
<button
onClick={() => setConfirmAction('off')}
disabled={!isReachable || isPending}
className={`p-1.5 rounded transition-colors ${
!isOn && isReachable
? 'bg-bambu-dark-tertiary text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white'
} disabled:opacity-50 disabled:cursor-not-allowed`}
title="Turn Off"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
</button>
</div>
)}
</div>
{confirmAction && (
@@ -0,0 +1,294 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Tag, Pencil, Trash2, Loader2, Search, Check, AlertTriangle } from 'lucide-react';
import { api } from '../api/client';
import type { TagInfo } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
interface TagManagementModalProps {
onClose: () => void;
}
export function TagManagementModal({ onClose }: TagManagementModalProps) {
const queryClient = useQueryClient();
const { showToast } = useToast();
const [search, setSearch] = useState('');
const [editingTag, setEditingTag] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<'count' | 'name'>('count');
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (editingTag) {
setEditingTag(null);
} else if (deleteConfirm) {
setDeleteConfirm(null);
} else {
onClose();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose, editingTag, deleteConfirm]);
const { data: tags, isLoading } = useQuery({
queryKey: ['tags'],
queryFn: api.getTags,
});
const renameMutation = useMutation({
mutationFn: ({ oldName, newName }: { oldName: string; newName: string }) =>
api.renameTag(oldName, newName),
onSuccess: (data, { oldName, newName }) => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
queryClient.invalidateQueries({ queryKey: ['archives'] });
showToast(`Renamed "${oldName}" to "${newName}" in ${data.affected} archive${data.affected !== 1 ? 's' : ''}`);
setEditingTag(null);
},
onError: (error: Error) => {
showToast(error.message || 'Failed to rename tag', 'error');
},
});
const deleteMutation = useMutation({
mutationFn: (name: string) => api.deleteTag(name),
onSuccess: (data, name) => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
queryClient.invalidateQueries({ queryKey: ['archives'] });
showToast(`Deleted "${name}" from ${data.affected} archive${data.affected !== 1 ? 's' : ''}`);
setDeleteConfirm(null);
},
onError: (error: Error) => {
showToast(error.message || 'Failed to delete tag', 'error');
},
});
const startEdit = (tag: TagInfo) => {
setEditingTag(tag.name);
setEditValue(tag.name);
setDeleteConfirm(null);
};
const cancelEdit = () => {
setEditingTag(null);
setEditValue('');
};
const submitEdit = () => {
if (!editingTag || !editValue.trim()) return;
const newName = editValue.trim();
if (newName === editingTag) {
cancelEdit();
return;
}
renameMutation.mutate({ oldName: editingTag, newName });
};
const handleEditKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
submitEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
};
const confirmDelete = (name: string) => {
setDeleteConfirm(name);
setEditingTag(null);
};
const executeDelete = () => {
if (deleteConfirm) {
deleteMutation.mutate(deleteConfirm);
}
};
// Filter and sort tags
const filteredTags = tags
?.filter(t => t.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => {
if (sortBy === 'count') {
return b.count - a.count || a.name.localeCompare(b.name);
}
return a.name.localeCompare(b.name);
});
const totalUsage = tags?.reduce((sum, t) => sum + t.count, 0) || 0;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-lg max-h-[80vh] flex flex-col">
<CardContent className="p-0 flex flex-col min-h-0">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
<div className="flex items-center gap-2">
<Tag className="w-5 h-5 text-bambu-green" />
<h2 className="text-xl font-semibold text-white">Manage Tags</h2>
</div>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Search and sort */}
<div className="p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
placeholder="Search tags..."
className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'count' | 'name')}
>
<option value="count">Sort by Count</option>
<option value="name">Sort by Name</option>
</select>
</div>
{tags && (
<p className="text-xs text-bambu-gray mt-2">
{tags.length} tag{tags.length !== 1 ? 's' : ''} across {totalUsage} usage{totalUsage !== 1 ? 's' : ''}
</p>
)}
</div>
{/* Tags list */}
<div className="flex-1 overflow-y-auto min-h-0 p-4">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-bambu-gray" />
</div>
) : !filteredTags?.length ? (
<div className="text-center py-8 text-bambu-gray">
{search ? 'No tags match your search' : 'No tags found'}
</div>
) : (
<div className="space-y-2">
{filteredTags.map((tag) => (
<div
key={tag.name}
className="flex items-center gap-2 p-2 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors group"
>
{editingTag === tag.name ? (
// Edit mode
<div className="flex-1 flex items-center gap-2">
<input
type="text"
className="flex-1 px-2 py-1 bg-bambu-dark-tertiary border border-bambu-green rounded text-white text-sm focus:outline-none"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleEditKeyDown}
autoFocus
/>
<Button
size="sm"
variant="primary"
onClick={submitEdit}
disabled={!editValue.trim() || renameMutation.isPending}
className="p-1.5"
>
{renameMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Check className="w-4 h-4" />
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={cancelEdit}
className="p-1.5"
>
<X className="w-4 h-4" />
</Button>
</div>
) : deleteConfirm === tag.name ? (
// Delete confirmation
<div className="flex-1 flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
<span className="text-sm text-bambu-gray-light flex-1">
Delete "{tag.name}" from {tag.count} archive{tag.count !== 1 ? 's' : ''}?
</span>
<Button
size="sm"
variant="danger"
onClick={executeDelete}
disabled={deleteMutation.isPending}
className="p-1.5"
>
{deleteMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setDeleteConfirm(null)}
className="p-1.5"
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
// Normal display
<>
<Tag className="w-4 h-4 text-bambu-gray flex-shrink-0" />
<span className="text-white flex-1 truncate">{tag.name}</span>
<span className="px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray text-xs">
{tag.count}
</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => startEdit(tag)}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
title="Rename tag"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => confirmDelete(tag.name)}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
title="Delete tag"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</>
)}
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
<Button variant="secondary" onClick={onClose} className="flex-1">
Close
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+15
View File
@@ -43,6 +43,7 @@ import {
FolderKanban,
ChevronLeft,
ChevronRight,
Settings,
} from 'lucide-react';
import { api } from '../api/client';
import { openInSlicer } from '../utils/slicer';
@@ -66,6 +67,7 @@ import { ProjectPageModal } from '../components/ProjectPageModal';
import { TimelapseViewer } from '../components/TimelapseViewer';
import { CompareArchivesModal } from '../components/CompareArchivesModal';
import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
import { TagManagementModal } from '../components/TagManagementModal';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
@@ -2076,6 +2078,7 @@ export function ArchivesPage() {
const [showExportMenu, setShowExportMenu] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [showCompareModal, setShowCompareModal] = useState(false);
const [showTagManagement, setShowTagManagement] = useState(false);
const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
// Clear highlight after 5 seconds and scroll to highlighted element
@@ -2719,6 +2722,13 @@ export function ArchivesPage() {
</option>
))}
</select>
<button
onClick={() => setShowTagManagement(true)}
className="p-2 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-green transition-colors"
title="Manage Tags"
>
<Settings className="w-4 h-4" />
</button>
</div>
)}
<div className="flex items-center gap-2 flex-shrink-0">
@@ -2947,6 +2957,11 @@ export function ArchivesPage() {
}}
/>
)}
{/* Tag Management Modal */}
{showTagManagement && (
<TagManagementModal onClose={() => setShowTagManagement(false)} />
)}
</div>
);
}
+37
View File
@@ -1095,6 +1095,12 @@ function PrinterCard({
queryFn: () => api.getSmartPlugByPrinter(printer.id),
});
// Fetch script plugs for this printer (for multi-device control)
const { data: scriptPlugs } = useQuery({
queryKey: ['scriptPlugsByPrinter', printer.id],
queryFn: () => api.getScriptPlugsByPrinter(printer.id),
});
// Fetch smart plug status if plug exists (faster refresh for energy monitoring)
const { data: plugStatus } = useQuery({
queryKey: ['smartPlugStatus', smartPlug?.id],
@@ -1158,6 +1164,15 @@ function PrinterCard({
},
});
// Run script mutation
const runScriptMutation = useMutation({
mutationFn: (scriptId: number) => api.controlSmartPlug(scriptId, 'on'),
onSuccess: () => {
showToast('Script triggered');
},
onError: (error: Error) => showToast(error.message || 'Failed to run script', 'error'),
});
// Print control mutations
const stopPrintMutation = useMutation({
mutationFn: () => api.stopPrint(printer.id),
@@ -2720,6 +2735,28 @@ function PrinterCard({
</button>
</div>
</div>
{/* Script buttons row */}
{scriptPlugs && scriptPlugs.length > 0 && (
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-bambu-dark-tertiary/50">
<Play className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />
<span className="text-xs text-bambu-gray">Scripts:</span>
<div className="flex flex-wrap gap-1">
{scriptPlugs.map(script => (
<button
key={script.id}
onClick={() => runScriptMutation.mutate(script.id)}
disabled={runScriptMutation.isPending}
title={`Run ${script.ha_entity_id}`}
className="px-2 py-0.5 text-xs bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 rounded transition-colors flex items-center gap-1"
>
<Play className="w-2.5 h-2.5" />
{script.name}
</button>
))}
</div>
</div>
)}
</div>
)}
+11 -7
View File
@@ -175,13 +175,17 @@ export function SettingsPage() {
let totalLifetime = 0;
let reachableCount = 0;
for (const { status } of statuses) {
if (status?.reachable && status.energy) {
for (const { plug, status } of statuses) {
// For MQTT plugs, consider reachable if we have power data
const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power != null);
const isReachable = (status?.reachable || hasMqttData) && status?.energy;
if (isReachable) {
reachableCount++;
if (status.energy.power != null) totalPower += status.energy.power;
if (status.energy.today != null) totalToday += status.energy.today;
if (status.energy.yesterday != null) totalYesterday += status.energy.yesterday;
if (status.energy.total != null) totalLifetime += status.energy.total;
if (status.energy?.power != null) totalPower += status.energy.power;
if (status.energy?.today != null) totalToday += status.energy.today;
if (status.energy?.yesterday != null) totalYesterday += status.energy.yesterday;
if (status.energy?.total != null) totalLifetime += status.energy.total;
}
}
@@ -1950,7 +1954,7 @@ export function SettingsPage() {
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
Connect to Home Assistant to control smart plugs via HA's REST API. Supports switch, light, and input_boolean entities.
Connect to Home Assistant to control smart plugs via HA's REST API. Supports switch, light, input_boolean, and script entities.
</p>
<div className="flex items-center justify-between">
+308
View File
@@ -0,0 +1,308 @@
import { useEffect, useMemo, useState } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Layers, Clock, Timer, Printer } from 'lucide-react';
import { api } from '../api/client';
import type { PrinterStatus } from '../api/client';
type OverlaySize = 'small' | 'medium' | 'large';
interface OverlayConfig {
size: OverlaySize;
showProgress: boolean;
showLayers: boolean;
showEta: boolean;
showFilename: boolean;
showStatus: boolean;
showPrinter: boolean;
}
function parseConfig(params: URLSearchParams): OverlayConfig {
const show = params.get('show')?.split(',') || ['progress', 'layers', 'eta', 'filename', 'status'];
return {
size: (params.get('size') as OverlaySize) || 'medium',
showProgress: show.includes('progress'),
showLayers: show.includes('layers'),
showEta: show.includes('eta'),
showFilename: show.includes('filename'),
showStatus: show.includes('status'),
showPrinter: show.includes('printer'),
};
}
function formatTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
function formatETA(remainingMinutes: number): string {
const now = new Date();
const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
const today = new Date();
today.setHours(0, 0, 0, 0);
const etaDay = new Date(eta);
etaDay.setHours(0, 0, 0, 0);
const timeStr = eta.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (etaDay.getTime() === today.getTime()) {
return timeStr;
} else if (etaDay.getTime() === today.getTime() + 86400000) {
return `Tomorrow ${timeStr}`;
} else {
return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr;
}
}
function getStatusText(status: PrinterStatus): string {
if (status.stg_cur_name) return status.stg_cur_name;
switch (status.state) {
case 'RUNNING': return 'Printing';
case 'PAUSE': return 'Paused';
case 'FINISH': return 'Finished';
case 'FAILED': return 'Failed';
case 'IDLE': return 'Idle';
default: return status.state || 'Unknown';
}
}
function getSizeClasses(size: OverlaySize) {
switch (size) {
case 'small':
return {
container: 'p-3',
text: 'text-sm',
textLarge: 'text-lg',
progressHeight: 'h-2',
icon: 'w-3 h-3',
gap: 'gap-2',
logoHeight: 'h-12',
};
case 'large':
return {
container: 'p-6',
text: 'text-xl',
textLarge: 'text-3xl',
progressHeight: 'h-4',
icon: 'w-6 h-6',
gap: 'gap-4',
logoHeight: 'h-24',
};
case 'medium':
default:
return {
container: 'p-4',
text: 'text-base',
textLarge: 'text-xl',
progressHeight: 'h-3',
icon: 'w-4 h-4',
gap: 'gap-3',
logoHeight: 'h-16',
};
}
}
export function StreamOverlayPage() {
const { printerId } = useParams<{ printerId: string }>();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const id = parseInt(printerId || '0', 10);
const [imageKey, setImageKey] = useState(Date.now());
const config = useMemo(() => parseConfig(searchParams), [searchParams]);
const sizes = getSizeClasses(config.size);
// Fetch printer info
const { data: printer } = useQuery({
queryKey: ['printer', id],
queryFn: () => api.getPrinter(id),
enabled: id > 0,
});
// Fetch printer status with polling
const { data: status } = useQuery({
queryKey: ['printerStatus', id],
queryFn: () => api.getPrinterStatus(id),
enabled: id > 0,
refetchInterval: 2000,
});
// WebSocket for real-time updates
useEffect(() => {
if (!id) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`;
const ws = new WebSocket(wsUrl);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'printer_status' && data.printer_id === id) {
queryClient.setQueryData(['printerStatus', id], data.status);
}
} catch {
// Ignore parse errors
}
};
ws.onerror = () => {
// WebSocket error - polling will continue as fallback
};
return () => {
ws.close();
};
}, [id, queryClient]);
// Update document title
useEffect(() => {
document.title = printer ? `${printer.name} - Stream Overlay` : 'Stream Overlay';
return () => {
document.title = 'Bambuddy';
};
}, [printer]);
// Refresh stream on error
const handleStreamError = () => {
setTimeout(() => {
setImageKey(Date.now());
}, 3000);
};
if (!id) {
return (
<div className="min-h-screen bg-black flex items-center justify-center">
<p className="text-white">Invalid printer ID</p>
</div>
);
}
if (!status) {
return (
<div className="min-h-screen bg-black flex items-center justify-center">
<p className="text-gray-400">Loading...</p>
</div>
);
}
const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
const progress = status.progress || 0;
const streamUrl = `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}`;
return (
<div className="min-h-screen bg-black relative overflow-hidden">
{/* Camera feed - fullscreen background */}
<img
key={imageKey}
src={streamUrl}
alt="Camera stream"
className="absolute inset-0 w-full h-full object-contain"
onError={handleStreamError}
/>
{/* Bambuddy logo - top right */}
<a
href="https://github.com/maziggy/bambuddy"
target="_blank"
rel="noopener noreferrer"
className="absolute top-4 right-4 z-10"
>
<img
src="/img/bambuddy_logo_dark_transparent.png"
alt="Bambuddy"
className={`${sizes.logoHeight} object-contain drop-shadow-lg hover:scale-105 transition-transform`}
/>
</a>
{/* Status overlay - bottom */}
<div className="absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/80 via-black/60 to-transparent">
<div className={`${sizes.container}`}>
{/* Printer name */}
{config.showPrinter && printer && (
<div className={`flex items-center ${sizes.gap} mb-2`}>
<Printer className={`${sizes.icon} text-white/70`} />
<span className={`${sizes.text} text-white font-medium`}>{printer.name}</span>
</div>
)}
{/* Filename */}
{config.showFilename && status.current_print && (
<div className={`${sizes.textLarge} text-white font-semibold mb-2 truncate drop-shadow-md`}>
{status.current_print.replace(/\.gcode\.3mf$|\.3mf$|\.gcode$/i, '')}
</div>
)}
{/* Status text */}
{config.showStatus && (
<div className={`${sizes.text} text-white/70 mb-2`}>
{getStatusText(status)}
</div>
)}
{/* Progress bar */}
{config.showProgress && isPrinting && (
<div className="mb-3">
<div className={`flex items-center justify-between mb-1 ${sizes.text}`}>
<span className="text-white/70">Progress</span>
<span className="text-white font-bold">{Math.round(progress)}%</span>
</div>
<div className={`w-full bg-white/20 rounded-full ${sizes.progressHeight}`}>
<div
className={`bg-bambu-green ${sizes.progressHeight} rounded-full transition-all duration-500`}
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{/* Stats row */}
{isPrinting && (config.showLayers || config.showEta) && (
<div className={`flex items-center ${sizes.gap} flex-wrap`}>
{/* Layers */}
{config.showLayers && status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
<div className={`flex items-center ${sizes.gap} text-white/70`}>
<Layers className={sizes.icon} />
<span className={sizes.text}>
<span className="text-white">{status.layer_num}</span>
<span className="mx-1">/</span>
<span>{status.total_layers}</span>
</span>
</div>
)}
{/* Remaining time */}
{config.showEta && status.remaining_time != null && status.remaining_time > 0 && (
<>
<div className={`flex items-center ${sizes.gap} text-white/70`}>
<Timer className={sizes.icon} />
<span className={`${sizes.text} text-white`}>
{formatTime(status.remaining_time * 60)}
</span>
</div>
<div className={`flex items-center ${sizes.gap} text-white/70`}>
<Clock className={sizes.icon} />
<span className={`${sizes.text} text-white`}>
ETA {formatETA(status.remaining_time)}
</span>
</div>
</>
)}
</div>
)}
{/* Idle state */}
{!isPrinting && (
<div className={`${sizes.text} text-white/70 py-2`}>
{status.connected ? 'Printer is idle' : 'Printer offline'}
</div>
)}
</div>
</div>
</div>
);
}
+234
View File
@@ -0,0 +1,234 @@
# BamBuddy Installation Scripts
Interactive installation scripts for BamBuddy with support for both native and Docker deployments.
## Quick Start
### Docker Installation (Recommended)
**Linux/macOS:**
```bash
curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/docker-install.sh | bash
```
### Native Installation
**Linux/macOS:**
```bash
curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh | bash
```
---
## Scripts Overview
| Script | Platform | Method |
|--------|----------|--------|
| `install.sh` | Linux, macOS | Native (Python venv) |
| `docker-install.sh` | Linux, macOS | Docker |
---
## Native Installation Scripts
### `install.sh` (Linux/macOS)
Installs BamBuddy with Python virtual environment and optional systemd/launchd service.
**Supported Systems:**
- Debian/Ubuntu (apt)
- RHEL/Fedora/CentOS (dnf/yum)
- Arch Linux (pacman)
- openSUSE (zypper)
- macOS (Homebrew)
**Options:**
```
--path PATH Installation directory (default: /opt/bambuddy)
--port PORT Port to listen on (default: 8000)
--tz TIMEZONE Timezone (default: system timezone)
--data-dir PATH Data directory (default: INSTALL_PATH/data)
--log-dir PATH Log directory (default: INSTALL_PATH/logs)
--debug Enable debug mode
--log-level LEVEL Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
--no-service Skip systemd/launchd service setup
--yes, -y Non-interactive mode, accept defaults
```
**Examples:**
```bash
# Interactive installation
./install.sh
# Unattended with custom settings
./install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes
# Minimal unattended
./install.sh -y
# Skip service setup
./install.sh --no-service -y
```
---
## Docker Installation Scripts
### `docker-install.sh` (Linux/macOS)
Installs BamBuddy using Docker containers.
**Options:**
```
--path PATH Installation directory (default: ~/bambuddy)
--port PORT Port to expose (default: 8000)
--tz TIMEZONE Timezone (default: system timezone)
--build Build from source instead of using pre-built image
--yes, -y Non-interactive mode, accept defaults
```
**Examples:**
```bash
# Interactive installation
./docker-install.sh
# Unattended with custom settings
./docker-install.sh --path /srv/bambuddy --port 3000 --tz Europe/Berlin --yes
# Build from source
./docker-install.sh --build --yes
```
---
## Configuration Options
All scripts support these configuration options:
| Option | Description | Default |
|--------|-------------|---------|
| Install Path | Where BamBuddy is installed | `/opt/bambuddy` (Linux/Docker) |
| Port | HTTP port for web interface | `8000` |
| Timezone | Server timezone | System timezone or `UTC` |
| Data Directory | Database and archives | `INSTALL_PATH/data` |
| Log Directory | Application logs | `INSTALL_PATH/logs` |
| Debug Mode | Enable verbose logging | `false` |
| Log Level | INFO, WARNING, ERROR, DEBUG | `INFO` |
---
## Post-Installation
### Accessing BamBuddy
After installation, open your browser to:
```
http://localhost:8000
```
Or use the port you specified during installation.
### Service Management
**Linux (systemd):**
```bash
sudo systemctl status bambuddy # Check status
sudo systemctl start bambuddy # Start
sudo systemctl stop bambuddy # Stop
sudo systemctl restart bambuddy # Restart
sudo journalctl -u bambuddy -f # View logs
```
**macOS (launchd):**
```bash
launchctl list | grep bambuddy # Check status
launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist # Start
launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist # Stop
```
**Docker:**
```bash
docker compose ps # Check status
docker compose up -d # Start
docker compose down # Stop
docker compose restart # Restart
docker compose logs -f # View logs
```
### Updating
**Native installation:**
```bash
cd /opt/bambuddy
git pull
source venv/bin/activate
pip install -r requirements.txt
cd frontend && npm ci && npm run build
sudo systemctl restart bambuddy # Linux
```
**Docker (pre-built image):**
```bash
cd ~/bambuddy
docker compose pull
docker compose up -d
```
**Docker (from source):**
```bash
cd ~/bambuddy
git pull
docker compose up -d --build
```
---
## Troubleshooting
### Permission Denied (Linux)
Run with `sudo` or ensure your user has appropriate permissions:
```bash
sudo ./install.sh
```
### Docker: Printer Discovery Not Working
Docker Desktop for macOS doesn't support host networking. Add printers manually by IP address in the BamBuddy web interface.
### Service Won't Start
Check logs for errors:
```bash
# Linux
sudo journalctl -u bambuddy -n 50
# Docker
docker compose logs bambuddy
```
### Port Already in Use
Choose a different port during installation or stop the conflicting service:
```bash
# Find what's using port 8000
sudo lsof -i :8000 # Linux/macOS
```
---
## Requirements
### Native Installation
- Python 3.10+ (automatically installed if missing)
- Node.js 18+ (automatically installed if missing)
- Git (automatically installed if missing)
- ~500MB disk space
### Docker Installation
- Docker Engine 20+ or Docker Desktop
- ~1GB disk space (includes image)
---
## Support
- **Documentation:** https://wiki.bambuddy.cool
- **Discord:** https://discord.gg/aFS3ZfScHM
- **Issues:** https://github.com/maziggy/bambuddy/issues
+541
View File
@@ -0,0 +1,541 @@
#!/usr/bin/env bash
#
# BamBuddy Docker Installation Script
# Supports: Linux (all distros), macOS
#
# Usage:
# Interactive: curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/docker-install.sh | bash
# Unattended: ./docker-install.sh --path /opt/bambuddy --port 8000 --yes
#
# Options:
# --path PATH Installation directory (default: /opt/bambuddy)
# --port PORT Port to expose (default: 8000)
# --bind ADDRESS Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)
# --tz TIMEZONE Timezone (default: system timezone or UTC)
# --build Build from source instead of using pre-built image
# --yes, -y Non-interactive mode, accept defaults
# --help, -h Show this help message
#
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# Default values
DEFAULT_INSTALL_PATH="/opt/bambuddy"
DEFAULT_PORT="8000"
DEFAULT_BIND_ADDRESS="0.0.0.0"
# Script variables
INSTALL_PATH=""
PORT=""
BIND_ADDRESS=""
TIMEZONE=""
BUILD_FROM_SOURCE="false"
NON_INTERACTIVE="false"
OS_TYPE=""
DOCKER_CMD=""
# -----------------------------------------------------------------------------
# Helper Functions
# -----------------------------------------------------------------------------
print_banner() {
echo -e "${CYAN}"
echo "╔════════════════════════════════════════════════════════╗"
echo "║ ║"
echo "║ ____ _ _ _ ║"
echo "║ | __ ) __ _ _ __ ___ | |__ _ _ __| | __| |_ _ ║"
echo "║ | _ \\ / _\` | '_ \` _ \\| '_ \\| | | |/ _\` |/ _\` | | | | ║"
echo "║ | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | ║"
echo "║ |____/ \\__,_|_| |_| |_|_.__/ \\__,_|\\__,_|\\__,_|\\__, | ║"
echo "║ |___/ ║"
echo "║ ║"
echo "║ Docker Installation Script ║"
echo "║ ║"
echo "╚════════════════════════════════════════════════════════╝"
echo -e "${NC}"
}
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[OK]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
prompt() {
local prompt_text="$1"
local default_value="$2"
local var_name="$3"
if [[ "$NON_INTERACTIVE" == "true" ]]; then
eval "$var_name=\"$default_value\""
return
fi
if [[ -n "$default_value" ]]; then
echo -en "${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: "
else
echo -en "${BOLD}$prompt_text${NC}: "
fi
read -r input
if [[ -z "$input" ]]; then
eval "$var_name=\"$default_value\""
else
eval "$var_name=\"$input\""
fi
}
prompt_yes_no() {
local prompt_text="$1"
local default="$2" # y or n
if [[ "$NON_INTERACTIVE" == "true" ]]; then
[[ "$default" == "y" ]] && return 0 || return 1
fi
local yn_hint="[y/n]"
[[ "$default" == "y" ]] && yn_hint="[Y/n]"
[[ "$default" == "n" ]] && yn_hint="[y/N]"
while true; do
echo -en "${BOLD}$prompt_text${NC} $yn_hint: "
read -r yn
[[ -z "$yn" ]] && yn="$default"
case "$yn" in
[Yy]* ) return 0;;
[Nn]* ) return 1;;
* ) echo "Please answer yes or no.";;
esac
done
}
show_help() {
echo "BamBuddy Docker Installation Script"
echo ""
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --path PATH Installation directory (default: /opt/bambuddy)"
echo " --port PORT Port to expose (default: 8000)"
echo " --bind ADDRESS Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)"
echo " --tz TIMEZONE Timezone (default: system timezone or UTC)"
echo " --build Build from source instead of using pre-built image"
echo " --yes, -y Non-interactive mode, accept defaults"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " Interactive installation:"
echo " ./docker-install.sh"
echo ""
echo " Unattended installation with custom settings:"
echo " ./docker-install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes"
echo ""
echo " Build from source:"
echo " ./docker-install.sh --build --yes"
exit 0
}
# -----------------------------------------------------------------------------
# System Detection
# -----------------------------------------------------------------------------
detect_os() {
if [[ "$OSTYPE" == "darwin"* ]]; then
OS_TYPE="macos"
return
fi
if [[ -f /etc/os-release ]]; then
OS_TYPE="linux"
else
log_error "Cannot detect operating system"
exit 1
fi
}
detect_docker() {
# Check for docker compose (v2) or docker-compose (v1)
if docker compose version &>/dev/null 2>&1; then
DOCKER_CMD="docker compose"
log_success "Found Docker Compose v2"
return 0
elif docker-compose --version &>/dev/null 2>&1; then
DOCKER_CMD="docker-compose"
log_success "Found Docker Compose v1"
return 0
fi
return 1
}
detect_timezone() {
if [[ -n "$TIMEZONE" ]]; then
return 0
fi
# Try to get system timezone (with error handling for set -e)
TIMEZONE=""
if [[ -f /etc/timezone ]]; then
TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true
fi
if [[ -z "$TIMEZONE" ]] && [[ -L /etc/localtime ]]; then
TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true
fi
if [[ -z "$TIMEZONE" ]] && command -v timedatectl &>/dev/null; then
TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
fi
# Default to UTC if not found (use if/then to avoid set -e issue with &&)
if [[ -z "$TIMEZONE" ]]; then
TIMEZONE="UTC"
fi
return 0
}
# -----------------------------------------------------------------------------
# Installation Functions
# -----------------------------------------------------------------------------
install_docker() {
log_info "Docker not found, installing..."
case "$OS_TYPE" in
linux)
# Use Docker's convenience script
curl -fsSL https://get.docker.com | sh
# Add current user to docker group
if [[ -n "$SUDO_USER" ]]; then
sudo usermod -aG docker "$SUDO_USER"
log_warn "Added $SUDO_USER to docker group. You may need to log out and back in."
else
sudo usermod -aG docker "$USER"
log_warn "Added $USER to docker group. You may need to log out and back in."
fi
# Start Docker service
sudo systemctl enable docker
sudo systemctl start docker
;;
macos)
log_error "Docker Desktop not found."
log_error "Please install Docker Desktop for Mac from: https://www.docker.com/products/docker-desktop"
exit 1
;;
esac
log_success "Docker installed"
}
create_install_dir() {
log_info "Creating installation directory..."
mkdir -p "$INSTALL_PATH"
cd "$INSTALL_PATH"
log_success "Directory created: $INSTALL_PATH"
}
download_compose_file() {
log_info "Downloading docker-compose.yml..."
if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
# Clone the full repo for building
if [[ -d ".git" ]]; then
log_info "Existing repository found, updating..."
git fetch origin
git reset --hard origin/main
else
git clone https://github.com/maziggy/bambuddy.git .
fi
else
# Just download the compose file
curl -fsSL -o docker-compose.yml \
https://raw.githubusercontent.com/maziggy/bambuddy/main/docker-compose.yml
fi
log_success "docker-compose.yml ready"
}
create_env_file() {
log_info "Creating environment configuration..."
cat > .env << EOF
# BamBuddy Docker Configuration
# Generated by docker-install.sh on $(date)
# Port BamBuddy runs on
PORT=$PORT
# Timezone
TZ=$TIMEZONE
EOF
log_success "Environment file created"
}
customize_compose() {
# Detect if we need to disable host networking (macOS/Windows in Docker Desktop)
if [[ "$OS_TYPE" == "macos" ]]; then
log_warn "Docker Desktop detected. Host networking is not supported."
log_info "Modifying docker-compose.yml for port mapping..."
# Create a modified compose file for macOS
if [[ -f docker-compose.yml ]]; then
# Comment out network_mode: host and uncomment ports section
sed -i.bak \
-e 's/^[[:space:]]*network_mode: host/# network_mode: host/' \
-e 's/^[[:space:]]*#ports:/ ports:/' \
-e 's/^[[:space:]]*#[[:space:]]*- "\${PORT:-8000}:8000"/ - "\${PORT:-8000}:8000"/' \
docker-compose.yml
log_warn "Printer discovery may not work. Add printers manually by IP address."
fi
fi
}
start_container() {
log_info "Starting BamBuddy..."
if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
$DOCKER_CMD up -d --build
else
$DOCKER_CMD up -d
fi
# Wait for container to start
log_info "Waiting for container to start..."
local max_attempts=15
local attempt=0
while [[ $attempt -lt $max_attempts ]]; do
# Check if container is running (Up)
if $DOCKER_CMD ps | grep -q "Up"; then
log_success "BamBuddy container is running"
return 0
fi
# Check if container failed
if $DOCKER_CMD ps -a | grep -q "Exited"; then
log_error "Container failed to start"
log_info "Check logs with: $DOCKER_CMD logs bambuddy"
return 1
fi
sleep 2
((attempt++))
done
log_warn "Container may still be starting. Check with: $DOCKER_CMD ps"
}
# -----------------------------------------------------------------------------
# Main Installation Flow
# -----------------------------------------------------------------------------
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--path)
INSTALL_PATH="$2"
shift 2
;;
--port)
PORT="$2"
shift 2
;;
--bind)
BIND_ADDRESS="$2"
shift 2
;;
--tz)
TIMEZONE="$2"
shift 2
;;
--build)
BUILD_FROM_SOURCE="true"
shift
;;
--yes|-y)
NON_INTERACTIVE="true"
shift
;;
--help|-h)
show_help
;;
*)
log_error "Unknown option: $1"
show_help
;;
esac
done
}
gather_config() {
echo ""
echo -e "${BOLD}Installation Configuration${NC}"
echo -e "${CYAN}─────────────────────────────────────────${NC}"
echo ""
# Installation path
[[ -z "$INSTALL_PATH" ]] && prompt "Installation directory" "$DEFAULT_INSTALL_PATH" INSTALL_PATH
# Port
[[ -z "$PORT" ]] && prompt "Port to expose" "$DEFAULT_PORT" PORT
# Bind address
if [[ -z "$BIND_ADDRESS" ]]; then
echo ""
echo "Network access:"
echo " 0.0.0.0 - Accessible from other devices on your network (recommended)"
echo " 127.0.0.1 - Only accessible from this machine"
prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS
fi
# Timezone
detect_timezone
prompt "Timezone" "$TIMEZONE" TIMEZONE
# Build from source?
if [[ "$BUILD_FROM_SOURCE" != "true" ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
if prompt_yes_no "Build from source? (No = use pre-built image)" "n"; then
BUILD_FROM_SOURCE="true"
fi
fi
# Confirm
echo ""
echo -e "${BOLD}Installation Summary${NC}"
echo -e "${CYAN}─────────────────────────────────────────${NC}"
echo -e " Install path: ${GREEN}$INSTALL_PATH${NC}"
echo -e " Port: ${GREEN}$PORT${NC}"
echo -e " Bind address: ${GREEN}$BIND_ADDRESS${NC}"
echo -e " Timezone: ${GREEN}$TIMEZONE${NC}"
echo -e " Build source: ${GREEN}$BUILD_FROM_SOURCE${NC}"
echo ""
if ! prompt_yes_no "Proceed with installation?" "y"; then
echo "Installation cancelled."
exit 0
fi
}
main() {
parse_args "$@"
print_banner
# Check if running via pipe (curl | bash) - interactive mode won't work
if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
log_error "Interactive mode requires a terminal."
log_info "When using 'curl | bash', you must use non-interactive mode:"
echo ""
echo " curl -fsSL URL | bash -s -- --yes"
echo ""
log_info "Or download and run directly:"
echo ""
echo " curl -fsSL URL -o docker-install.sh && chmod +x docker-install.sh && ./docker-install.sh"
echo ""
exit 1
fi
# Detect system
log_info "Detecting system..."
detect_os
log_success "Detected: $OS_TYPE"
# Check for Docker
if ! command -v docker &>/dev/null; then
install_docker
fi
if ! detect_docker; then
log_error "Docker Compose not found. Please install Docker Compose."
exit 1
fi
# Check if Docker daemon is running
if ! docker info &>/dev/null; then
log_error "Docker daemon is not running. Please start Docker and try again."
exit 1
fi
# Gather configuration
gather_config
# Install steps
echo ""
echo -e "${BOLD}Starting Installation${NC}"
echo -e "${CYAN}─────────────────────────────────────────${NC}"
echo ""
create_install_dir
download_compose_file
create_env_file
customize_compose
start_container
# Done!
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ ║${NC}"
echo -e "${GREEN}║ Installation Complete! ║${NC}"
echo -e "${GREEN}║ ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}"
echo ""
# Show appropriate URL based on bind address
if [[ "$BIND_ADDRESS" == "0.0.0.0" ]]; then
local ip_addr
ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="<your-ip>"
echo -e " ${BOLD}Access BamBuddy:${NC} ${CYAN}http://localhost:$PORT${NC}"
echo -e " ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)"
else
echo -e " ${BOLD}Access BamBuddy:${NC} ${CYAN}http://localhost:$PORT${NC}"
fi
echo ""
echo -e " ${BOLD}Manage container:${NC}"
echo -e " Status: cd $INSTALL_PATH && $DOCKER_CMD ps"
echo -e " Logs: cd $INSTALL_PATH && $DOCKER_CMD logs -f bambuddy"
echo -e " Stop: cd $INSTALL_PATH && $DOCKER_CMD down"
echo -e " Start: cd $INSTALL_PATH && $DOCKER_CMD up -d"
echo -e " Restart: cd $INSTALL_PATH && $DOCKER_CMD restart"
echo ""
echo -e " ${BOLD}Update BamBuddy:${NC}"
if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
echo -e " cd $INSTALL_PATH && git pull && $DOCKER_CMD up -d --build"
else
echo -e " cd $INSTALL_PATH && $DOCKER_CMD pull && $DOCKER_CMD up -d"
fi
echo ""
echo -e " ${BOLD}Data location:${NC} Docker volumes (bambuddy_data, bambuddy_logs)"
echo ""
echo -e " ${BOLD}Documentation:${NC} ${CYAN}https://wiki.bambuddy.cool${NC}"
echo ""
if [[ "$OS_TYPE" == "macos" ]]; then
echo -e " ${YELLOW}Note:${NC} Printer discovery may not work with Docker Desktop."
echo -e " Add printers manually using their IP address."
echo ""
fi
}
main "$@"
+883
View File
@@ -0,0 +1,883 @@
#!/usr/bin/env bash
#
# BamBuddy Native Installation Script
# Supports: Debian/Ubuntu, RHEL/Fedora/CentOS, Arch Linux, macOS
#
# Usage:
# Interactive: curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh | bash
# Unattended: ./install.sh --path /opt/bambuddy --port 8000 --yes
#
# Options:
# --path PATH Installation directory (default: /opt/bambuddy)
# --port PORT Port to listen on (default: 8000)
# --bind ADDRESS Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)
# --tz TIMEZONE Timezone (default: system timezone or UTC)
# --data-dir PATH Data directory (default: INSTALL_PATH/data)
# --log-dir PATH Log directory (default: INSTALL_PATH/logs)
# --debug Enable debug mode
# --log-level LEVEL Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
# --no-service Skip systemd service setup (Linux only)
# --set-system-tz Set system timezone to match (for unattended installs)
# --yes, -y Non-interactive mode, accept defaults
# --help, -h Show this help message
#
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# Default values
DEFAULT_INSTALL_PATH="/opt/bambuddy"
DEFAULT_PORT="8000"
DEFAULT_BIND_ADDRESS="0.0.0.0"
DEFAULT_LOG_LEVEL="INFO"
DEFAULT_DEBUG="false"
# Script variables
INSTALL_PATH=""
PORT=""
BIND_ADDRESS=""
TIMEZONE=""
DATA_DIR=""
LOG_DIR=""
DEBUG_MODE=""
LOG_LEVEL=""
SKIP_SERVICE="false"
SET_SYSTEM_TZ=""
NON_INTERACTIVE="false"
OS_TYPE=""
PKG_MANAGER=""
PYTHON_CMD=""
SERVICE_USER="bambuddy"
# -----------------------------------------------------------------------------
# Helper Functions
# -----------------------------------------------------------------------------
print_banner() {
echo -e "${CYAN}"
echo "╔════════════════════════════════════════════════════════╗"
echo "║ ║"
echo "║ ____ _ _ _ ║"
echo "║ | __ ) __ _ _ __ ___ | |__ _ _ __| | __| |_ _ ║"
echo "║ | _ \\ / _\` | '_ \` _ \\| '_ \\| | | |/ _\` |/ _\` | | | | ║"
echo "║ | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | ║"
echo "║ |____/ \\__,_|_| |_| |_|_.__/ \\__,_|\\__,_|\\__,_|\\__, | ║"
echo "║ |___/ ║"
echo "║ ║"
echo "║ Native Installation Script ║"
echo "║ ║"
echo "╚════════════════════════════════════════════════════════╝"
echo -e "${NC}"
}
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[OK]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
prompt() {
local prompt_text="$1"
local default_value="$2"
local var_name="$3"
if [[ "$NON_INTERACTIVE" == "true" ]]; then
eval "$var_name=\"$default_value\""
return
fi
if [[ -n "$default_value" ]]; then
echo -en "${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: "
else
echo -en "${BOLD}$prompt_text${NC}: "
fi
read -r input
if [[ -z "$input" ]]; then
eval "$var_name=\"$default_value\""
else
eval "$var_name=\"$input\""
fi
}
prompt_yes_no() {
local prompt_text="$1"
local default="$2" # y or n
if [[ "$NON_INTERACTIVE" == "true" ]]; then
[[ "$default" == "y" ]] && return 0 || return 1
fi
local yn_hint="[y/n]"
[[ "$default" == "y" ]] && yn_hint="[Y/n]"
[[ "$default" == "n" ]] && yn_hint="[y/N]"
while true; do
echo -en "${BOLD}$prompt_text${NC} $yn_hint: "
read -r yn
[[ -z "$yn" ]] && yn="$default"
case "$yn" in
[Yy]* ) return 0;;
[Nn]* ) return 1;;
* ) echo "Please answer yes or no.";;
esac
done
}
show_help() {
echo "BamBuddy Native Installation Script"
echo ""
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --path PATH Installation directory (default: /opt/bambuddy)"
echo " --port PORT Port to listen on (default: 8000)"
echo " --bind ADDRESS Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)"
echo " --tz TIMEZONE Timezone (default: system timezone or UTC)"
echo " --data-dir PATH Data directory (default: INSTALL_PATH/data)"
echo " --log-dir PATH Log directory (default: INSTALL_PATH/logs)"
echo " --debug Enable debug mode"
echo " --log-level LEVEL Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)"
echo " --no-service Skip systemd service setup (Linux only)"
echo " --set-system-tz Set system timezone to match (for unattended installs)"
echo " --yes, -y Non-interactive mode, accept defaults"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " Interactive installation:"
echo " ./install.sh"
echo ""
echo " Unattended installation with custom settings:"
echo " ./install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes"
echo ""
echo " Minimal unattended installation:"
echo " ./install.sh -y"
exit 0
}
# -----------------------------------------------------------------------------
# System Detection
# -----------------------------------------------------------------------------
detect_os() {
if [[ "$OSTYPE" == "darwin"* ]]; then
OS_TYPE="macos"
PKG_MANAGER="brew"
return
fi
if [[ -f /etc/os-release ]]; then
. /etc/os-release
case "$ID" in
ubuntu|debian|raspbian|linuxmint|pop)
OS_TYPE="debian"
PKG_MANAGER="apt"
;;
fedora|rhel|centos|rocky|almalinux|ol)
OS_TYPE="rhel"
if command -v dnf &>/dev/null; then
PKG_MANAGER="dnf"
else
PKG_MANAGER="yum"
fi
;;
arch|manjaro|endeavouros)
OS_TYPE="arch"
PKG_MANAGER="pacman"
;;
opensuse*|sles)
OS_TYPE="suse"
PKG_MANAGER="zypper"
;;
*)
log_error "Unsupported Linux distribution: $ID"
exit 1
;;
esac
else
log_error "Cannot detect operating system"
exit 1
fi
}
detect_python() {
# Try python3 first, then python
if command -v python3 &>/dev/null; then
PYTHON_CMD="python3"
elif command -v python &>/dev/null; then
local version
version=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1)
if [[ "$version" -ge 3 ]]; then
PYTHON_CMD="python"
fi
fi
if [[ -z "$PYTHON_CMD" ]]; then
return 1
fi
# Check version >= 3.10
local version
version=$($PYTHON_CMD -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
local major minor
major=$(echo "$version" | cut -d'.' -f1)
minor=$(echo "$version" | cut -d'.' -f2)
if [[ "$major" -lt 3 ]] || { [[ "$major" -eq 3 ]] && [[ "$minor" -lt 10 ]]; }; then
log_warn "Python $version found, but 3.10+ is required"
return 1
fi
log_success "Found Python $version"
return 0
}
detect_timezone() {
if [[ -n "$TIMEZONE" ]]; then
return 0
fi
# Try to get system timezone (with error handling for set -e)
TIMEZONE=""
if [[ -f /etc/timezone ]]; then
TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true
fi
if [[ -z "$TIMEZONE" ]] && [[ -L /etc/localtime ]]; then
TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true
fi
if [[ -z "$TIMEZONE" ]] && command -v timedatectl &>/dev/null; then
TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
fi
# Default to UTC if not found (use if/then to avoid set -e issue with &&)
if [[ -z "$TIMEZONE" ]]; then
TIMEZONE="UTC"
fi
return 0
}
# -----------------------------------------------------------------------------
# Package Installation
# -----------------------------------------------------------------------------
install_dependencies() {
log_info "Installing system dependencies..."
case "$PKG_MANAGER" in
apt)
sudo apt-get update
sudo apt-get install -y python3 python3-pip python3-venv git curl ffmpeg
;;
dnf|yum)
sudo $PKG_MANAGER install -y python3 python3-pip git curl ffmpeg
;;
pacman)
sudo pacman -Sy --noconfirm python python-pip git curl ffmpeg
;;
zypper)
sudo zypper install -y python3 python3-pip git curl ffmpeg
;;
brew)
# Check if Homebrew is installed
if ! command -v brew &>/dev/null; then
log_error "Homebrew not found. Please install it first: https://brew.sh"
exit 1
fi
brew install python git curl ffmpeg
;;
esac
log_success "System dependencies installed"
}
# -----------------------------------------------------------------------------
# Installation Steps
# -----------------------------------------------------------------------------
create_user() {
if [[ "$OS_TYPE" == "macos" ]]; then
return # Skip user creation on macOS
fi
if id "$SERVICE_USER" &>/dev/null; then
log_info "User '$SERVICE_USER' already exists"
return
fi
log_info "Creating service user '$SERVICE_USER'..."
sudo useradd --system --shell /usr/sbin/nologin --home-dir "$INSTALL_PATH" "$SERVICE_USER"
log_success "Service user created"
}
download_bambuddy() {
log_info "Downloading BamBuddy..."
if [[ -d "$INSTALL_PATH/.git" ]]; then
log_info "Existing installation found, updating..."
# Add safe.directory to avoid "dubious ownership" error when running as root
git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true
cd "$INSTALL_PATH"
git fetch origin
git reset --hard origin/main
# Ensure correct ownership after update
sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
else
sudo mkdir -p "$INSTALL_PATH"
sudo chown "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
git clone https://github.com/maziggy/bambuddy.git "$INSTALL_PATH"
sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
fi
log_success "BamBuddy downloaded to $INSTALL_PATH"
}
setup_virtualenv() {
log_info "Setting up Python virtual environment..."
cd "$INSTALL_PATH"
if [[ "$OS_TYPE" == "macos" ]]; then
$PYTHON_CMD -m venv venv
source venv/bin/activate
else
sudo -u "$SERVICE_USER" $PYTHON_CMD -m venv venv 2>/dev/null || $PYTHON_CMD -m venv venv
source venv/bin/activate
fi
pip install --upgrade pip
pip install -r requirements.txt
log_success "Virtual environment configured"
}
check_node_version() {
# Returns 0 if Node.js 20+ is available, 1 otherwise
if ! command -v node &>/dev/null; then
return 1
fi
local version
version=$(node --version 2>/dev/null | sed 's/^v//')
local major
major=$(echo "$version" | cut -d'.' -f1)
if [[ "$major" -ge 20 ]]; then
log_success "Found Node.js v$version"
return 0
else
log_warn "Found Node.js v$version (need 20+)"
return 1
fi
}
install_nodejs() {
log_info "Installing Node.js 22..."
case "$PKG_MANAGER" in
apt)
# Remove old nodejs if present
sudo apt-get remove -y nodejs npm 2>/dev/null || true
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
;;
dnf|yum)
sudo $PKG_MANAGER remove -y nodejs npm 2>/dev/null || true
curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash -
sudo $PKG_MANAGER install -y nodejs
;;
pacman)
sudo pacman -S --noconfirm nodejs npm
;;
zypper)
sudo zypper install -y nodejs22
;;
brew)
brew install node@22
brew link --overwrite node@22
;;
*)
log_error "Please install Node.js 20+ manually: https://nodejs.org/"
exit 1
;;
esac
# Refresh PATH
hash -r 2>/dev/null || true
}
build_frontend() {
log_info "Building frontend..."
cd "$INSTALL_PATH/frontend"
# Check for Node.js 20+
if ! check_node_version; then
install_nodejs
# Verify installation
if ! check_node_version; then
log_error "Failed to install Node.js 20+. Please install manually."
exit 1
fi
fi
npm ci
npm run build
log_success "Frontend built"
}
create_directories() {
log_info "Creating data directories..."
sudo mkdir -p "$DATA_DIR" "$LOG_DIR"
if [[ "$OS_TYPE" != "macos" ]]; then
sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR" "$LOG_DIR"
fi
log_success "Directories created"
}
create_env_file() {
log_info "Creating environment configuration..."
local env_file="$INSTALL_PATH/.env"
# Note: Only include settings recognized by the app's pydantic Settings class
# Other settings (PORT, BIND_ADDRESS, DATA_DIR, LOG_DIR, TZ) are set in systemd service
cat > /tmp/bambuddy.env << EOF
# BamBuddy Configuration
# Generated by install.sh on $(date)
# Debug mode (true = verbose logging)
DEBUG=$DEBUG_MODE
# Log level (only used when DEBUG=false)
# Options: DEBUG, INFO, WARNING, ERROR
LOG_LEVEL=$LOG_LEVEL
# Enable file logging
LOG_TO_FILE=true
EOF
sudo mv /tmp/bambuddy.env "$env_file"
if [[ "$OS_TYPE" != "macos" ]]; then
sudo chown "$SERVICE_USER:$SERVICE_USER" "$env_file"
fi
sudo chmod 600 "$env_file"
log_success "Environment file created at $env_file"
}
create_systemd_service() {
if [[ "$OS_TYPE" == "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then
return
fi
log_info "Creating systemd service..."
cat > /tmp/bambuddy.service << EOF
[Unit]
Description=BamBuddy - Bambu Lab Print Management
Documentation=https://github.com/maziggy/bambuddy
After=network.target
[Service]
Type=simple
User=$SERVICE_USER
Group=$SERVICE_USER
WorkingDirectory=$INSTALL_PATH
# App settings from .env file
EnvironmentFile=$INSTALL_PATH/.env
# Service settings (not in .env to avoid pydantic validation errors)
Environment="DATA_DIR=$DATA_DIR"
Environment="LOG_DIR=$LOG_DIR"
Environment="TZ=$TIMEZONE"
ExecStart=$INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host $BIND_ADDRESS --port $PORT
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=$DATA_DIR $LOG_DIR $INSTALL_PATH
[Install]
WantedBy=multi-user.target
EOF
sudo mv /tmp/bambuddy.service /etc/systemd/system/bambuddy.service
sudo systemctl daemon-reload
log_success "Systemd service created"
if prompt_yes_no "Enable BamBuddy to start on boot?" "y"; then
sudo systemctl enable bambuddy
log_success "Service enabled"
fi
if prompt_yes_no "Start BamBuddy now?" "y"; then
sudo systemctl start bambuddy
sleep 2
if sudo systemctl is-active --quiet bambuddy; then
log_success "BamBuddy is running"
else
log_warn "Service may have failed to start. Check: sudo journalctl -u bambuddy -f"
fi
fi
}
create_launchd_service() {
if [[ "$OS_TYPE" != "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then
return
fi
log_info "Creating launchd service..."
local plist_path="$HOME/Library/LaunchAgents/com.bambuddy.app.plist"
cat > "$plist_path" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.bambuddy.app</string>
<key>ProgramArguments</key>
<array>
<string>$INSTALL_PATH/venv/bin/uvicorn</string>
<string>backend.app.main:app</string>
<string>--host</string>
<string>$BIND_ADDRESS</string>
<string>--port</string>
<string>$PORT</string>
</array>
<key>WorkingDirectory</key>
<string>$INSTALL_PATH</string>
<key>EnvironmentVariables</key>
<dict>
<key>DEBUG</key>
<string>$DEBUG_MODE</string>
<key>LOG_LEVEL</key>
<string>$LOG_LEVEL</string>
<key>DATA_DIR</key>
<string>$DATA_DIR</string>
<key>LOG_DIR</key>
<string>$LOG_DIR</string>
<key>TZ</key>
<string>$TIMEZONE</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>$LOG_DIR/bambuddy.log</string>
<key>StandardErrorPath</key>
<string>$LOG_DIR/bambuddy.error.log</string>
</dict>
</plist>
EOF
log_success "Launchd plist created at $plist_path"
if prompt_yes_no "Load BamBuddy service now?" "y"; then
launchctl load "$plist_path"
sleep 2
if launchctl list | grep -q "com.bambuddy.app"; then
log_success "BamBuddy is running"
else
log_warn "Service may have failed to start. Check: cat $LOG_DIR/bambuddy.error.log"
fi
fi
}
# -----------------------------------------------------------------------------
# Main Installation Flow
# -----------------------------------------------------------------------------
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--path)
INSTALL_PATH="$2"
shift 2
;;
--port)
PORT="$2"
shift 2
;;
--bind)
BIND_ADDRESS="$2"
shift 2
;;
--tz)
TIMEZONE="$2"
shift 2
;;
--data-dir)
DATA_DIR="$2"
shift 2
;;
--log-dir)
LOG_DIR="$2"
shift 2
;;
--debug)
DEBUG_MODE="true"
shift
;;
--log-level)
LOG_LEVEL="$2"
shift 2
;;
--no-service)
SKIP_SERVICE="true"
shift
;;
--set-system-tz)
SET_SYSTEM_TZ="true"
shift
;;
--yes|-y)
NON_INTERACTIVE="true"
shift
;;
--help|-h)
show_help
;;
*)
log_error "Unknown option: $1"
show_help
;;
esac
done
}
gather_config() {
echo ""
echo -e "${BOLD}Installation Configuration${NC}"
echo -e "${CYAN}─────────────────────────────────────────${NC}"
echo ""
# Installation path
[[ -z "$INSTALL_PATH" ]] && prompt "Installation directory" "$DEFAULT_INSTALL_PATH" INSTALL_PATH
# Port
[[ -z "$PORT" ]] && prompt "Port to listen on" "$DEFAULT_PORT" PORT
# Bind address
if [[ -z "$BIND_ADDRESS" ]]; then
echo ""
echo "Network access:"
echo " 0.0.0.0 - Accessible from other devices on your network (recommended)"
echo " 127.0.0.1 - Only accessible from this machine"
prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS
fi
# Timezone
detect_timezone
prompt "Timezone" "$TIMEZONE" TIMEZONE
# Offer to set system timezone if different from current (skip if already set via --set-system-tz)
if [[ -z "$SET_SYSTEM_TZ" ]]; then
local current_tz
current_tz=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
if [[ -n "$TIMEZONE" ]] && [[ "$TIMEZONE" != "$current_tz" ]]; then
# Default to "n" so unattended installs don't change system TZ unless --set-system-tz is used
if prompt_yes_no "Set system timezone to $TIMEZONE?" "n"; then
SET_SYSTEM_TZ="true"
else
SET_SYSTEM_TZ="false"
fi
else
SET_SYSTEM_TZ="false"
fi
fi
# Data directory
[[ -z "$DATA_DIR" ]] && DATA_DIR="$INSTALL_PATH/data"
prompt "Data directory" "$DATA_DIR" DATA_DIR
# Log directory
[[ -z "$LOG_DIR" ]] && LOG_DIR="$INSTALL_PATH/logs"
prompt "Log directory" "$LOG_DIR" LOG_DIR
# Debug mode
if [[ -z "$DEBUG_MODE" ]]; then
if prompt_yes_no "Enable debug mode?" "n"; then
DEBUG_MODE="true"
else
DEBUG_MODE="false"
fi
fi
# Log level
if [[ -z "$LOG_LEVEL" ]]; then
echo ""
echo "Log levels: DEBUG, INFO, WARNING, ERROR"
prompt "Log level" "$DEFAULT_LOG_LEVEL" LOG_LEVEL
fi
# Confirm
echo ""
echo -e "${BOLD}Installation Summary${NC}"
echo -e "${CYAN}─────────────────────────────────────────${NC}"
echo -e " Install path: ${GREEN}$INSTALL_PATH${NC}"
echo -e " Port: ${GREEN}$PORT${NC}"
echo -e " Bind address: ${GREEN}$BIND_ADDRESS${NC}"
echo -e " Timezone: ${GREEN}$TIMEZONE${NC}"
echo -e " Data dir: ${GREEN}$DATA_DIR${NC}"
echo -e " Log dir: ${GREEN}$LOG_DIR${NC}"
echo -e " Debug mode: ${GREEN}$DEBUG_MODE${NC}"
echo -e " Log level: ${GREEN}$LOG_LEVEL${NC}"
echo ""
if ! prompt_yes_no "Proceed with installation?" "y"; then
echo "Installation cancelled."
exit 0
fi
}
main() {
parse_args "$@"
print_banner
# Check if running via pipe (curl | bash) - interactive mode won't work
if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
log_error "Interactive mode requires a terminal."
log_info "When using 'curl | bash', you must use non-interactive mode:"
echo ""
echo " curl -fsSL URL | bash -s -- --yes"
echo ""
log_info "Or download and run directly:"
echo ""
echo " curl -fsSL URL -o install.sh && chmod +x install.sh && ./install.sh"
echo ""
exit 1
fi
# Check for root (we need sudo for some operations)
if [[ "$EUID" -eq 0 ]] && [[ "$OS_TYPE" != "macos" ]]; then
log_warn "Running as root. Consider using a regular user with sudo privileges."
fi
# Detect system
log_info "Detecting system..."
detect_os
log_success "Detected: $OS_TYPE (package manager: $PKG_MANAGER)"
# Check/install Python
if ! detect_python; then
log_info "Python 3.10+ not found, will install..."
fi
# Gather configuration
gather_config
# Install steps
echo ""
echo -e "${BOLD}Starting Installation${NC}"
echo -e "${CYAN}─────────────────────────────────────────${NC}"
echo ""
install_dependencies
detect_python || { log_error "Failed to install Python"; exit 1; }
# Set system timezone if requested
if [[ "$SET_SYSTEM_TZ" == "true" ]]; then
log_info "Setting system timezone to $TIMEZONE..."
if [[ "$OS_TYPE" == "macos" ]]; then
sudo systemsetup -settimezone "$TIMEZONE" 2>/dev/null || true
else
sudo timedatectl set-timezone "$TIMEZONE" 2>/dev/null || true
fi
log_success "System timezone set to $TIMEZONE"
fi
if [[ "$OS_TYPE" != "macos" ]]; then
create_user
else
SERVICE_USER="$USER"
fi
download_bambuddy
setup_virtualenv
build_frontend
create_directories
create_env_file
if [[ "$OS_TYPE" == "macos" ]]; then
create_launchd_service
else
create_systemd_service
fi
# Done!
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ ║${NC}"
echo -e "${GREEN}║ Installation Complete! ║${NC}"
echo -e "${GREEN}║ ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}"
echo ""
# Show appropriate URL based on bind address
if [[ "$BIND_ADDRESS" == "0.0.0.0" ]]; then
local ip_addr
ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="<your-ip>"
echo -e " ${BOLD}Access BamBuddy:${NC} ${CYAN}http://localhost:$PORT${NC}"
echo -e " ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)"
else
echo -e " ${BOLD}Access BamBuddy:${NC} ${CYAN}http://localhost:$PORT${NC}"
fi
echo ""
if [[ "$OS_TYPE" == "macos" ]]; then
echo -e " ${BOLD}Manage service:${NC}"
echo -e " Start: launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist"
echo -e " Stop: launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist"
echo -e " Logs: tail -f $LOG_DIR/bambuddy.log"
else
echo -e " ${BOLD}Manage service:${NC}"
echo -e " Status: sudo systemctl status bambuddy"
echo -e " Start: sudo systemctl start bambuddy"
echo -e " Stop: sudo systemctl stop bambuddy"
echo -e " Logs: sudo journalctl -u bambuddy -f"
fi
echo ""
echo -e " ${BOLD}Update BamBuddy:${NC}"
echo -e " cd $INSTALL_PATH && git pull && source venv/bin/activate"
echo -e " pip install -r requirements.txt && cd frontend && npm ci && npm run build"
if [[ "$OS_TYPE" != "macos" ]]; then
echo -e " sudo systemctl restart bambuddy"
fi
echo ""
echo -e " ${BOLD}Documentation:${NC} ${CYAN}https://wiki.bambuddy.cool${NC}"
echo ""
}
main "$@"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long