mirror of
https://github.com/pe1hvh/meshcore-gui.git
synced 2026-07-06 01:42:13 +02:00
220 lines
10 KiB
Markdown
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 |
|