something-x-dev 1.8.2.dev26__tar.gz → 1.9.0.dev28__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/PKG-INFO +1 -1
  2. something_x_dev-1.9.0.dev28/ROADMAP.md +107 -0
  3. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/nothing_app/_version.py +2 -2
  4. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/nothing_app/application.py +49 -0
  5. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/nothing_app/pages/device.py +155 -1
  6. something_x_dev-1.9.0.dev28/nothing_app/pages/theme.py +484 -0
  7. something_x_dev-1.9.0.dev28/nothing_app/profiles.py +129 -0
  8. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/nothing_app/protocol.py +19 -16
  9. something_x_dev-1.9.0.dev28/nothing_app/theme.py +312 -0
  10. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/nothing_app/window.py +74 -2
  11. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/something_x_dev.egg-info/PKG-INFO +1 -1
  12. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/something_x_dev.egg-info/SOURCES.txt +5 -1
  13. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/tests/conftest.py +2 -0
  14. something_x_dev-1.9.0.dev28/tests/test_notifications.py +133 -0
  15. something_x_dev-1.9.0.dev28/tests/test_profiles.py +267 -0
  16. something_x_dev-1.9.0.dev28/tests/test_theme.py +454 -0
  17. something_x_dev-1.8.2.dev26/ROADMAP.md +0 -69
  18. something_x_dev-1.8.2.dev26/nothing_app/profiles.py +0 -41
  19. something_x_dev-1.8.2.dev26/tests/test_profiles.py +0 -90
  20. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/.github/CODEOWNERS +0 -0
  21. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/.github/workflows/ci.yml +0 -0
  22. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/.github/workflows/release-dev.yml +0 -0
  23. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/.github/workflows/release.yml +0 -0
  24. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/.gitignore +0 -0
  25. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/DEVICES.md +0 -0
  26. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/LICENSE +0 -0
  27. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/PKGBUILD +0 -0
  28. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/README.md +0 -0
  29. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/cliff.toml +0 -0
  30. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/docs/RELEASING.md +0 -0
  31. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/docs/docs.html +0 -0
  32. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/docs/index.html +0 -0
  33. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/flake.nix +0 -0
  34. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/nothing_app/__init__.py +0 -0
  35. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/nothing_app/bluetooth.py +0 -0
  36. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/nothing_app/data/__init__.py +0 -0
  37. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/nothing_app/data/com.something.x.omarchy.desktop +0 -0
  38. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/nothing_app/data/style.css +0 -0
  39. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/nothing_app/pages/__init__.py +0 -0
  40. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/nothing_app/pages/home.py +0 -0
  41. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/nothing_app/splash.py +0 -0
  42. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/nothing_app/tray.py +0 -0
  43. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/pyproject.toml +0 -0
  44. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/setup.cfg +0 -0
  45. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/something_x_dev.egg-info/dependency_links.txt +0 -0
  46. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/something_x_dev.egg-info/entry_points.txt +0 -0
  47. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/something_x_dev.egg-info/requires.txt +0 -0
  48. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/something_x_dev.egg-info/top_level.txt +0 -0
  49. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/somethingx +0 -0
  50. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/tests/__init__.py +0 -0
  51. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/tests/test_bluetooth.py +0 -0
  52. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/tests/test_crc.py +0 -0
  53. {something_x_dev-1.8.2.dev26 → something_x_dev-1.9.0.dev28}/tests/test_protocol.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: something-x-dev
3
- Version: 1.8.2.dev26
3
+ Version: 1.9.0.dev28
4
4
  Summary: Something X device manager for Omarchy / Linux
5
5
  Author: Raphael
6
6
  License: MIT
