Skip to content
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,28 @@ SpoolmanSync automatically tracks which filament spools are loaded in your Bambu
- **Webhook Integration** - Receives tray change events from Home Assistant automations
- **Activity Logging** - Track all spool changes and sync events

## Home Assistant Integration (HACS)

Manage your AMS trays directly from your Home Assistant dashboard with the SpoolmanSync integration.

### Features
- **AMS Tray Selection**: Assign spools to trays using a simple dropdown menu.
- **Spool Information**: View detailed filament data (material, weight, color) for each tray.
- **Modern Dashboard Card**: Includes a custom Tile-based card for a clean, native look.
- **Automatic Sync**: Stays in sync with the SpoolmanSync web app and QR/NFC scans.

### Installation via HACS
1. Ensure [HACS](https://hacs.xyz/) is installed.
2. Go to **HACS** → **Integrations**.
3. Click **⋮** (top right) → **Custom repositories**.
4. Add: `https://github.com/gibz104/SpoolmanSync` with category **Integration**.
5. Find **SpoolmanSync** in HACS and click **Download**.
6. Restart Home Assistant.
7. Go to **Settings** → **Devices & Services** → **Add Integration** and search for **SpoolmanSync**.
8. Enter your SpoolmanSync URL (e.g., `http://192.168.0.34:3000`).

---

## Home Assistant Add-on (Recommended for HA OS users)

If you're running Home Assistant OS or Supervised, install SpoolmanSync directly as an add-on:
Expand Down
48 changes: 48 additions & 0 deletions custom_components/spoolmansync/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# SpoolmanSync Home Assistant Integration

This integration allows you to manage your Bambu Lab AMS tray assignments directly from Home Assistant.

## Features

- **AMS Tray Entities**: Creates `select` entities for each AMS tray (and external spool) discovered via SpoolmanSync.
- **Spool Assignment**: Change the assigned spool for any tray using the `select` entity.
- **Spool Info**: Provides sensors with detailed information about the currently assigned spool (vendor, material, remaining weight, etc.).

## Installation

1. Copy the `custom_components/spoolmansync` directory to your Home Assistant `custom_components` folder.
2. Restart Home Assistant.
3. Go to **Settings** -> **Devices & Services** -> **Add Integration**.
4. Search for **SpoolmanSync**.
5. Enter your SpoolmanSync URL (e.g., `http://192.168.0.34:3000`).

## Requirements

- **SpoolmanSync** must be running and accessible from Home Assistant.
- **ha-bambulab** integration must be installed and configured in Home Assistant (as SpoolmanSync relies on it for printer discovery).

## Lovelace AMS Card

A custom card is included to easily manage your AMS trays.

### Installation

1. Add Lovelace resource (**Settings > Dashboards > Resources > + Add Resource**):
**URL**: `/custom_components/spoolmansync/www/spoolmansync-card.js`
**Resource Type**: `JavaScript Module`

**Remote Access (Nabu Casa/Cloudflare Tunnel):** If 404 error, copy `custom_components/spoolmansync/www/spoolmansync-card.js` to your HA `config/www/spoolmansync-card.js` and use **URL**: `/local/spoolmansync-card.js`

2. Click **Reload Resources** (or refresh browser).

### Example

```yaml
type: custom:spoolmansync-card
tray1: select.p2s_22e8bj5b1400071_ams_1_tray_1
tray2: select.p2s_22e8bj5b1400071_ams_1_tray_2
tray3: select.p2s_22e8bj5b1400071_ams_1_tray_3
tray4: select.p2s_22e8bj5b1400071_ams_1_tray_4
```

The visual editor provides entity picker dropdowns for each tray (filtered to `select` domain).
87 changes: 87 additions & 0 deletions custom_components/spoolmansync/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import logging
import asyncio
import os
from datetime import timedelta
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.components.http import StaticPathConfig

_LOGGER = logging.getLogger(__name__)

DOMAIN = "spoolmansync"
PLATFORMS = [Platform.SELECT, Platform.SENSOR]

async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up SpoolmanSync from a config entry."""
url = entry.data[CONF_URL]
session = async_get_clientsession(hass)

async def async_get_data():
try:
# Fetch printers and spools from SpoolmanSync API
async with session.get(f"{url}/api/printers") as response:
if response.status != 200:
raise UpdateFailed(f"Error fetching printers: {response.status}")
printers_data = await response.json()

async with session.get(f"{url}/api/spools") as response:
if response.status != 200:
raise UpdateFailed(f"Error fetching spools: {response.status}")
spools_data = await response.json()

return {
"printers": printers_data.get("printers", []),
"spools": spools_data.get("spools", [])
}
except Exception as err:
raise UpdateFailed(f"Error communicating with SpoolmanSync: {err}")

coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name="spoolmansync",
update_method=async_get_data,
update_interval=timedelta(seconds=30),
)

await coordinator.async_config_entry_first_refresh()

hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator

# Register custom card
await async_register_custom_card(hass)

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

return True

async def async_register_custom_card(hass: HomeAssistant):
"""Register the custom card with Home Assistant."""
www_path = hass.config.path("custom_components", DOMAIN, "www")
if not os.path.exists(www_path):
return

# Use a versioned path to force browser cache refresh
version = "1.2.1"
url_path = f"/{DOMAIN}/local/{version}"

# Register using the modern async API (accepts a list of StaticPathConfig)
await hass.http.async_register_static_paths([
StaticPathConfig(
url_path=url_path,
path=www_path,
cache_headers=False # or True, depending on whether you want aggressive caching
)
])

async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)

return unload_ok
Binary file added custom_components/spoolmansync/brand/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added custom_components/spoolmansync/brand/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 38 additions & 0 deletions custom_components/spoolmansync/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import logging
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_URL
from homeassistant.helpers.aiohttp_client import async_get_clientsession

_LOGGER = logging.getLogger(__name__)

DOMAIN = "spoolmansync"

class SpoolmanSyncConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for SpoolmanSync."""

VERSION = 1

async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
url = user_input[CONF_URL].rstrip("/")
try:
session = async_get_clientsession(self.hass)
async with session.get(f"{url}/api/settings") as response:
if response.status == 200:
return self.async_create_entry(title="SpoolmanSync", data={CONF_URL: url})
else:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Error connecting to SpoolmanSync")
errors["base"] = "cannot_connect"

return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required(CONF_URL, default="http://192.168.0.34:3000"): str,
}),
errors=errors,
)
12 changes: 12 additions & 0 deletions custom_components/spoolmansync/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"domain": "spoolmansync",
"name": "SpoolmanSync",
"codeowners": ["@gibz104"],
"config_flow": true,
"documentation": "https://github.com/gibz104/SpoolmanSync",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/gibz104/SpoolmanSync/issues",
"requirements": [],
"version": "1.2.4",
"hacs": "2.0.0"
}
169 changes: 169 additions & 0 deletions custom_components/spoolmansync/select.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import logging
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.entity import DeviceInfo

