Skip to content

Commit 897b0fe

Browse files
committed
fix(locations): include empty Locations-page entries (#854)
The integration only read locations from GET /api/v1/location, which returns the distinct location values referenced by at least one spool. Locations created on Spoolman's Locations settings page but not yet assigned to a spool (e.g. 'External Spool Holder', 'Ordered') were therefore missing from the Home Assistant location selector. Add SpoolmanAPI.get_setting_locations(), reading the full UI-managed list from GET /api/v1/setting/locations (a Setting whose JSON-encoded 'value' is a string array, decoded here). The coordinator now fetches both endpoints independently and merges them, so empty locations show up while spool-only locations are still covered. Each source degrades gracefully on older Spoolman servers (404), unset/empty value and malformed JSON; when both are unavailable it falls back to deriving locations from spools as before. Tests: API-level parsing/edge cases, coordinator merge + fallback, and the characterization snapshot now lists the empty locations. To the reporter: sorry — I misread the original report and the earlier fix (switching to /api/v1/location in #839) didn't actually cover the empty Locations-page entries you described. This should now be fixed. Thanks for the precise write-up pointing at /api/v1/setting/locations.
1 parent f7c9556 commit 897b0fe

7 files changed

Lines changed: 234 additions & 5 deletions

File tree

custom_components/spoolman/classes/spoolman_api.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,13 @@ async def backup( # pragma: no cover — not called by the integration
8888
return payload
8989

9090
async def get_locations(self) -> list[str]:
91-
"""Return the list of configured spool locations from Spoolman.
91+
"""Return the spool-referenced locations from Spoolman.
9292
93-
Uses ``GET /api/v1/location`` so that locations without any assigned
94-
spool (e.g. empty AMS trays) are still selectable in HA.
93+
Uses ``GET /api/v1/location``, which only lists location values that
94+
are currently assigned to at least one spool. Locations created on
95+
the Spoolman *Locations* settings page but not yet used by any spool
96+
are NOT returned here — see :meth:`get_setting_locations` for the full
97+
UI-managed list (issue #854).
9598
"""
9699
_LOGGER.debug("SpoolmanAPI: get_locations")
97100
url = f"{self.base_url}/location"
@@ -102,6 +105,39 @@ async def get_locations(self) -> list[str]:
102105
_LOGGER.debug("SpoolmanAPI: get_locations response %s", payload)
103106
return [str(loc) for loc in payload if loc]
104107

108+
async def get_setting_locations(self) -> list[str]:
109+
"""Return the full Locations list from Spoolman's settings page.
110+
111+
Uses ``GET /api/v1/setting/locations``. Unlike ``/api/v1/location``
112+
(which only lists locations referenced by a spool), this holds the
113+
complete UI-managed list — including empty locations such as
114+
"External Spool Holder" or "Ordered" (issue #854).
115+
116+
Spoolman returns a ``Setting`` object whose ``value`` is a
117+
JSON-encoded string array (a string containing e.g. ``["Ordered"]``),
118+
which we decode here. Robust against older servers (404), an
119+
unset/empty value and malformed JSON — callers get an empty list in
120+
those cases.
121+
"""
122+
_LOGGER.debug("SpoolmanAPI: get_setting_locations")
123+
url = f"{self.base_url}/setting/locations"
124+
session = await self._get_session()
125+
async with session.get(url) as response:
126+
response.raise_for_status()
127+
payload: dict[str, Any] = await response.json()
128+
_LOGGER.debug("SpoolmanAPI: get_setting_locations response %s", payload)
129+
raw = payload.get("value")
130+
if not raw:
131+
return []
132+
try:
133+
parsed = json.loads(raw) if isinstance(raw, str) else raw
134+
except (ValueError, TypeError):
135+
_LOGGER.debug("SpoolmanAPI: could not parse locations value %r", raw)
136+
return []
137+
if not isinstance(parsed, list):
138+
return []
139+
return [str(loc) for loc in parsed if loc]
140+
105141
async def get_extra_fields(self, entity_type: str) -> dict[str, dict[str, Any]]:
106142
"""Return extra-field metadata for the given entity type (e.g. ``spool``).
107143

custom_components/spoolman/coordinator.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,32 @@ async def _async_update_data(self) -> CoordinatorData:
105105
"Could not fetch spool extra-field metadata: %s", exception
106106
)
107107
spool_extra_fields = {}
108+
# Merge two location sources (issue #854):
109+
# * /setting/locations — the full UI-managed Locations page,
110+
# including empty locations not used by any spool.
111+
# * /location — locations actually referenced by spools, which
112+
# can include values absent from the settings list.
113+
# Either endpoint may be missing on older Spoolman versions, so
114+
# each is fetched independently and failures degrade gracefully.
108115
try:
109-
locations = await self.spoolman_api.get_locations()
116+
setting_locations = await self.spoolman_api.get_setting_locations()
117+
except Exception as exception:
118+
_LOGGER.debug("Could not fetch setting locations: %s", exception)
119+
setting_locations = None
120+
try:
121+
spool_locations = await self.spoolman_api.get_locations()
110122
except Exception as exception:
111123
# /location endpoint isn't on every Spoolman version; fall back
112124
# to deriving from spools at the consumer side.
113125
_LOGGER.debug("Could not fetch locations: %s", exception)
126+
spool_locations = None
127+
if setting_locations is None and spool_locations is None:
114128
locations = None
129+
else:
130+
merged: set[str] = set()
131+
merged.update(setting_locations or [])
132+
merged.update(spool_locations or [])
133+
locations = sorted(merged)
115134
except asyncio.CancelledError: # pragma: no cover — shutdown path
116135
_LOGGER.debug("Data update was cancelled")
117136
raise

tests/conftest.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,16 @@ def locations_data() -> list[str]:
112112
return _load_fixture("locations.json")
113113

114114

115+
@pytest.fixture
116+
def setting_locations_data() -> list[str]:
117+
"""Return the full UI Locations list served by ``GET /setting/locations``.
118+
119+
Includes empty locations ("External Spool Holder", "Ordered") absent from
120+
the spool-referenced ``/location`` feed (issue #854).
121+
"""
122+
return _load_fixture("setting_locations.json")
123+
124+
115125
@pytest.fixture
116126
def extra_fields_data() -> list[dict[str, Any]]:
117127
"""Return extra-field metadata served by ``GET /field/spool``."""
@@ -123,6 +133,7 @@ def mock_spoolman_api(
123133
spools_data: list[dict[str, Any]],
124134
filaments_data: list[dict[str, Any]],
125135
locations_data: list[str],
136+
setting_locations_data: list[str],
126137
extra_fields_data: list[dict[str, Any]],
127138
) -> AsyncGenerator[aioresponses, None]:
128139
"""Mock the Spoolman HTTP API. Default = healthy server, all endpoints respond."""
@@ -149,6 +160,13 @@ def mock_spoolman_api(
149160
mocked.get(
150161
re.compile(rf"{base}/filament.*"), payload=filaments_data, repeat=True
151162
)
163+
# /setting/locations is registered before /location so the more
164+
# specific Setting endpoint is matched first (issue #854).
165+
mocked.get(
166+
re.compile(rf"{base}/setting/locations"),
167+
payload={"value": json.dumps(setting_locations_data), "is_set": True},
168+
repeat=True,
169+
)
152170
mocked.get(re.compile(rf"{base}/location"), payload=locations_data, repeat=True)
153171
mocked.get(
154172
re.compile(rf"{base}/field/spool"), payload=extra_fields_data, repeat=True
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
["Shelf A", "Shelf B", "Drawer 1", "External Spool Holder", "Ordered"]

tests/snapshots/test_characterization.ambr

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
'icon': 'mdi:map-marker',
3939
'options': list([
4040
'Drawer 1',
41+
'External Spool Holder',
42+
'Ordered',
4143
'Shelf A',
4244
'Shelf B',
4345
]),
@@ -55,6 +57,8 @@
5557
'icon': 'mdi:map-marker',
5658
'options': list([
5759
'Drawer 1',
60+
'External Spool Holder',
61+
'Ordered',
5862
'Shelf A',
5963
'Shelf B',
6064
]),

tests/test_api.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,42 @@ async def test_close_only_closes_owned_session() -> None:
8686
await api.close()
8787
assert not session.closed
8888
await session.close()
89+
90+
91+
async def test_get_setting_locations_parses_json_value(hass: HomeAssistant) -> None:
92+
"""#854: the Setting's JSON-encoded ``value`` is decoded to a list of names."""
93+
with aioresponses() as m:
94+
m.get(
95+
re.compile(rf"{re.escape(MOCK_URL)}api/v1/setting/locations"),
96+
payload={"value": '["Ordered", "External Spool Holder"]', "is_set": True},
97+
)
98+
api = SpoolmanAPI(MOCK_URL, session=async_get_clientsession(hass))
99+
assert await api.get_setting_locations() == ["Ordered", "External Spool Holder"]
100+
101+
102+
@pytest.mark.parametrize(
103+
"payload",
104+
[
105+
{"value": "", "is_set": False}, # unset → empty default
106+
{"value": None}, # explicit null
107+
{}, # no value key at all
108+
{"value": "{not json"}, # malformed JSON
109+
{"value": '"a string"'}, # valid JSON but not a list
110+
{"value": '["Ordered", "", null, "Ordered"]'}, # falsy entries dropped
111+
],
112+
)
113+
async def test_get_setting_locations_handles_edge_values(
114+
hass: HomeAssistant, payload: dict[str, object]
115+
) -> None:
116+
"""Unset / malformed / non-list values degrade to a sane list, never raise."""
117+
with aioresponses() as m:
118+
m.get(
119+
re.compile(rf"{re.escape(MOCK_URL)}api/v1/setting/locations"),
120+
payload=payload,
121+
)
122+
api = SpoolmanAPI(MOCK_URL, session=async_get_clientsession(hass))
123+
result = await api.get_setting_locations()
124+
125+
assert isinstance(result, list)
126+
# The only payload yielding entries keeps non-falsy strings, duplicates intact:
127+
assert result in ([], ["Ordered", "Ordered"])

tests/test_coordinator.py

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ async def test_extra_fields_endpoint_404_degrades(
6969
payload=filaments_data,
7070
repeat=True,
7171
)
72+
m.get(
73+
re.compile(rf"{re.escape(MOCK_URL)}api/v1/setting/locations.*"),
74+
status=404,
75+
repeat=True,
76+
)
7277
m.get(
7378
re.compile(rf"{re.escape(MOCK_URL)}api/v1/location.*"),
7479
payload=locations_data,
@@ -92,7 +97,7 @@ async def test_locations_fallback_to_derived(
9297
spools_data: list[dict[str, Any]],
9398
filaments_data: list[dict[str, Any]],
9499
) -> None:
95-
"""No /location endpoint → derive from spool.location values."""
100+
"""Neither /setting/locations nor /location → derive from spool.location."""
96101
config_entry.add_to_hass(hass)
97102
with aioresponses() as m:
98103
m.get(
@@ -105,6 +110,11 @@ async def test_locations_fallback_to_derived(
105110
payload=filaments_data,
106111
repeat=True,
107112
)
113+
m.get(
114+
re.compile(rf"{re.escape(MOCK_URL)}api/v1/setting/locations.*"),
115+
status=404,
116+
repeat=True,
117+
)
108118
m.get(
109119
re.compile(rf"{re.escape(MOCK_URL)}api/v1/location.*"),
110120
status=404,
@@ -123,6 +133,108 @@ async def test_locations_fallback_to_derived(
123133
assert set(coord.data["locations"]) == {"Shelf A", "Shelf B"}
124134

125135

136+
async def test_locations_merge_setting_and_spool(
137+
hass: HomeAssistant,
138+
config_entry: MockConfigEntry,
139+
mock_spoolman_api: aioresponses,
140+
) -> None:
141+
"""#854: empty Locations-page entries are merged with spool-referenced ones."""
142+
config_entry.add_to_hass(hass)
143+
coord = SpoolManCoordinator(hass, config_entry)
144+
await coord.async_refresh()
145+
146+
assert coord.last_update_success
147+
locations = set(coord.data["locations"])
148+
# Empty locations only present in /setting/locations (the bug in #854):
149+
assert {"External Spool Holder", "Ordered"} <= locations
150+
# ...merged with the /location feed ("Drawer 1") and spool-derived ones:
151+
assert {"Shelf A", "Shelf B", "Drawer 1"} <= locations
152+
153+
154+
async def test_setting_locations_404_falls_back_to_location(
155+
hass: HomeAssistant,
156+
config_entry: MockConfigEntry,
157+
spools_data: list[dict[str, Any]],
158+
filaments_data: list[dict[str, Any]],
159+
locations_data: list[str],
160+
) -> None:
161+
"""Older Spoolman without /setting/locations still uses the /location feed."""
162+
config_entry.add_to_hass(hass)
163+
with aioresponses() as m:
164+
m.get(
165+
re.compile(rf"{re.escape(MOCK_URL)}api/v1/spool.*"),
166+
payload=[s for s in spools_data if not s.get("archived")],
167+
repeat=True,
168+
)
169+
m.get(
170+
re.compile(rf"{re.escape(MOCK_URL)}api/v1/filament.*"),
171+
payload=filaments_data,
172+
repeat=True,
173+
)
174+
m.get(
175+
re.compile(rf"{re.escape(MOCK_URL)}api/v1/setting/locations.*"),
176+
status=404,
177+
repeat=True,
178+
)
179+
m.get(
180+
re.compile(rf"{re.escape(MOCK_URL)}api/v1/location.*"),
181+
payload=locations_data,
182+
repeat=True,
183+
)
184+
m.get(
185+
re.compile(rf"{re.escape(MOCK_URL)}api/v1/field/spool.*"),
186+
payload=[],
187+
repeat=True,
188+
)
189+
coord = SpoolManCoordinator(hass, config_entry)
190+
await coord.async_refresh()
191+
192+
assert coord.last_update_success
193+
assert set(coord.data["locations"]) == {"Shelf A", "Shelf B", "Drawer 1"}
194+
195+
196+
async def test_setting_locations_malformed_value_degrades(
197+
hass: HomeAssistant,
198+
config_entry: MockConfigEntry,
199+
spools_data: list[dict[str, Any]],
200+
filaments_data: list[dict[str, Any]],
201+
locations_data: list[str],
202+
) -> None:
203+
"""A malformed Setting value doesn't crash; /location still contributes."""
204+
config_entry.add_to_hass(hass)
205+
with aioresponses() as m:
206+
m.get(
207+
re.compile(rf"{re.escape(MOCK_URL)}api/v1/spool.*"),
208+
payload=[s for s in spools_data if not s.get("archived")],
209+
repeat=True,
210+
)
211+
m.get(
212+
re.compile(rf"{re.escape(MOCK_URL)}api/v1/filament.*"),
213+
payload=filaments_data,
214+
repeat=True,
215+
)
216+
m.get(
217+
re.compile(rf"{re.escape(MOCK_URL)}api/v1/setting/locations.*"),
218+
payload={"value": "{not valid json", "is_set": True},
219+
repeat=True,
220+
)
221+
m.get(
222+
re.compile(rf"{re.escape(MOCK_URL)}api/v1/location.*"),
223+
payload=locations_data,
224+
repeat=True,
225+
)
226+
m.get(
227+
re.compile(rf"{re.escape(MOCK_URL)}api/v1/field/spool.*"),
228+
payload=[],
229+
repeat=True,
230+
)
231+
coord = SpoolManCoordinator(hass, config_entry)
232+
await coord.async_refresh()
233+
234+
assert coord.last_update_success
235+
assert set(coord.data["locations"]) == {"Shelf A", "Shelf B", "Drawer 1"}
236+
237+
126238
async def test_total_remaining_weight_calculated(
127239
hass: HomeAssistant,
128240
config_entry: MockConfigEntry,

0 commit comments

Comments
 (0)