Files
2026-03-09 17:53:29 +01:00

220 lines
10 KiB
Markdown

# 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 |