from . import DOMAIN

_LOGGER = logging.getLogger(__name__)

async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up SpoolmanSync select entities."""
coordinator = hass.data[DOMAIN][entry.entry_id]
url = entry.data[CONF_URL]

entities = []

# We assume there is at least one printer and it has AMS units
# The user asked for 4 entities: AMS Tray 1, 2, 3, 4
# We will map these to the first printer's first AMS unit for simplicity,
# or create them based on discovered printers.

printers = coordinator.data.get("printers", [])
if not printers:
_LOGGER.warning("No printers found in SpoolmanSync")
return

# For each printer and each AMS unit, create tray entities
for printer in printers:
printer_name = printer.get("name", "Printer")
for ams in printer.get("ams_units", []):
ams_name = ams.get("name", "AMS")
for tray in ams.get("trays", []):
entities.append(
SpoolmanTraySelect(
coordinator,
url,
printer_name,
ams_name,
tray,
)
)

# Also handle external spool if it exists
if printer.get("external_spool"):
entities.append(
SpoolmanTraySelect(
coordinator,
url,
printer_name,
"External",
printer["external_spool"],
)
)

async_add_entities(entities)

class SpoolmanTraySelect(CoordinatorEntity, SelectEntity):
"""Representation of a Spoolman Tray selection."""

def __init__(self, coordinator, url, printer_name, ams_name, tray):
"""Initialize the select entity."""
super().__init__(coordinator)
self._url = url
self._printer_name = printer_name
self._ams_name = ams_name
self._tray_entity_id = tray["entity_id"]
self._tray_number = tray["tray_number"]

# Unique ID based on the HA entity ID of the tray
self._attr_unique_id = f"spoolmansync_{self._tray_entity_id}"
self._attr_name = f"{ams_name} Tray {self._tray_number}"
if ams_name == "External":
self._attr_name = "External Tray"

self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, printer_name)},
name=printer_name,
manufacturer="SpoolmanSync",
model="Bambu Lab Printer",
)

@property
def options(self) -> list[str]:
"""Return a list of available spools."""
spools = self.coordinator.data.get("spools", [])
options = ["None"]
for spool in spools:
vendor = spool.get("filament", {}).get("vendor", {}).get("name", "Unknown")
material = spool.get("filament", {}).get("material", "Unknown")
name = spool.get("filament", {}).get("name", "")
spool_label = f"#{spool['id']} {vendor} {material} {name}".strip()
options.append(spool_label)
return options

@property
def current_option(self) -> str | None:
"""Return the currently selected spool."""
spools = self.coordinator.data.get("spools", [])
for spool in spools:
active_tray = spool.get("extra", {}).get("active_tray")
if active_tray:
# Spoolman stores extra values as JSON strings
import json
try:
clean_tray_id = json.loads(active_tray)
if clean_tray_id == self._tray_entity_id:
vendor = spool.get("filament", {}).get("vendor", {}).get("name", "Unknown")
material = spool.get("filament", {}).get("material", "Unknown")
name = spool.get("filament", {}).get("name", "")
return f"#{spool['id']} {vendor} {material} {name}".strip()
except Exception:
if active_tray.strip('"') == self._tray_entity_id:
vendor = spool.get("filament", {}).get("vendor", {}).get("name", "Unknown")
material = spool.get("filament", {}).get("material", "Unknown")
name = spool.get("filament", {}).get("name", "")
return f"#{spool['id']} {vendor} {material} {name}".strip()
return "None"

async def async_select_option(self, option: str) -> None:
"""Change the selected spool."""
session = async_get_clientsession(self.hass)

if option == "None":
# Find current spool and unassign
current_spool_id = None
spools = self.coordinator.data.get("spools", [])
for spool in spools:
active_tray = spool.get("extra", {}).get("active_tray")
if active_tray:
import json
try:
if json.loads(active_tray) == self._tray_entity_id:
current_spool_id = spool["id"]
break
except:
if active_tray.strip('"') == self._tray_entity_id:
current_spool_id = spool["id"]
break

if current_spool_id:
async with session.delete(
f"{self._url}/api/spools",
json={"spoolId": current_spool_id}
) as response:
if response.status != 200:
_LOGGER.error("Failed to unassign spool: %s", await response.text())
else:
# Extract ID from option string (e.g., "#123 Vendor Material Name")
try:
spool_id = int(option.split(" ")[0].replace("#", ""))
async with session.post(
f"{self._url}/api/spools",
json={"spoolId": spool_id, "trayId": self._tray_entity_id}
) as response:
if response.status != 200:
_LOGGER.error("Failed to assign spool: %s", await response.text())
except Exception as err:
_LOGGER.error("Error parsing spool ID from option %s: %s", option, err)

await self.coordinator.async_request_refresh()
Loading