@@ -0,0 +1,107 @@
1
+ # Something X — Roadmap
2
+
3
+ ---
4
+
5
+ ## Current release — v1.8
6
+
7
+ ### Shipped
8
+ - [x] **GitHub Pages** — project landing page + full documentation site (`docs/`)
9
+ - [x] **AUR package** — Arch users can install without pip
10
+ - [x] **NixOS flake** — `flake.nix` for Nix users
11
+ - [x] **`.desktop` file** — ships and auto-installs so the app appears in Walker/Rofi/GNOME launcher
12
+ - [x] **Low battery notification** — `notify-send` when any bud drops below 20 %
13
+ - [x] **Background mode** — closing the window hides it; relaunch shows it again (Gio single-instance)
14
+ - [x] **Per-device profiles** — ANC + EQ saved per device address to `~/.config/something-x/profiles.json`, restored on reconnect
15
+ - [x] **CLI quick-toggle** — `something-x --anc off|on|transparency`, `something-x --eq bass`, `something-x --battery`
16
+ - [x] **System tray icon** — battery on hover via StatusNotifierItem (Waybar/SNI)
17
+ - [x] **Wearing detection display** — L/R in-ear status shown on the earbud visual
18
+ - [x] **Auto-connect RFCOMM** — connects as soon as BlueZ reports the device connected
19
+ - [x] **Async BlueZ** — native `Gio` async D-Bus, no `dbus-python` dependency
20
+ - [x] **Unit tests** — 65 tests covering CRC, protocol parsing, battery dedup, profiles, BluetoothManager
21
+ - [x] **Debug mode** — `SOMETHING_X_DEBUG=1` dumps raw RFCOMM frames for contributors
22
+ - [x] **Graceful ANC mode detection** — infer supported modes from RFCOMM response; hide unsupported buttons
23
+ - [x] **Dynamic versioning** — version sourced from git tags at build time
24
+ - [x] **Liquid glass dark UI** — overhauled aesthetic for the app window
25
+
26
+ ---
27
+
28
+ ## 1.9 — Quality of life
29
+
30
+ ### Features
31
+ - [x] **Device nickname** — rename a paired device in the UI; stored in profiles
32
+ - [x] **Profile import / export** — share `.json` profile files between machines or with other users
33
+ - [ ] **Wear-detect MPRIS actions** — pause media when both buds are removed; resume when reinserted (opt-in)
34
+ - [x] **Notification preferences** — per-event toggles (battery low, connect, disconnect) in settings
35
+ - [x] **Theming** — user-selectable accent color and light/dark mode toggle; theme stored in config
36
+
37
+ ### Tech
38
+ - [x] **Improved test coverage** — profiles round-trip, nickname, notify prefs, notification gating (91 tests total)
39
+
40
+ ---
41
+
42
+ ## 1.10 — Desktop integration
43
+
44
+ ### Features
45
+ - [ ] **D-Bus API** — expose ANC, EQ, battery, and connect/disconnect over a session-bus interface so scripts and status bars can consume it without spawning a CLI process
46
+ - [ ] **Global keybind support** — register a configurable shortcut (e.g. `Super+F1`) to cycle ANC modes without opening the window
47
+ - [ ] **Quick-settings overlay** — small floating panel triggered by keybind or tray click; ANC toggle + battery at a glance
48
+ - [ ] **Waybar module documentation** — sample `custom/somethingx` block wired to the D-Bus API
49
+
50
+ ---
51
+
52
+ ## 1.11 — Multi-device
53
+
54
+ ### Features
55
+ - [ ] **Multiple devices simultaneously** — open a page per device, manage both at once without switching
56
+ - [ ] **Battery overview** — compact summary card on the home page showing all connected devices and their battery levels
57
+
58
+ ---
59
+
60
+ ## 1.12 — EQ overhaul
61
+
62
+ ### Features
63
+ - [ ] **Visual EQ graph** — interactive SVG/Cairo curve instead of preset pills
64
+ - [ ] **Custom EQ presets** — per-band sliders, save with a user-defined name, stored in profiles
65
+ - [ ] **Preset sharing** — export / import individual EQ presets as `.json`
66
+
67
+ ---
68
+
69
+ ## 1.13 — Protocol depth
70
+
71
+ ### Features
72
+ - [ ] **Touch controls remapping** — reassign tap/hold actions if the protocol exposes it (command IDs found in APK, not yet wired up)
73
+ - [ ] **Firmware version display** — read firmware string from RFCOMM and show it in the device page
74
+ - [ ] **Firmware update check** — compare device firmware against latest known version, show a badge when outdated
75
+
76
+ ### Protocol / devices
77
+ - [ ] **CMF Buds / Buds Pro validation** — confirm command IDs and ship explicit support
78
+ - [ ] **Nothing Ear (1) / (a) / (stick) community testing** — collect field reports, fix edge cases
79
+ - [ ] **Structured debug logger** — `SOMETHING_X_DEBUG=1` writes a structured `.jsonl` capture file for easier issue reproduction
80
+
81
+ ---
82
+
83
+ ## 1.14 — Additional devices
84
+
85
+ ### Features
86
+ - [ ] **Nothing Ear (open) support** — ANC not applicable; validate battery and EQ commands
87
+ - [ ] **CMF Watch support** — separate protocol; requires fresh APK reverse-engineering
88
+ - [ ] **Nothing Phone glyph integration** — notification mirroring and glyph lighting (depends on BlueZ + glyph API availability)
89
+
90
+ ---
91
+
92
+ ## Long term / ideas
93
+
94
+ These have no version target yet — they need either upstream work, a contributor, or significant scoping.
95
+
96
+ - [ ] **GNOME Shell extension** — battery indicator in the GNOME top bar
97
+ - [ ] **KDE Plasma widget** — same idea for KDE users
98
+ - [ ] **macOS port** — replace BlueZ D-Bus with CoreBluetooth via `pyobjc` (long shot)
99
+ - [ ] **Battery history graph** — plot battery level over time, persisted across sessions
100
+
101
+ ---
102
+
103
+ ## Won't do
104
+
105
+ - **Windows** — no BlueZ; RFCOMM access requires a completely different stack
106
+ - **Wayland screen capture control** — out of scope
107
+ - **Streaming / media playback control** — MPRIS already handles this system-wide
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.8.2.dev26'
22
- __version_tuple__ = version_tuple = (1, 8, 2, 'dev26')
21
+ __version__ = version = '1.9.0.dev28'
22
+ __version_tuple__ = version_tuple = (1, 9, 0, 'dev28')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -50,6 +50,9 @@ class SomethingXApplication(Adw.Application):
50
50
  self._splash: SplashScreen | None = None
