diff --git a/README.md b/README.md index 8885377..ef580f7 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/custom_components/spoolmansync/README.md b/custom_components/spoolmansync/README.md new file mode 100644 index 0000000..def0c68 --- /dev/null +++ b/custom_components/spoolmansync/README.md @@ -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). diff --git a/custom_components/spoolmansync/__init__.py b/custom_components/spoolmansync/__init__.py new file mode 100644 index 0000000..7136bb8 --- /dev/null +++ b/custom_components/spoolmansync/__init__.py @@ -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 diff --git a/custom_components/spoolmansync/brand/icon.png b/custom_components/spoolmansync/brand/icon.png new file mode 100644 index 0000000..147cc64 Binary files /dev/null and b/custom_components/spoolmansync/brand/icon.png differ diff --git a/custom_components/spoolmansync/brand/logo.png b/custom_components/spoolmansync/brand/logo.png new file mode 100644 index 0000000..b9c1e10 Binary files /dev/null and b/custom_components/spoolmansync/brand/logo.png differ diff --git a/custom_components/spoolmansync/config_flow.py b/custom_components/spoolmansync/config_flow.py new file mode 100644 index 0000000..d5cd2ca --- /dev/null +++ b/custom_components/spoolmansync/config_flow.py @@ -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, + ) diff --git a/custom_components/spoolmansync/manifest.json b/custom_components/spoolmansync/manifest.json new file mode 100644 index 0000000..1cfcf28 --- /dev/null +++ b/custom_components/spoolmansync/manifest.json @@ -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" +} diff --git a/custom_components/spoolmansync/select.py b/custom_components/spoolmansync/select.py new file mode 100644 index 0000000..4512b7b --- /dev/null +++ b/custom_components/spoolmansync/select.py @@ -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() diff --git a/custom_components/spoolmansync/sensor.py b/custom_components/spoolmansync/sensor.py new file mode 100644 index 0000000..37aa0be --- /dev/null +++ b/custom_components/spoolmansync/sensor.py @@ -0,0 +1,119 @@ +import logging +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +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 sensor entities.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + entities = [] + + printers = coordinator.data.get("printers", []) + 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( + SpoolmanTraySensor( + coordinator, + printer_name, + ams_name, + tray, + ) + ) + + if printer.get("external_spool"): + entities.append( + SpoolmanTraySensor( + coordinator, + printer_name, + "External", + printer["external_spool"], + ) + ) + + async_add_entities(entities) + +class SpoolmanTraySensor(CoordinatorEntity, SensorEntity): + """Representation of a Spoolman Tray sensor showing assigned spool info.""" + + def __init__(self, coordinator, printer_name, ams_name, tray): + """Initialize the sensor.""" + super().__init__(coordinator) + self._printer_name = printer_name + self._ams_name = ams_name + self._tray_entity_id = tray["entity_id"] + self._tray_number = tray["tray_number"] + + self._attr_unique_id = f"spoolmansync_sensor_{self._tray_entity_id}" + self._attr_name = f"{ams_name} Tray {self._tray_number} Info" + if ams_name == "External": + self._attr_name = "External Tray Info" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, printer_name)}, + name=printer_name, + manufacturer="SpoolmanSync", + model="Bambu Lab Printer", + ) + + @property + def native_value(self) -> str | None: + """Return the state of the sensor.""" + spools = self.coordinator.data.get("spools", []) + for spool in spools: + active_tray = spool.get("extra", {}).get("active_tray") + if active_tray: + import json + try: + clean_tray_id = json.loads(active_tray) + if clean_tray_id == self._tray_entity_id: + return f"Spool #{spool['id']}" + except Exception: + if active_tray.strip('"') == self._tray_entity_id: + return f"Spool #{spool['id']}" + return "No Spool" + + @property + def extra_state_attributes(self) -> dict: + """Return entity specific state attributes.""" + spools = self.coordinator.data.get("spools", []) + for spool in spools: + active_tray = spool.get("extra", {}).get("active_tray") + if active_tray: + import json + try: + clean_tray_id = json.loads(active_tray) + if clean_tray_id == self._tray_entity_id: + return { + "spool_id": spool["id"], + "vendor": spool.get("filament", {}).get("vendor", {}).get("name"), + "material": spool.get("filament", {}).get("material"), + "filament_name": spool.get("filament", {}).get("name"), + "remaining_weight": spool.get("remaining_weight"), + "color_hex": spool.get("filament", {}).get("color_hex"), + } + except Exception: + if active_tray.strip('"') == self._tray_entity_id: + return { + "spool_id": spool["id"], + "vendor": spool.get("filament", {}).get("vendor", {}).get("name"), + "material": spool.get("filament", {}).get("material"), + "filament_name": spool.get("filament", {}).get("name"), + "remaining_weight": spool.get("remaining_weight"), + "color_hex": spool.get("filament", {}).get("color_hex"), + } + return {} diff --git a/custom_components/spoolmansync/www/spoolmansync-card.js b/custom_components/spoolmansync/www/spoolmansync-card.js new file mode 100644 index 0000000..00dfafb --- /dev/null +++ b/custom_components/spoolmansync/www/spoolmansync-card.js @@ -0,0 +1,175 @@ +/** + * SpoolmanSync AMS Card + */ +class SpoolmanSyncCard extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + } + + set hass(hass) { + this._hass = hass; + if (!this.content) { + this.render(); + } else { + this.update(); + } + } + + setConfig(config) { + this._config = config; + if (this.content) { + this.update(); + } + } + + render() { + this.shadowRoot.innerHTML = ` + +
+ `; + this.content = this.shadowRoot.querySelector(".grid"); + this.update(); + } + + async update() { + if (!this.content || !this._config || !this._hass) return; + + this.content.innerHTML = ""; + + const trays = [ + { id: this._config.tray1, name: "Tray 1", color: "blue" }, + { id: this._config.tray2, name: "Tray 2", color: "orange" }, + { id: this._config.tray3, name: "Tray 3", color: "green" }, + { id: this._config.tray4, name: "Tray 4", color: "purple" } + ].filter(t => t.id && t.id !== ""); + + try { + const helpers = await window.loadCardHelpers(); + const createCardElement = helpers.createCardElement; + for (const tray of trays) { + const tileConfig = { + type: "tile", + entity: tray.id, + name: tray.name, + icon: "mdi:printer-3d-nozzle", + color: tray.color, + vertical: false, + features_position: "bottom" + }; + const tile = await createCardElement(tileConfig); + tile.hass = this._hass; + this.content.appendChild(tile); + } + } catch (e) { + console.error("Error rendering SpoolmanSync tiles:", e); + } + } + + static getConfigElement() { + return document.createElement("spoolmansync-card-editor"); + } + + static getStubConfig() { + return { tray1: "", tray2: "", tray3: "", tray4: "" }; + } +} + +customElements.define("spoolmansync-card", SpoolmanSyncCard); + +class SpoolmanSyncCardEditor extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + } + + set hass(hass) { + this._hass = hass; + if (!this.initialized) { + this.render(); + this.initialized = true; + } + } + + setConfig(config) { + this._config = config; + } + + render() { + this.shadowRoot.innerHTML = ` + +
+ + + + +
+ `; + + this.shadowRoot.querySelectorAll("ha-entity-picker").forEach(picker => { + picker.addEventListener("value-changed", (ev) => { + const field = picker.getAttribute("data-config"); + const value = ev.detail.value; + const event = new CustomEvent("config-changed", { + detail: { config: { ...this._config, [field]: value } }, + bubbles: true, + composed: true, + }); + this.dispatchEvent(event); + }); + }); + } +} + +customElements.define("spoolmansync-card-editor", SpoolmanSyncCardEditor); + +window.customCards = window.customCards || []; +window.customCards.push({ + type: "spoolmansync-card", + name: "SpoolmanSync AMS Card", + description: "A card to manage your SpoolmanSync AMS trays", + preview: true, +}); + +console.info( + "%c SPOOLMANSYNC-CARD %c 1.2.3 ", + "color: white; background: #03a9f4; font-weight: 700;", + "color: #03a9f4; background: white; font-weight: 700;" +); diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..3c154c8 --- /dev/null +++ b/hacs.json @@ -0,0 +1,8 @@ +{ + "name": "SpoolmanSync", + "content_in_root": false, + "zip_release": false, + "render_readme": true, + "country": [], + "filename": "spoolmansync-card.js" +} diff --git a/homeassistant/dashboard_card_tile.yaml b/homeassistant/dashboard_card_tile.yaml new file mode 100644 index 0000000..4c3e568 --- /dev/null +++ b/homeassistant/dashboard_card_tile.yaml @@ -0,0 +1,5 @@ +type: custom:spoolmansync-card +tray1: select.printer_ams_1_tray_1 +tray2: select.printer_ams_1_tray_2 +tray3: select.printer_ams_1_tray_3 +tray4: select.printer_ams_1_tray_4 diff --git a/repository.yaml b/repository.yaml index 4cb2c0c..52c063f 100644 --- a/repository.yaml +++ b/repository.yaml @@ -1,3 +1,3 @@ -name: SpoolmanSync Add-ons +name: SpoolmanSync url: https://github.com/gibz104/SpoolmanSync maintainer: gibz104