diff --git a/Switch2Bridge.py b/Switch2Bridge.py index fcd5f90..a8797b6 100644 --- a/Switch2Bridge.py +++ b/Switch2Bridge.py @@ -66,7 +66,7 @@ # ============================================================ APP_NAME = "Switch2 Bridge" -APP_VERSION = "1.2.2" # single source of truth — read by setup_app.py & build_dmg.sh +APP_VERSION = "1.2.3" # single source of truth — read by setup_app.py & build_dmg.sh INPUT_CHAR_UUID = "7492866c-ec3e-4619-8258-32755ffcc0f9" # Nintendo company identifiers seen in BLE advertisements: @@ -273,8 +273,10 @@ def set_dsu_enabled(self, enabled): with open(MAPPINGS_FILE) as f: cfg = json.load(f) except Exception: - log.exception("could not read mappings.json to persist DSU toggle") - cfg = json.loads(json.dumps(self.DEFAULT)) + # Never clobber a corrupt (but user-authored) file with defaults — + # the toggle just won't persist until the file is fixed. + log.exception("mappings.json unreadable, DSU toggle not persisted") + return dsu = cfg.get("dsu") if not isinstance(dsu, dict): dsu = cfg["dsu"] = {} @@ -596,6 +598,10 @@ async def _connect_async(self): streamed = await self._session( address, name, reconnected=was_connected ) + if not streamed and was_connected: + # Still auto-retrying — keep the failure in the logs + # only; a notification per attempt would spam the user. + self.last_error = None if self._stop_event.is_set(): return @@ -791,7 +797,7 @@ def _apply_state(self, state): elif state == 'connected': self.title = "🟢" self._status_item.title = f"✓ {self.bridge.controller_name or 'Controller'}" - self._detail_item.title = f" {self.bridge.packet_count} packets" + self._detail_item.title = f" {self.bridge.packet_count} pkts" self._action_item.title = "Disconnect" self._action_item.set_callback(self._on_action) elif state == 'stopping': diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 0a9d955..2e5e76a 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -308,6 +308,40 @@ class Named: a, n = asyncio.run(br7._find_controller()) check("unrelated device ignored", a is None) +# failing reconnect attempts must not spam last_error; only the final +# give-up message surfaces +print("== reconnect error spam ==") +S2B.INITIAL_SCAN_WINDOW = 10.0 +S2B.RECONNECT_WINDOW = 2.0 +MockScanner.delay = 0.05 +MockScanner.queue = [] +MockScanner.result = {"AA:BB": (Dev(), Adv())} +br8 = S2B.ControllerBridge(mm) +br8.connect() +time.sleep(0.6) +check("spam test: connected", br8.is_connected) + +orig_connect = MockClient.connect +async def _failing_connect(self): + raise RuntimeError("nope") +MockClient.connect = _failing_connect +MockClient.instances[-1]._connected = False # unexpected drop +time.sleep(1.2) # several failing reconnect attempts happen here +check("no error surfaced while retrying", br8.last_error is None, br8.last_error) +time.sleep(2.5) # past the (shrunk) reconnect window +check("final give-up error surfaced", + br8.last_error and "reconnect" in br8.last_error.lower(), br8.last_error) +check("spam test: thread ended", not br8._thread.is_alive()) +MockClient.connect = orig_connect + +# a corrupt mappings.json must never be clobbered by the DSU toggle +print("== dsu toggle vs corrupt file ==") +S2B.MAPPINGS_FILE.write_text("{broken json") +m4 = S2B.Mappings() +m4.set_dsu_enabled(False) +check("corrupt file untouched", S2B.MAPPINGS_FILE.read_text() == "{broken json") +check("in-memory toggle still applied", m4.dsu_enabled is False) + print() if FAILURES: print(f"FAILED: {len(FAILURES)} -> {FAILURES}")