51
51
  self._window: SomethingXWindow | None = None
52
52
  self._tray: SomethingXTray | None = None
53
+ self._theme_provider: Gtk.CssProvider | None = None
54
+ self._current_theme = None
55
+ self._theme_save_id: int | None = None
53
56
  self.connect("activate", self._on_activate)
54
57
 
55
58
  def _on_activate(self, _app):
@@ -61,6 +64,7 @@ class SomethingXApplication(Adw.Application):
61
64
  _install_desktop_file()
62
65
  Adw.StyleManager.get_default().set_color_scheme(Adw.ColorScheme.FORCE_DARK)
63
66
  self._load_css()
67
+ self._load_theme_css()
64
68
  self._bt = BluetoothManager()
65
69
  splash = SplashScreen(on_done=self._on_splash_done)
66
70
  splash.set_application(self)
@@ -72,6 +76,8 @@ class SomethingXApplication(Adw.Application):
72
76
  win = SomethingXWindow(bt_manager=self._bt, application=self)
73
77
  win.connect("close-request", self._on_window_close)
74
78
  self._window = win
79
+ if self._current_theme is not None:
80
+ win.set_opacity(max(0.05, min(1.0, self._current_theme.window_opacity)))
75
81
  self._tray = SomethingXTray(self._bt, on_show_window=win.present)
76
82
  win.present()
77
83
  if self._splash:
@@ -93,6 +99,49 @@ class SomethingXApplication(Adw.Application):
93
99
  )
94
100
  return True # prevent default close/destroy
95
101
 
102
+ def _load_theme_css(self):
103
+ from . import theme as _theme_mod
104
+
105
+ self._current_theme = _theme_mod.load()
106
+ self._theme_provider = Gtk.CssProvider()
107
+ css = _theme_mod.generate_css(self._current_theme)
108
+ try:
109
+ self._theme_provider.load_from_string(css)
110
+ except AttributeError:
111
+ self._theme_provider.load_from_data(css.encode())
112
+
113
+ display = Gdk.Display.get_default()
114
+ if display:
115
+ Gtk.StyleContext.add_provider_for_display(
116
+ display,
117
+ self._theme_provider,
118
+ Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
119
+ )
120
+
121
+ def apply_theme(self, t):
122
+ from . import theme as _theme_mod
123
+
124
+ self._current_theme = t
125
+ css = _theme_mod.generate_css(t)
126
+ try:
127
+ self._theme_provider.load_from_string(css)
128
+ except AttributeError:
129
+ self._theme_provider.load_from_data(css.encode())
130
+
131
+ if self._window is not None:
132
+ self._window.set_opacity(max(0.05, min(1.0, t.window_opacity)))
133
+
134
+ if self._theme_save_id is not None:
135
+ GLib.source_remove(self._theme_save_id)
136
+ self._theme_save_id = GLib.timeout_add(500, self._flush_theme_save)
137
+
138
+ def _flush_theme_save(self):
139
+ from . import theme as _theme_mod
140
+
141
+ _theme_mod.save(self._current_theme)
142
+ self._theme_save_id = None
143
+ return False
144
+
96
145
  def _load_css(self):
97
146
  provider = Gtk.CssProvider()
98
147
  css = _css_path()
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import math
2
3
  import re
