Skip to content

Commit a775502

Browse files
committed
fix: location handling — persist user overrides and enforce location list policy
Locations (and name/type overrides) assigned from the context menu were never persisted: the DiscoveryManager stores were not saved in the UI preferences nor reloaded at startup, so everything was lost on restart. - Persist and restore location_overrides, name_overrides and type_overrides in the UI preferences; wire the manager's location-prefs dirty callback through a thread-safe Qt signal so auto-stored locations are saved too. - Drop the FR/EN equivalence table in normalize_location_options: no translation handling, labels are shown as-is with case-insensitive dedup only (first occurrence wins, user entries are never replaced). - New DiscoveryManager.set_location_policy(list, auto_add): a discovered (mDNS/SSDP) location is applied only when present in the configured list (reusing the list's exact spelling, no case duplicates) or when "Add location" is enabled (then added to the list as-is); otherwise the device stays in "no location". Explicit user overrides always win. - Context menu now always offers the device's current location alongside the configured options (location_options_with_current). - Main window falls back to the locale default location presets when no list is configured; the auto-add flag from the Preferences dialog is now actually propagated. Bump version
1 parent 94cf969 commit a775502

4 files changed

Lines changed: 90 additions & 35 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.0.4
1+
2.0.5

app/discovery/manager.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,10 @@ def __init__(
289289
self._demo_mode = demo_mode
290290
self._location_prefs_need_reapply = False
291291
self._location_prefs_dirty_callback: Callable[[], None] | None = None
292+
# Discovered-location policy: casefolded label -> configured spelling.
293+
# Permissive until the UI pushes the configured list via set_location_policy.
294+
self._allowed_locations: dict[str, str] = {}
295+
self._auto_add_discovered_locations = True
292296

293297
for protocol in self._protocols:
294298
protocol.set_callback(self._on_protocol_event)
@@ -1726,6 +1730,18 @@ def set_location_overrides(self, overrides: dict[str, str]) -> None:
17261730
def get_location_overrides(self) -> dict[str, str]:
17271731
return dict(self._location_overrides)
17281732

1733+
def set_location_policy(self, allowed_locations: list[str], auto_add_discovered: bool) -> None:
1734+
"""Discovered locations are applied only when listed here or when auto-add is on;
1735+
otherwise the device stays without location. User overrides are unaffected."""
1736+
mapping: dict[str, str] = {}
1737+
for raw in allowed_locations or []:
1738+
if isinstance(raw, str) and raw.strip():
1739+
mapping.setdefault(raw.strip().casefold(), raw.strip())
1740+
self._allowed_locations = mapping
1741+
self._auto_add_discovered_locations = bool(auto_add_discovered)
1742+
# Re-evaluate every cached device against the new policy.
1743+
self.set_location_overrides(self.get_location_overrides())
1744+
17291745
def set_field_mapping_rules(self, rules: dict[str, dict[str, list[str]]]) -> None:
17301746
normalized: dict[str, dict[str, list[str]]] = {}
17311747
for key, value in rules.items():
@@ -2642,14 +2658,25 @@ def _apply_location_override(self, device: Device) -> None:
26422658
self._clip_loc_log(chosen),
26432659
)
26442660
elif d_norm and is_plausible_room_location(d_norm):
2645-
self._store_location_preference_for_device(device, d_norm)
2646-
chosen = d_norm
2647-
dev_log.debug(
2648-
"Location: auto from discovery (persisted) ip=%s port=%s value=%r",
2649-
device.ip,
2650-
device.port,
2651-
self._clip_loc_log(chosen),
2652-
)
2661+
canonical = self._allowed_locations.get(d_norm.casefold())
2662+
if canonical is not None:
2663+
d_norm = canonical
2664+
if canonical is not None or self._auto_add_discovered_locations:
2665+
self._store_location_preference_for_device(device, d_norm)
2666+
chosen = d_norm
2667+
dev_log.debug(
2668+
"Location: auto from discovery (persisted) ip=%s port=%s value=%r",
2669+
device.ip,
2670+
device.port,
2671+
self._clip_loc_log(chosen),
2672+
)
2673+
else:
2674+
dev_log.debug(
2675+
"Location: discovered %r ignored (not in configured list, auto-add off) ip=%s port=%s",
2676+
self._clip_loc_log(d_norm),
2677+
device.ip,
2678+
device.port,
2679+
)
26532680
else:
26542681
dev_log.debug(
26552682
"Location: empty ip=%s port=%s (no RoomName/xml_fields or TXT keys matched)",

app/ui/main_window.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
from utils.discovery_cache import purge_device_from_discovery_cache
8282
from utils.discovery_config import normalize_information_precedence_list
8383
from utils.double_click_open import resolve_all_connect_targets, resolve_connect_target
84-
from utils.location_label import normalize_location_options
84+
from utils.location_label import location_options_with_current, normalize_location_options
8585
from utils.icon_view_prefs import (
8686
DEVICE_ICON_REFERENCE_PX,
8787
ICON_SIZE_PRESET_PIXELS,
@@ -296,6 +296,7 @@ class NetNeighborMainWindow(QMainWindow):
296296
"""Table (list) and icon-mode list; view options stored in ``ui_prefs.json``."""
297297

298298
_notification_received = Signal(str, str, str) # datetime_str, device_name, status
299+
_location_prefs_dirty = Signal() # emitted from discovery threads → persist on main thread
299300

300301
_FILTER_ROLE = Qt.ItemDataRole.UserRole
301302
_BUNDLE_KEY_ROLE = Qt.ItemDataRole.UserRole + 1
@@ -375,6 +376,9 @@ def __init__(
375376
self._location_options = normalize_location_options(
376377
[str(v).strip() for v in raw_locs if isinstance(v, str) and str(v).strip()]
377378
)
379+
if not self._location_options:
380+
from ui.preset_editors import _default_location_presets
381+
self._location_options = _default_location_presets()
378382
self._type_options: list[tuple[str, str]] = []
379383
raw_types = prefs.get("type_options")
380384
if isinstance(raw_types, list):
@@ -406,6 +410,19 @@ def __init__(
406410
raw_hidden = prefs.get("hidden_overrides")
407411
if isinstance(raw_hidden, dict):
408412
discovery_manager.set_hidden_overrides(raw_hidden)
413+
raw_type_o = prefs.get("type_overrides")
414+
if isinstance(raw_type_o, dict):
415+
discovery_manager.set_type_overrides(raw_type_o)
416+
raw_name_o = prefs.get("name_overrides")
417+
if isinstance(raw_name_o, dict):
418+
discovery_manager.set_name_overrides(raw_name_o)
419+
raw_loc_o = prefs.get("location_overrides")
420+
if isinstance(raw_loc_o, dict):
421+
discovery_manager.set_location_overrides(raw_loc_o)
422+
discovery_manager.set_location_policy(
423+
self._location_options,
424+
bool(prefs.get("auto_add_discovered_locations", False)),
425+
)
409426

410427
raw_hidden_meta = prefs.get("hidden_device_meta")
411428
self._hidden_device_meta: dict[str, dict[str, object]] = {}
@@ -416,10 +433,14 @@ def __init__(
416433

417434
self._notification_log: list[tuple[str, str, str]] = []
418435
self._notification_received.connect(self._append_notification)
436+
self._location_prefs_dirty.connect(self._schedule_persist_ui_prefs)
419437
if discovery_manager is not None:
420438
discovery_manager.register_presence_transition_hook(
421439
self._on_presence_transition
422440
)
441+
discovery_manager.set_location_prefs_dirty_callback(
442+
self._location_prefs_dirty.emit
443+
)
423444

424445
self._last_devices: list[Device] = []
425446
self._bundles: list[DeviceBundle] = []
@@ -1038,6 +1059,9 @@ def _persist_ui_prefs(self) -> None:
10381059
prefs["field_mapping_rules"] = self._discovery_manager.get_field_mapping_rules()
10391060
prefs["monitored_overrides"] = self._discovery_manager.get_monitored_overrides()
10401061
prefs["hidden_overrides"] = self._discovery_manager.get_hidden_overrides()
1062+
prefs["type_overrides"] = self._discovery_manager.get_type_overrides()
1063+
prefs["name_overrides"] = self._discovery_manager.get_name_overrides()
1064+
prefs["location_overrides"] = self._discovery_manager.get_location_overrides()
10411065
self._prune_hidden_device_meta()
10421066
prefs["hidden_device_meta"] = dict(self._hidden_device_meta)
10431067
save_ui_preferences(prefs)
@@ -1110,8 +1134,10 @@ def _open_preferences(self) -> None:
11101134
)
11111135
dlg.exec()
11121136

1113-
def _apply_location_presets(self, options: list[str], _auto_add: bool) -> None:
1137+
def _apply_location_presets(self, options: list[str], auto_add: bool) -> None:
11141138
self._location_options = options
1139+
if self._discovery_manager is not None:
1140+
self._discovery_manager.set_location_policy(options, auto_add)
11151141

11161142
def _apply_type_presets(self, options: list[tuple[str, str]]) -> None:
11171143
self._type_options = options
@@ -1470,6 +1496,8 @@ def _maybe_auto_add_discovered_locations(self) -> None:
14701496
self._location_options = merged
14711497
prefs["location_options"] = merged
14721498
save_ui_preferences(prefs)
1499+
if self._discovery_manager is not None:
1500+
self._discovery_manager.set_location_policy(merged, True)
14731501

14741502
def _filtered_bundles(self) -> list[DeviceBundle]:
14751503
bundles = apply_bundle_category_filter(
@@ -1646,10 +1674,7 @@ def _show_device_context_menu(self, global_pos: QPoint, bundle: DeviceBundle) ->
16461674
bundle_custom_command(bundle) or (self._custom_command_template or "").strip()
16471675
)
16481676
current_loc = bundle_location_label(bundle, no_location_label="")
1649-
if current_loc and current_loc not in self._location_options:
1650-
loc_opts = normalize_location_options(self._location_options + [current_loc])
1651-
else:
1652-
loc_opts = self._location_options
1677+
loc_opts = location_options_with_current(self._location_options, current_loc)
16531678
show_device_context_menu(
16541679
self,
16551680
global_pos,

app/utils/location_label.py

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,32 +21,35 @@ def is_plausible_room_location(value: str | None) -> bool:
2121
return True
2222

2323

24-
_LOCATION_CANONICAL_EQUIV = {
25-
"office": "bureau",
26-
"room": "chambre",
27-
"living room": "salon",
28-
"kitchen": "cuisine",
29-
"workshop": "atelier",
30-
"bathroom": "salle de bain",
31-
"master bedroom": "chambre principale",
32-
}
33-
34-
3524
def normalize_location_options(options: list[str]) -> list[str]:
36-
"""Drop empty/invalid entries and collapse obvious FR/EN duplicates.
25+
"""Drop empty/invalid entries and case-insensitive duplicates.
3726
38-
Keeps insertion order while preferring the latest value for equivalent labels.
27+
Keeps insertion order while preferring the earliest value, so
28+
user-configured options are never replaced by later (discovered) ones.
29+
Translations are deliberately NOT handled: "office" and "bureau" are two
30+
distinct locations and both stay in the list.
3931
"""
4032
normalized: list[str] = []
41-
by_key: dict[str, int] = {}
33+
seen: set[str] = set()
4234
for raw in options:
4335
value = str(raw).strip() if isinstance(raw, str) else ""
4436
if not value or not is_plausible_room_location(value):
4537
continue
46-
key = _LOCATION_CANONICAL_EQUIV.get(value.casefold(), value.casefold())
47-
if key in by_key:
48-
normalized[by_key[key]] = value
49-
else:
50-
by_key[key] = len(normalized)
51-
normalized.append(value)
38+
key = value.casefold()
39+
if key in seen:
40+
continue
41+
seen.add(key)
42+
normalized.append(value)
5243
return normalized
44+
45+
46+
def location_options_with_current(options: list[str], current: str | None) -> list[str]:
47+
"""Return *options* plus the device's current location label."""
48+
result = list(options)
49+
value = current.strip() if isinstance(current, str) else ""
50+
if not value or not is_plausible_room_location(value):
51+
return result
52+
if any(value.casefold() == opt.casefold() for opt in result):
53+
return result
54+
result.append(value)
55+
return result

0 commit comments

Comments
 (0)