10 KiB
SOLID Analysis — MeshCore GUI
1. Reference: standard Python OOP project conventions
| Convention | Norm | This project |
|---|---|---|
| Package with subpackage when widgets emerge | ✅ | ✅ widgets/ subpackage (6 classes) |
| One class per module | ✅ | ✅ every module ≤1 class |
| Entry point outside package | ✅ | ✅ meshcore_gui.py beside package |
__init__.py with version |
✅ | ✅ only __version__ |
| Constants in own module | ✅ | ✅ config.py |
| No circular imports | ✅ | ✅ acyclic dependency tree |
| Type hints on public API | ✅ | ✅ 84/84 methods typed |
Private methods with _ prefix |
✅ | ✅ consistent |
| Docstrings on modules and classes | ✅ | ✅ present everywhere |
| PEP 8 import order | ✅ | ✅ stdlib → third-party → local |
Dependency tree (acyclic)
config protocols
↑ ↑
shared_data worker
↑ main_page → widgets/*
↑ route_builder ← route_page
↑
meshcore_gui.py (only place that knows the concrete SharedData)
No circular dependencies. config and protocols are leaf nodes; everything points in one direction. Widgets depend only on config (for constants) and NiceGUI — they have zero knowledge of SharedData or protocols.
2. SOLID assessment per principle
S — Single Responsibility Principle
"A class should have only one reason to change."
| Module | Class | Responsibility | Verdict |
|---|---|---|---|
config.py |
(no class) | Constants and debug helper | ✅ Single purpose |
protocols.py |
(Protocol classes) | Interface contracts | ✅ Single purpose |
shared_data.py |
SharedData | Thread-safe data store | ✅ See note |
ble/worker.py |
SerialWorker | Serial communication thread | ✅ Single purpose |
main_page.py |
DashboardPage | Dashboard layout orchestrator | ✅ See note |
route_builder.py |
RouteBuilder | Route data construction (pure logic) | ✅ Single purpose |
route_page.py |
RoutePage | Route page rendering | ✅ Single purpose |
widgets/device_panel.py |
DevicePanel | Header, device info, actions | ✅ Single purpose |
widgets/map_panel.py |
MapPanel | Leaflet map with markers | ✅ Single purpose |
widgets/contacts_panel.py |
ContactsPanel | Contacts list + DM dialog | ✅ Single purpose |
widgets/message_input.py |
MessageInput | Message input + channel select | ✅ Single purpose |
widgets/message_list.py |
MessageList | Message feed + channel filter | ✅ Single purpose |
widgets/rx_log_panel.py |
RxLogPanel | RX log table | ✅ Single purpose |
SharedData: 15 public methods in 5 categories (device updates, status, collections, snapshots, lookups). This is deliberate design: SharedData is the single source of truth between two threads. Splitting it would spread lock logic across multiple objects, making thread-safety harder. The responsibility is "thread-safe data access" — that is one reason to change.
DashboardPage: After the widget decomposition, DashboardPage is now 148 lines with only 4 methods. It is a thin orchestrator that composes six widgets into a layout and drives the update timer. All rendering and data-update logic has been extracted into the widget classes. The previous ⚠️ for DashboardPage is resolved.
Conclusion SRP: No violations. All classes have a single, well-defined responsibility.
O — Open/Closed Principle
"Open for extension, closed for modification."
| Scenario | How to extend | Existing code modified? |
|---|---|---|
| Add new page | New module + @ui.page in entry point |
Only entry point (1 line) |
| Add new command | _handle_command() case |
Only ble/worker.py |
| Add new contact type | TYPE_ICONS/NAMES/LABELS in config |
Only config.py |
| Add new dashboard widget | New widget class + compose in DashboardPage | Only main_page.py |
| Add new route info | Extend RouteBuilder.build() | Only route_builder.py |
Where not ideal: _handle_command() in SerialWorker is an if/elif chain. In a larger project, a Command pattern or dict-dispatch would be more appropriate. For 4 commands this is pragmatically correct.
Conclusion OCP: Good. Extensions touch only one module.
L — Liskov Substitution Principle
"Subtypes must be substitutable for their base types."
There is no inheritance in this project. All classes are concrete and standalone. This is correct for the project scale — there is no reason for a class hierarchy.
Where LSP does apply: The Protocol interfaces (SharedDataWriter, SharedDataReader, ContactLookup, SharedDataReadAndLookup) define contracts that SharedData implements. Any object that satisfies these protocols can be substituted — for example a test stub. This is LSP via structural subtyping.
Conclusion LSP: Satisfied via Protocol interfaces. No violations.
I — Interface Segregation Principle
"Clients should not be forced to depend on interfaces they do not use."
| Client | Protocol | Methods visible | SharedData methods not visible |
|---|---|---|---|
| SerialWorker | SharedDataWriter | 10 | 5 (snapshot, flags, GUI commands) |
| DashboardPage | SharedDataReader | 4 | 11 (all write methods) |
| RouteBuilder | ContactLookup | 1 | 14 (everything else) |
| RoutePage | SharedDataReadAndLookup | 5 | 10 (all write methods) |
| Widget classes | (none — receive Dict/callback) | 0 | 15 (all methods) |
Each consumer sees only the methods it needs. The protocols enforce this at the type level. Widget classes go even further: they have zero knowledge of SharedData and receive only plain dictionaries and callbacks.
Conclusion ISP: Satisfied. Each consumer depends on a narrow, purpose-built interface.
D — Dependency Inversion Principle
"Depend on abstractions, not on concretions."
| Dependency | Before (protocols) | After (protocols) |
|---|---|---|
| SerialWorker → SharedData | Concrete ⚠️ | Protocol (SharedDataWriter) ✅ |
| DashboardPage → SharedData | Concrete ⚠️ | Protocol (SharedDataReader) ✅ |
| RouteBuilder → SharedData | Concrete ⚠️ | Protocol (ContactLookup) ✅ |
| RoutePage → SharedData | Concrete ⚠️ | Protocol (SharedDataReadAndLookup) ✅ |
| Widget classes → SharedData | N/A | No dependency at all ✅ |
| meshcore_gui.py → SharedData | Concrete | Concrete ✅ (composition root) |
The composition root (meshcore_gui.py) is the only place that knows the concrete SharedData class. All other modules depend on protocols or receive plain data. This is standard DIP practice: the wiring layer knows the concretions, the business logic knows only abstractions.
Conclusion DIP: Satisfied. Constructor injection was already present; now the abstractions are explicit.
3. Protocol interface design
Why typing.Protocol and not abc.ABC?
Python offers two approaches for defining interfaces:
| Aspect | abc.ABC (nominal) |
typing.Protocol (structural) |
|---|---|---|
| Subclassing required | Yes (class Foo(MyABC)) |
No |
| Duck typing compatible | No | Yes |
| Runtime checkable | Yes | Optional (@runtime_checkable) |
| Python version | 3.0+ | 3.8+ |
Protocol was chosen because SharedData does not need to inherit from an abstract base class. Any object that has the right methods automatically satisfies the protocol — this is idiomatic Python (duck typing with type safety).
Interface map
SharedDataWriter (SerialWorker)
├── update_from_appstart()
├── update_from_device_query()
├── set_status()
├── set_connected()
├── set_contacts()
├── set_channels()
├── add_message()
├── add_rx_log()
├── get_next_command()
└── get_contact_name_by_prefix()
SharedDataReader (DashboardPage)
├── get_snapshot()
├── clear_update_flags()
├── mark_gui_initialized()
└── put_command()
ContactLookup (RouteBuilder)
└── get_contact_by_prefix()
SharedDataReadAndLookup (RoutePage)
├── get_snapshot()
├── clear_update_flags()
├── mark_gui_initialized()
├── put_command()
└── get_contact_by_prefix()
4. Summary
| Principle | Before protocols | With protocols | With widgets | Change |
|---|---|---|---|---|
| SRP | ✅ Good | ✅ Good | ✅ Good | Widget extraction resolved DashboardPage size |
| OCP | ✅ Good | ✅ Good | ✅ Good | Widgets are easy to add |
| LSP | ✅ N/A | ✅ Satisfied via Protocol | ✅ Satisfied via Protocol | — |
| ISP | ⚠️ Acceptable | ✅ Good | ✅ Good | Widgets have zero SharedData dependency |
| DIP | ⚠️ Acceptable | ✅ Good | ✅ Good | — |
Changes: Protocol interfaces
| # | Change | Files affected |
|---|---|---|
| 1 | Added protocols.py with 4 Protocol interfaces |
New file |
| 2 | SerialWorker depends on SharedDataWriter |
ble/worker.py |
| 3 | DashboardPage depends on SharedDataReader |
main_page.py |
| 4 | RouteBuilder depends on ContactLookup |
route_builder.py |
| 5 | RoutePage depends on SharedDataReadAndLookup |
route_page.py |
| 6 | No consumer imports shared_data.py directly |
All consumer modules |
Changes: Widget decomposition
| # | Change | Files affected |
|---|---|---|
| 1 | Added widgets/ subpackage with 6 widget classes |
New directory (7 files) |
| 2 | MeshCoreGUI (740 lines) replaced by DashboardPage (148 lines) + 6 widgets | main_page.py, widgets/*.py |
| 3 | DashboardPage is now a thin orchestrator | main_page.py |
| 4 | Widget classes depend only on config and NiceGUI |
widgets/*.py |
| 5 | Maximum decoupling: widgets have zero SharedData knowledge | All widget modules |
Metrics
| Metric | Monolith | With protocols | With widgets |
|---|---|---|---|
| Files | 1 | 8 | 16 |
| Total lines | 1,395 | ~1,500 | ~1,955 |
| Largest class (lines) | MeshCoreGUI (740) | MeshCoreGUI (740) | SharedData (263) |
| Typed methods | 51 (partial) | 51 (partial) | 90/90 |
| Protocol interfaces | 0 | 4 | 4 |