3
4
  import subprocess
@@ -8,10 +9,11 @@ import gi
8
9
  gi.require_version("Gtk", "4.0")
9
10
  gi.require_version("Pango", "1.0")
10
11
  gi.require_version("PangoCairo", "1.0")
11
- from gi.repository import Gtk, GLib, PangoCairo
12
+ from gi.repository import Gtk, GLib, PangoCairo, Gio
12
13
 
13
14
  from ..bluetooth import BluetoothDevice, BluetoothManager
14
15
  from ..protocol import NothingDevice, ANCMode, EQ_PRESETS
16
+ from .. import profiles
15
17
 
16
18
 
17
19
  def _mono_font() -> str:
@@ -487,12 +489,58 @@ class DevicePage(Gtk.Box):
487
489
 
488
490
  page.append(settings_group)
489
491
 
492
+ page.append(_section("NOTIFICATIONS"))
493
+
494
+ notif_group = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
495
+ notif_group.add_css_class("settings-group")
496
+ notif_group.set_margin_bottom(4)
497
+
498
+ notify_prefs = profiles.get_notify_prefs(self._bt_device.address)
499
+
500
+ self._notif_battery_switch = Gtk.Switch()
501
+ self._notif_battery_switch.set_active(notify_prefs.get("battery_low", True))
502
+ self._notif_battery_switch.set_valign(Gtk.Align.CENTER)
503
+ self._notif_battery_switch.connect("state-set", self._on_notif_battery_toggled)
504
+ notif_group.append(
505
+ _settings_row("Battery Low", "Alert when battery drops below 20%", self._notif_battery_switch)
506
+ )
507
+
508
+ self._notif_connect_switch = Gtk.Switch()
509
+ self._notif_connect_switch.set_active(notify_prefs.get("connect", True))
510
+ self._notif_connect_switch.set_valign(Gtk.Align.CENTER)
511
+ self._notif_connect_switch.connect("state-set", self._on_notif_connect_toggled)
512
+ notif_group.append(
513
+ _settings_row("Connected", "Alert when device connects", self._notif_connect_switch)
514
+ )
515
+
516
+ self._notif_disconnect_switch = Gtk.Switch()
517
+ self._notif_disconnect_switch.set_active(notify_prefs.get("disconnect", True))
518
+ self._notif_disconnect_switch.set_valign(Gtk.Align.CENTER)
519
+ self._notif_disconnect_switch.connect("state-set", self._on_notif_disconnect_toggled)
520
+ notif_group.append(
521
+ _settings_row("Disconnected", "Alert when device disconnects", self._notif_disconnect_switch)
522
+ )
523
+
524
+ page.append(notif_group)
525
+
490
526
  page.append(_section("DEVICE INFO"))
491
527
 
492
528
  info_group = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
493
529
  info_group.add_css_class("settings-group")
494
530
  info_group.set_margin_bottom(4)
495
531
 
532
+ nick_entry = Gtk.Entry()
533
+ nick_entry.set_hexpand(True)
534
+ nick_entry.set_valign(Gtk.Align.CENTER)
535
+ saved_nick = profiles.get_nickname(self._bt_device.address)
536
+ if saved_nick:
537
+ nick_entry.set_text(saved_nick)
538
+ nick_entry.set_placeholder_text(self._bt_device.name)
539
+ nick_entry.connect("activate", self._on_nickname_activate)
540
+ nick_entry.connect("notify::has-focus", self._on_nickname_focus_lost)
541
+ self._nick_entry = nick_entry
542
+ info_group.append(_settings_row("Nickname", right_widget=nick_entry))
543
+
496
544
  self._fw_label = Gtk.Label(label="—")
497
545
  self._fw_label.add_css_class("info-value")
498
546
  self._fw_label.set_xalign(1)
@@ -508,6 +556,23 @@ class DevicePage(Gtk.Box):
508
556
  addr_val.set_xalign(1)
509
557
  info_group.append(_settings_row("Address", right_widget=addr_val))
510
558
 
559
+ io_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
560
+ io_box.set_margin_top(4)
561
+
562
+ export_btn = Gtk.Button(label="Export Profile")
563
+ export_btn.add_css_class("settings-row-action")
564
+ export_btn.set_hexpand(True)
565
+ export_btn.connect("clicked", self._on_export_clicked)
566
+
567
+ import_btn = Gtk.Button(label="Import Profile")
568
+ import_btn.add_css_class("settings-row-action")
569
+ import_btn.set_hexpand(True)
570
+ import_btn.connect("clicked", self._on_import_clicked)
571
+
572
+ io_box.append(export_btn)
573
+ io_box.append(import_btn)
574
+ info_group.append(io_box)
575
+
511
576
  page.append(info_group)
