Skip to content

Commit 324c2be

Browse files
authored
Merge pull request #839 from Disane87/fix/dynamic-spools-and-empty-locations
fix: dynamically add new spools and expose empty Spoolman locations
2 parents 9593fca + 5db99d1 commit 324c2be

4 files changed

Lines changed: 199 additions & 30 deletions

File tree

custom_components/spoolman/classes/spoolman_api.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,21 @@ async def backup(self):
5959
_LOGGER.debug("SpoolmanAPI: backup response %s", response)
6060
return response
6161

62+
async def get_locations(self):
63+
"""Return the list of configured spool locations from Spoolman.
64+
65+
Uses ``GET /api/v1/location`` so that locations without any assigned
66+
spool (e.g. empty AMS trays) are still selectable in HA.
67+
"""
68+
_LOGGER.debug("SpoolmanAPI: get_locations")
69+
url = f"{self.base_url}/location"
70+
session = await self._get_session()
71+
async with session.get(url) as response:
72+
response.raise_for_status()
73+
payload = await response.json()
74+
_LOGGER.debug("SpoolmanAPI: get_locations response %s", payload)
75+
return [str(loc) for loc in payload if loc]
76+
6277
async def get_extra_fields(self, entity_type):
6378
"""Return extra-field metadata for the given entity type (e.g. ``spool``).
6479

custom_components/spoolman/coordinator.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ async def _async_update_data(self):
6464
# Older Spoolman versions or transient errors: degrade gracefully.
6565
_LOGGER.debug("Could not fetch spool extra-field metadata: %s", exception)
6666
spool_extra_fields = {}
67+
try:
68+
locations = await self.spoolman_api.get_locations()
69+
except Exception as exception:
70+
# /location endpoint isn't on every Spoolman version; fall back
71+
# to deriving from spools at the consumer side.
72+
_LOGGER.debug("Could not fetch locations: %s", exception)
73+
locations = None
6774
except asyncio.CancelledError:
6875
# Task was cancelled (e.g., during shutdown), re-raise to let coordinator handle it
6976
_LOGGER.debug("Data update was cancelled")
@@ -104,10 +111,20 @@ async def _async_update_data(self):
104111
_LOGGER.error(f"Error processing Klipper API data: {exception}")
105112
# Continue returning spools even if Klipper processing fails
106113

114+
# Fall back to deriving locations from spools when Spoolman doesn't
115+
# expose /location, so older servers keep working.
116+
if locations is None:
117+
locations = sorted({
118+
spool["location"]
119+
for spool in spools
120+
if spool.get("location")
121+
})
122+
107123
return {
108124
"spools": spools,
109125
"filaments": filaments,
110126
"extra_fields": {"spool": spool_extra_fields},
127+
"locations": locations,
111128
}
112129

113130
async def async_cleanup_extra_fields(self):

custom_components/spoolman/select.py

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,19 @@
1616
ICON = "mdi:map-marker"
1717

1818

19+
def _resolve_locations(coordinator_data, spools):
20+
"""Pick the canonical location list, preferring Spoolman's /location feed.
21+
22+
Falls back to the set of locations actually used by spools so older
23+
Spoolman servers (without /api/v1/location) still work.
24+
"""
25+
locations = coordinator_data.get("locations") if coordinator_data else None
26+
if locations:
27+
return sorted(set(locations))
28+
derived = {spool["location"] for spool in spools if spool.get("location")}
29+
return sorted(derived)
30+
31+
1932
async def async_setup_entry(
2033
hass: HomeAssistant,
2134
config_entry: ConfigEntry,
@@ -44,26 +57,48 @@ async def async_setup_entry(
4457
spool_data = coordinator.data.get("spools", [])
4558
_LOGGER.info("Found %d spools to create select entities for", len(spool_data))
4659

47-
# Get unique locations from all spools
48-
locations = set()
49-
for spool in spool_data:
50-
location = spool.get("location")
51-
if location:
52-
locations.add(location)
53-
60+
locations = _resolve_locations(coordinator.data, spool_data)
5461
_LOGGER.info("Found locations: %s", locations)
5562

63+
existing_spool_ids: set = set()
64+
5665
# Create a select entity for each spool
5766
for spool in spool_data:
5867
select_entity = SpoolLocationSelect(
59-
hass, coordinator, spool, list(locations), config_entry
68+
hass, coordinator, spool, locations, config_entry
6069
)
6170
all_entities.append(select_entity)
71+
existing_spool_ids.add(spool["id"])
6272
_LOGGER.debug("Created select entity for spool %s", spool['id'])
6373

6474
_LOGGER.info("Adding %d select entities", len(all_entities))
6575
async_add_entities(all_entities)
6676

77+
@callback
78+
def add_dynamic_selects():
79+
"""Add a location select for spools that appeared after setup (#327)."""
80+
if not coordinator.data:
81+
return
82+
new_entities = []
83+
current_locations = _resolve_locations(
84+
coordinator.data, coordinator.data.get("spools", [])
85+
)
86+
for spool in coordinator.data.get("spools", []):
87+
spool_id = spool.get("id")
88+
if spool_id in existing_spool_ids:
89+
continue
90+
new_entities.append(
91+
SpoolLocationSelect(
92+
hass, coordinator, spool, current_locations, config_entry
93+
)
94+
)
95+
existing_spool_ids.add(spool_id)
96+
_LOGGER.info("Dynamically adding location select for spool %s", spool_id)
97+
if new_entities:
98+
async_add_entities(new_entities)
99+
100+
coordinator.async_add_listener(add_dynamic_selects)
101+
67102

68103
class SpoolLocationSelect(CoordinatorEntity, SelectEntity):
69104
"""Representation of a Spoolman Spool Location Select."""
@@ -163,13 +198,12 @@ def _handle_coordinator_update(self) -> None:
163198
self._attr_available = True
164199
self._spool = spool_data
165200

166-
# Update available locations from all spools
167-
locations = set()
168-
for spool in self.coordinator.data.get("spools", []):
169-
location = spool.get("location")
170-
if location:
171-
locations.add(location)
172-
173-
self._locations = sorted(locations) if locations else ["Unknown"]
201+
# Refresh available locations from coordinator (Spoolman /location
202+
# feed) with fallback to spool-derived set.
203+
locations = _resolve_locations(
204+
self.coordinator.data,
205+
self.coordinator.data.get("spools", []),
206+
)
207+
self._locations = locations if locations else ["Unknown"]
174208

175209
self.async_write_ha_state()

custom_components/spoolman/sensor.py

Lines changed: 117 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,42 +57,143 @@ async def async_setup_entry( # noqa: C901
5757
# Use the coordinator from hass.data that was created in __init__.py
5858
coordinator = hass.data[DOMAIN]["coordinator"]
5959

60-
# Track existing extra field entities to detect new ones
61-
existing_extra_fields = {} # key: (spool_id, field_key), value: entity
60+
# Track which spools / extra fields we've already materialised so we can
61+
# add brand-new spools and extras when the coordinator notices them
62+
# (#327: previously only extra fields were added dynamically; new spools
63+
# required a full integration reload).
64+
existing_spool_ids: set = set()
65+
existing_extra_fields: dict = {} # key: (spool_id, field_key), value: entity
66+
67+
image_dir = hass.config.path(PUBLIC_IMAGE_PATH)
68+
69+
async def _build_entities_for_spool(spool, idx):
70+
"""Build the full sensor stack for a single spool."""
71+
entities: list = []
72+
image_url = await hass.async_add_executor_job(
73+
_generate_entity_picture, spool, image_dir
74+
)
75+
entities.append(Spool(hass, coordinator, spool, idx, config_entry, image_url))
76+
entities.append(SpoolFlowRate(hass, coordinator, spool, config_entry))
77+
entities.append(SpoolEstimatedRunOut(hass, coordinator, spool, config_entry))
78+
entities.append(SpoolUsedWeight(hass, coordinator, spool, config_entry))
79+
entities.append(SpoolRemainingLength(hass, coordinator, spool, config_entry))
80+
entities.append(SpoolUsedLength(hass, coordinator, spool, config_entry))
81+
entities.append(SpoolLocation(hass, coordinator, spool, config_entry))
82+
entities.append(SpoolUsedPercentage(hass, coordinator, spool, config_entry))
83+
84+
if spool.get("registered"):
85+
entities.append(SpoolRegistered(hass, coordinator, spool, config_entry))
86+
if spool.get("first_used"):
87+
entities.append(SpoolFirstUsed(hass, coordinator, spool, config_entry))
88+
if spool.get("last_used"):
89+
entities.append(SpoolLastUsed(hass, coordinator, spool, config_entry))
90+
if spool.get("price") is not None:
91+
entities.append(SpoolPrice(hass, coordinator, spool, config_entry))
92+
if spool.get("spool_weight") is not None:
93+
entities.append(SpoolWeight(hass, coordinator, spool, config_entry))
94+
if spool.get("lot_nr"):
95+
entities.append(SpoolLotNumber(hass, coordinator, spool, config_entry))
96+
if spool.get("comment"):
97+
entities.append(SpoolComment(hass, coordinator, spool, config_entry))
98+
99+
filament = spool.get("filament", {})
100+
if filament.get("density") is not None:
101+
entities.append(FilamentDensity(hass, coordinator, spool, config_entry))
102+
if filament.get("diameter") is not None:
103+
entities.append(FilamentDiameter(hass, coordinator, spool, config_entry))
104+
if filament.get("settings_extruder_temp") is not None:
105+
entities.append(FilamentExtruderTemp(hass, coordinator, spool, config_entry))
106+
if filament.get("settings_bed_temp") is not None:
107+
entities.append(FilamentBedTemp(hass, coordinator, spool, config_entry))
108+
if filament.get("article_number"):
109+
entities.append(FilamentArticleNumber(hass, coordinator, spool, config_entry))
110+
111+
entities.append(SpoolId(hass, coordinator, spool, config_entry))
112+
113+
if filament.get("name"):
114+
entities.append(FilamentName(hass, coordinator, spool, config_entry))
115+
if filament.get("material"):
116+
entities.append(FilamentMaterial(hass, coordinator, spool, config_entry))
117+
if filament.get("color_hex"):
118+
filament_image_url = await hass.async_add_executor_job(
119+
_generate_filament_entity_picture, filament, image_dir
120+
)
121+
entities.append(
122+
FilamentColorHex(
123+
hass, coordinator, spool, config_entry, filament_image_url
124+
)
125+
)
126+
if filament.get("vendor", {}).get("name"):
127+
entities.append(VendorName(hass, coordinator, spool, config_entry))
128+
if filament.get("weight") is not None:
129+
entities.append(FilamentWeight(hass, coordinator, spool, config_entry))
130+
131+
for field_key in spool.get("extra", {}):
132+
extra_sensor = SpoolExtraField(
133+
hass, coordinator, spool, config_entry, field_key
134+
)
135+
entities.append(extra_sensor)
136+
existing_extra_fields[(spool["id"], field_key)] = extra_sensor
137+
138+
existing_spool_ids.add(spool["id"])
139+
return entities
140+
141+
async def _async_add_new_spools(new_spools):
142+
"""Build & register sensors for spools the coordinator just discovered."""
143+
new_entities: list = []
144+
# Use a stable index continuation for the picture filename.
145+
base_idx = len(existing_spool_ids)
146+
for offset, spool in enumerate(new_spools):
147+
_LOGGER.info(
148+
"Dynamically adding sensors for new spool %s", spool.get("id")
149+
)
150+
new_entities.extend(
151+
await _build_entities_for_spool(spool, base_idx + offset)
152+
)
153+
if new_entities:
154+
async_add_entities(new_entities)
62155

63156
@callback
64-
def add_extra_field_entities():
65-
"""Add new extra field entities when they appear in coordinator data."""
157+
def add_dynamic_entities():
158+
"""Add new spools and new extra-field sensors as they appear."""
66159
if not coordinator.data:
67160
return
68161

69-
new_entities = []
70162
spools = coordinator.data.get("spools", [])
71163

164+
# New spools (#327): full sensor stack, async because of image gen.
165+
new_spools = [
166+
s for s in spools if s.get("id") not in existing_spool_ids
167+
]
168+
if new_spools:
169+
hass.async_create_task(_async_add_new_spools(new_spools))
170+
171+
# New extra fields on already-known spools.
172+
new_entities = []
72173
for spool in spools:
73174
spool_id = spool.get("id")
74-
extra_data = spool.get("extra", {})
75-
76-
for field_key in extra_data:
175+
if spool_id not in existing_spool_ids:
176+
# Will be handled by _async_add_new_spools above.
177+
continue
178+
for field_key in spool.get("extra", {}):
77179
entity_key = (spool_id, field_key)
78-
79-
# Only create if it doesn't exist yet
80180
if entity_key not in existing_extra_fields:
81181
extra_field_sensor = SpoolExtraField(
82182
hass, coordinator, spool, config_entry, field_key
83183
)
84184
new_entities.append(extra_field_sensor)
85185
existing_extra_fields[entity_key] = extra_field_sensor
86186
_LOGGER.info(
87-
f"Dynamically adding new extra field sensor for spool {spool_id}: {field_key}"
187+
"Dynamically adding new extra field sensor for spool %s: %s",
188+
spool_id,
189+
field_key,
88190
)
89191

90192
if new_entities:
91193
async_add_entities(new_entities)
92194

93195
if coordinator.data:
94196
all_entities = []
95-
image_dir = hass.config.path(PUBLIC_IMAGE_PATH)
96197

97198
# Create spool entities
98199
spool_data = coordinator.data.get("spools", [])
@@ -104,6 +205,7 @@ def add_extra_field_entities():
104205
hass, coordinator, spool, idx, config_entry, image_url
105206
)
106207
all_entities.append(spool_device)
208+
existing_spool_ids.add(spool["id"])
107209

108210
# Create flow rate sensor for this spool
109211
flow_rate_sensor = SpoolFlowRate(
@@ -312,8 +414,9 @@ def add_extra_field_entities():
312414

313415
async_add_entities(all_entities)
314416

315-
# Register listener for coordinator updates to add new extra fields
316-
coordinator.async_add_listener(add_extra_field_entities)
417+
# Register listener for coordinator updates so new spools (#327) and new
418+
# extra fields are added without requiring an integration reload.
419+
coordinator.async_add_listener(add_dynamic_entities)
317420

318421

319422
def _generate_entity_picture(spool_data, image_dir):

0 commit comments

Comments
 (0)