512
577
 
513
578
  self._sync_anc_ui(ANCMode.OFF)
@@ -634,6 +699,95 @@ class DevicePage(Gtk.Box):
634
699
  else:
635
700
  btn.remove_css_class("active")
636
701
 
702
+ def _save_nickname(self):
703
+ if not hasattr(self, "_nick_entry"):
704
+ return
705
+ text = self._nick_entry.get_text().strip()
706
+ profiles.set_nickname(self._bt_device.address, text or None)
707
+
708
+ def _on_nickname_activate(self, _entry):
709
+ self._save_nickname()
710
+
711
+ def _on_nickname_focus_lost(self, entry, _param):
712
+ if not entry.has_focus():
713
+ self._save_nickname()
714
+
715
+ def _on_notif_battery_toggled(self, _switch, state: bool):
716
+ profiles.set_notify_prefs(self._bt_device.address, {"battery_low": state})
717
+ return False
718
+
719
+ def _on_notif_connect_toggled(self, _switch, state: bool):
720
+ profiles.set_notify_prefs(self._bt_device.address, {"connect": state})
721
+ return False
722
+
723
+ def _on_notif_disconnect_toggled(self, _switch, state: bool):
724
+ profiles.set_notify_prefs(self._bt_device.address, {"disconnect": state})
725
+ return False
726
+
727
+ def _on_export_clicked(self, _btn):
728
+ dialog = Gtk.FileChooserNative(
729
+ title="Export Profile",
730
+ transient_for=self.get_root(),
731
+ action=Gtk.FileChooserAction.SAVE,
732
+ )
733
+ dialog.set_current_name(f"something-x-{self._bt_device.address.replace(':', '-')}.json")
734
+ dialog.connect("response", self._on_export_response, dialog)
735
+ dialog.show()
736
+
737
+ def _on_export_response(self, _dialog, response: int, dialog: Gtk.FileChooserNative):
738
+ if response != Gtk.ResponseType.ACCEPT:
739
+ return
740
+ gfile = dialog.get_file()
741
+ if not gfile:
742
+ return
743
+ path = gfile.get_path()
744
+ data = profiles.export_profile(self._bt_device.address)
745
+ try:
746
+ with open(path, "w") as f:
747
+ json.dump(data, f, indent=2)
748
+ except Exception as exc:
749
+ print(f"[device] export failed: {exc}")
750
+
751
+ def _on_import_clicked(self, _btn):
752
+ dialog = Gtk.FileChooserNative(
753
+ title="Import Profile",
754
+ transient_for=self.get_root(),
755
+ action=Gtk.FileChooserAction.OPEN,
756
+ )
757
+ filt = Gtk.FileFilter()
758
+ filt.set_name("JSON files")
759
+ filt.add_mime_type("application/json")
760
+ filt.add_pattern("*.json")
761
+ dialog.add_filter(filt)
762
+ dialog.connect("response", self._on_import_response, dialog)
763
+ dialog.show()
764
+
765
+ def _on_import_response(self, _dialog, response: int, dialog: Gtk.FileChooserNative):
766
+ if response != Gtk.ResponseType.ACCEPT:
767
+ return
768
+ gfile = dialog.get_file()
769
+ if not gfile:
770
+ return
771
+ path = gfile.get_path()
772
+ try:
773
+ with open(path) as f:
774
+ data = json.load(f)
775
+ profiles.import_profile(self._bt_device.address, data)
776
+ self._reload_notif_switches()
777
+ if hasattr(self, "_nick_entry"):
778
+ nick = profiles.get_nickname(self._bt_device.address)
779
+ self._nick_entry.set_text(nick or "")
780
+ except Exception as exc:
781
+ print(f"[device] import failed: {exc}")
782
+
783
+ def _reload_notif_switches(self):
784
+ if not hasattr(self, "_notif_battery_switch"):
785
+ return
786
+ prefs = profiles.get_notify_prefs(self._bt_device.address)
787
+ self._notif_battery_switch.set_active(prefs.get("battery_low", True))
788
+ self._notif_connect_switch.set_active(prefs.get("connect", True))
789
+ self._notif_disconnect_switch.set_active(prefs.get("disconnect", True))
790
+
637
791
  def _on_anc_clicked(self, _btn, mode: int):
638
792
  self._sync_anc_ui(mode)
639
793
  if self._nothing_dev: