diff --git a/CLAUDE.md b/CLAUDE.md index 81da36b..0c2d9c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ Pulsemap is developed by Dotify. It follows Dotify's internal engineering standa - **XAML compiler quirks (WindowsAppSDK.WinUI 2.3.6, observed 2026-09-01):** A standalone `.xaml` file whose root element is `ResourceDictionary` may fail with a cryptic `WMC9999 "cannot find Polish/neutral culture resources"` error — this is the XAML compiler's own error-reporting path breaking on a non-English OS locale, not a real problem with your markup, and it can mask a genuinely different underlying error. If you hit `WMC9999`, don't trust it as the real problem: temporarily move suspect files fully outside the project directory (renaming within the tree isn't enough — the SDK's XAML globbing is recursive and folder-name-independent) to bisect down to the actual cause. Concretely already found: (1) an extra `ResourceDictionary`-rooted file caused it once a `Page`-rooted file was *also* present and broken — inlining resources into `App.xaml` (which is `Application`-rooted and always compiled first) sidesteps it; (2) `{x:Bind Some.Method('literalArg')}` — the function-call-with-string-literal-argument form of x:Bind — is not supported by this compiler version and triggers the same masked error; use a computed property on the bound type instead; (3) `{x:Bind PageRoot.ViewModel.SomeCommand}` inside a nested `DataTemplate` — binding to an outer named element's property path from within a `DataTemplate`'s own `x:DataType` scope — also isn't supported here; fall back to a plain `Tag="{x:Bind}"` (binds the template's own DataContext, which *is* supported) plus a code-behind `Click` handler that calls the command directly. - **Related but distinct — a runtime (not compile-time) XAML crash:** declaring `IsChecked="True"` on a `ToggleButton` in markup throws `Microsoft.UI.Xaml.Markup.XamlParseException: Failed to assign to property 'Microsoft.UI.Xaml.Controls.Primitives.ToggleButton.IsChecked'` the moment that page is constructed (`LoadComponent`), silently killing the app since it happens inside `async void`/fire-and-forget navigation code with nothing to catch it. Same locale-masked-error family (the exception's primary message is unreadable Polish mojibake; only a secondary fallback line names the real property), but this one fires at `InitializeComponent()` time, not build time — so it won't show up until the page is actually navigated to. Fix: don't set `IsChecked` declaratively; set it in code-behind after `InitializeComponent()` instead (e.g. `SomeToggleButton.IsChecked = true;`). If a page like this crashes the app with no visible error, temporarily wire `Application.UnhandledException` to write `e.Exception.ToString()` to a file — the default unhandled-exception behavior here is silent process termination with nothing in the UI. - **`x:Bind TwoWay` on `TextBox.Text` defaults to `UpdateSourceTrigger=LostFocus`, not `PropertyChanged`.** This bit us for real: a wizard step's "Next" button stayed disabled after typing a value and clicking straight to "Next" without tabbing away first — the ViewModel property was still empty because the TextBox never lost focus to flush the binding. Any TwoWay-bound `TextBox.Text` that gates a `CanExecute` (or is read immediately after by other logic) needs `UpdateSourceTrigger=PropertyChanged` explicitly: `Text="{x:Bind ViewModel.Foo, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"`. `CheckBox.IsChecked`, `ComboBox.SelectedItem`, and `NumberBox.Value` all commit immediately on their own interaction and don't need this. -- **`Pivot` silently clips overflowing headers into a scroll strip** (small `‹›` chevrons) once header text is too wide for the host column — a clipped-away `PivotItem` is not click-reachable at its "logical" screen position even though `FindFirst`-by-name still returns a seemingly valid `BoundingRectangle` for it (UI Automation doesn't know it's visually clipped). If a Pivot has 3+ items in a narrow side panel, size the column generously and keep header text short, or the last tab(s) become effectively unreachable by mouse. +- **`Pivot` silently clips overflowing headers into a scroll strip** (small `‹›` chevrons) once header text is too wide for the host column — a clipped-away `PivotItem` is not click-reachable at its "logical" screen position even though `FindFirst`-by-name still returns a seemingly valid `BoundingRectangle` for it (UI Automation doesn't know it's visually clipped). If a Pivot has 3+ items in a narrow side panel, size the column generously and keep header text short, or the last tab(s) become effectively unreachable by mouse. **This app no longer uses `Pivot` anywhere** (replaced by `TabView` in the Workspace side panel, 2026-09-02, precisely to remove this failure mode) — kept here as a general WinUI note in case a future control choice reintroduces it. ## Non-negotiable, every change @@ -54,3 +54,29 @@ Don't invent a convention to fill a gap. Name what's missing, propose the smalle - **Surveys library page (added 2026-09-01, DONE):** the `NavSurveys` ("Badania") nav tab was a dead stub (`// Not built yet`) — clicking it did nothing. Home previously showed every survey with no cap, so there was nothing left for a dedicated Surveys page to add; per a product decision, Home is now a dashboard (greeting + New Survey CTA + the 3 most recent surveys only, `HomeViewModel.RecentSurveysLimit`), and the new `SurveysPage`/`SurveysViewModel` owns the full, unlimited list plus rename and delete (each behind a `ContentDialog` confirm — this app's first use of `ContentDialog`). Rename changes `Survey.Name` in the persisted file in place via the existing `ISurveyFileService.SaveAsync` — it does not touch the filename on disk, which was never derived from the display name after creation anyway. `ISurveyLibraryService` gained `DeleteAsync`/`RenameAsync`. Per-card Rename/Delete buttons use `Tag="{x:Bind}"` + a code-behind `Click` handler rather than binding `Command` directly inside the `GridView`'s `DataTemplate`, per the documented nested-`DataTemplate`-binding XAML compiler quirk above. - **Multi-floor, outdoor areas, historical comparison, adaptive Kriging suggestion (added 2026-09-02, DONE):** pulled forward from README's Phase 2 at the user's request (Linux/winget stayed deferred). `Survey.Floor` (singular) is now `Survey.Floors` (`List`) — `Floor` gained `Id`/`Name`/`IsOutdoor`/`Level`/`OutdoorBoundsMin`/`OutdoorBoundsMax`, none `required`, all self-defaulting, so a v1 `"Floor": {...}` object deserializes straight into the current `Floor` type with zero migration DTO needed. `ZipSurveyFileService.LoadAsync` pre-parses `SchemaVersion` via `JsonDocument` and branches — v1 files get their singular floor lifted into `Floors: [floor]`; no exception-driven control flow. **Outdoor areas** (confirmed with the user: same local meter grid as indoor, no real GPS/map) reuse the existing engine almost entirely: `FloorGrid.BuildPoints` falls back to an explicit `OutdoorBoundsMin`/`Max` rectangle when `Walls` is empty and `IsOutdoor`, and since `IPropagationModel.PredictSignalDbm` already took a bare wall list (not a `Floor`), an outdoor call with `Walls` empty already produces pure free-space loss with no propagation-formula changes at all. **Inter-floor propagation** is a deliberate simplification, not full 3D: floors are assumed to stack at the same local X/Y origin, and `CoverageGridCalculator.ComputeGrid` (now takes the full `Floors` list alongside the target floor) applies a flat `InterFloorAttenuationDbPerLevel` (25dB) penalty per level difference for another floor's APs, skipping outdoor areas and same-level-different-floor pairs entirely — AP *placement* stays per-floor-only this pass (no joint cross-floor optimizer), matching how the earlier SINR-interference feature scoped its own harder joint-design piece out. **Adaptive Kriging suggestion**: `IKrigingInterpolator` gained `InterpolateVariance` (ordinary kriging's own estimation-variance side-product, reusing the existing LU factorization — `IKrigingInterpolator` had zero production callers before this, so the extension was purely additive); `MeasurementPointSuggester.SuggestPoints` gained a `(Floor, Band, IKrigingInterpolator)` overload that reorders (doesn't replace) the same candidate list by descending uncertainty once ≥2 same-band measurements exist, falling straight back to the old grid-scan order otherwise. **Snapshots**: `SurveySnapshot` freezes a deep copy of `Floors`' walls/test points/access points (not plan images — those stay live, `[JsonIgnore]`d either way) inside the same `survey.json`; heatmaps for a snapshot are always recomputed live via the same `CoverageGridCalculator`, never cached. New Workspace UI: a floor/zone switcher `ComboBox` in the toolbar row (deliberately not a 4th Pivot tab — the header-clipping bug above is confirmed still live, exactly 3 tabs in a fixed 440px column), "+ Add Floor" (name + outdoor checkbox, one `ContentDialog`), "Save Snapshot" (label, another `ContentDialog`), and "Compare" opening a new `SnapshotComparisonPage` (two independently-pickable "Current or a saved snapshot" sides, a floor picker, two `FloorPlanCanvas` instances side-by-side — not a from-scratch delta-heatmap renderer). The wizard itself is untouched — still creates exactly one `Floor`; adding more floors/areas is a Workspace-only action. 135 tests now (69 Core/66 App), Core branch coverage 87.94%. **Not yet verified live**: the user's real existing survey file loading correctly after the schema migration — attempted via the running app but the session locked mid-check; needs a manual pass. - **Live manual QA pass (2026-09-01):** the app's own developer did a real click-through (not automation) and found two real bugs, both fixed same-day: (1) the dead Surveys tab above; (2) Settings' "Open Logs Folder" button silently did nothing on a fresh install, because `FileAppLogger` only creates its log directory lazily on first write, and `Launcher.LaunchFolderPathAsync` fails on a path that doesn't exist yet — fixed at the call site (`SettingsViewModel.OpenLogsFolderAsync` now `Directory.CreateDirectory`s before launching) rather than inside `FileAppLogger` itself, since creating the directory eagerly in `FileAppLogger`'s constructor would have broken an existing `FileAppLoggerTests` case that deliberately blocks the log path with a plain file to prove the logger degrades gracefully when its directory can't be created. The floor-plan image/PDF rendering and the 4 Export buttons weren't specifically called out as broken or working in this pass — still not explicitly confirmed either way, unlike the two bugs above. +- **Per-wall material editing and guided-walk improvements (added 2026-09-02, DONE, merged to staging via PR #8):** the Select tool (previously a no-op placeholder) now does two things depending on what's clicked: a wall toggles into a multi-select set (highlighted blue), with a toolbar panel to batch-apply one material/thickness to everything selected; a test point instead prompts to recapture its reading (rescans via the currently selected adapter and replaces `Measurements`/`InterferenceReadings`, keeping `Position`). Guided walk gained a Skip button (dequeues without capturing, stays active), renders its remaining queue as faint markers on the canvas, and now persists its pending queue on `Floor.PendingGuidedWalkPoints`/`PendingGuidedWalkBand` — saved on every dequeue/skip/cancel, so closing and reopening the app mid-walk resumes instead of losing the remaining points; `OnSelectedFloorChanged` auto-resumes/pauses as the floor selector switches. 144 tests now on that branch (75 App/69 Core). +- **In-app update check (added 2026-09-02, DONE, merged to staging via PR #7 — see ADR-0004):** Pulsemap's first-ever outbound network call, explicitly cleared with the PM given ADR-0003 had deliberately left this question open. Home now checks `https://api.github.com/repos/DotifyBIZ/pulsemap/releases/latest` on load (via new `IUpdateCheckService`/`GitHubUpdateCheckService`, App-layer, `IHttpClientFactory`-based) and shows a dismissible banner + release link if the tag is newer than the running build (`SemanticVersionComparer`, Core, pure/testable, compares `Assembly.GetName().Version` against a `vX.Y.Z` tag). Gated behind a new `AppSettings.CheckForUpdatesEnabled` (default on, Core, `FileAppSettingsService` at `%LocalAppData%\Pulsemap\settings.json` — Pulsemap's first app-preferences store, distinct from survey data and from `FileAppLogger`'s logs), with a visible toggle on the Settings page ("Check for updates on launch"). A real prerequisite gap found and fixed along the way: `scripts/build-installer.ps1`'s `dotnet publish` never passed `-p:Version=$Version`, so the published assembly's version was always the SDK's static default regardless of the actual release — without that fix, the whole comparison would have been meaningless. README's "won't phone home" bullet is amended accordingly (telemetry/account/server-free claim still holds; the literal zero-network-calls reading no longer does). 82 Core / 76 App tests on that branch — includes a stub-`HttpMessageHandler`-backed test of the actual HTTP/JSON path, not just the pure version-comparison logic. + +**UX pass (started 2026-09-02, in progress)** — a full-codebase UX review turned up several friction points (Select tool overloaded with undiscoverable per-click-target behavior, no undo anywhere, no onboarding, inconsistent delete confirmation, silent AP-suggestion replacement, no survey-type context in Workspace, the Pivot-clipping bug). Being fixed incrementally, each as its own branch: +- **Pivot → TabView migration (added 2026-09-02, on branch `feature/workspace-tabview-migration`, not yet merged to staging):** replaces `Pivot` with `TabView` (`IsAddTabButtonVisible="False"`, `IsClosable="False"` per tab) in the Workspace side panel — same 3 tabs (Coverage/Suggestions/Adapter), same `x:Uid`-driven `Header`s, no ViewModel changes, purely a control swap to eliminate the documented header-clipping failure mode for good rather than keep managing around it. Also removes the "deliberately not a 4th Pivot tab" constraint noted on the CSV/JSON/PDF export and multi-floor entries above — that specific limitation no longer applies, though nothing currently plans to add a 4th tab. +- **Cross-floor channel coordination (added 2026-09-02, on branch `feature/cross-floor-channel-coordination`, not yet merged to staging):** `IApPlacementOptimizer.SuggestPlacements` gained an `allFloors` parameter (mirroring `CoverageGridCalculator.ComputeGrid`'s own `floor`+`allFloors` shape) so `GreedyCoverageApPlacementOptimizer`'s channel ranking can see channels already assigned to APs on other floors, not just this floor's own measured interference. Placement itself stays per-floor-only, per explicit scope decision — this is channel-assignment coordination, not a joint cross-floor optimizer. `CrossFloorChannelUsageScore` reuses `CoverageGridCalculator`'s same skip rules (same-level-different-floor and outdoor floors don't participate) and the same 25dB/level attenuation figure (duplicated as its own constant, not shared across layers — matches how `WorkspaceViewModel` already duplicates `ReliableCoverageThresholdDbm`), scored in the same linear-power units as the existing same-floor `MeasuredInterferenceScore` so the two combine meaningfully instead of one dominating on scale. Known, accepted limitation: since AP suggestion is still a per-floor, user-triggered action, a floor suggested *before* its neighbor won't retroactively pick up the neighbor's channels — re-running suggestions on the earlier floor afterward does. +- **Outdoor-area bounds editor (added 2026-09-02, on branch `feature/outdoor-bounds-editor`, not yet merged to staging):** outdoor areas previously had no way to resize/reposition their extent from the UI — a new floor always got a fixed 40x40m default with no follow-up editor. `FloorPlanCanvas` now draws an outdoor floor's bounds as a dashed rectangle with one resize handle at its far corner (deliberately one anchored corner + one dragged corner, not four independent handles — the smallest interaction that still covers both resize and reposition); dragging inside the rectangle moves the whole area instead. A click first checks for an existing test point/AP within the usual 0.5m tolerance so precise clicks on markers placed inside the area still work — only a click that misses every marker starts a bounds drag. Drag state and a live preview stay canvas-local (mirrors the "canvas owns rendering/gesture recognition only" rule already documented on `FloorPlanCanvas`); only on release does a new `OutdoorBoundsChanged` event fire, which `WorkspaceViewModel.UpdateOutdoorBoundsAsync` persists to `Floor.OutdoorBoundsMin`/`Max`. +- **Per-wall material editing and guided-walk improvements (added 2026-09-02, on branch `feature/wall-material-and-guided-walk`, not yet merged to staging):** the Select tool (previously a no-op placeholder) now does two things depending on what's clicked: a wall toggles into a multi-select set (highlighted blue), with a toolbar panel to batch-apply one material/thickness to everything selected; a test point instead prompts to recapture its reading (rescans via the currently selected adapter and replaces `Measurements`/`InterferenceReadings`, keeping `Position`). Guided walk gained a Skip button (dequeues without capturing, stays active), renders its remaining queue as faint markers on the canvas, and now persists its pending queue on `Floor.PendingGuidedWalkPoints`/`PendingGuidedWalkBand` — saved on every dequeue/skip/cancel, so closing and reopening the app mid-walk resumes instead of losing the remaining points; `OnSelectedFloorChanged` auto-resumes/pauses as the floor selector switches. 144 tests now on that branch (75 App/69 Core). +- **In-app update check (added 2026-09-02, on branch `feature/update-check-notify`, not yet merged to staging — see ADR-0004):** Pulsemap's first-ever outbound network call, explicitly cleared with the PM given ADR-0003 had deliberately left this question open. Home now checks `https://api.github.com/repos/DotifyBIZ/pulsemap/releases/latest` on load (via new `IUpdateCheckService`/`GitHubUpdateCheckService`, App-layer, `IHttpClientFactory`-based) and shows a dismissible banner + release link if the tag is newer than the running build (`SemanticVersionComparer`, Core, pure/testable, compares `Assembly.GetName().Version` against a `vX.Y.Z` tag). Gated behind a new `AppSettings.CheckForUpdatesEnabled` (default on, Core, `FileAppSettingsService` at `%LocalAppData%\Pulsemap\settings.json` — Pulsemap's first app-preferences store, distinct from survey data and from `FileAppLogger`'s logs), with a visible toggle on the Settings page ("Check for updates on launch"). A real prerequisite gap found and fixed along the way: `scripts/build-installer.ps1`'s `dotnet publish` never passed `-p:Version=$Version`, so the published assembly's version was always the SDK's static default regardless of the actual release — without that fix, the whole comparison would have been meaningless. README's "won't phone home" bullet is amended accordingly (telemetry/account/server-free claim still holds; the literal zero-network-calls reading no longer does). 82 Core / 76 App tests on that branch — includes a stub-`HttpMessageHandler`-backed test of the actual HTTP/JSON path, not just the pure version-comparison logic. + +**UX pass (started 2026-09-02, in progress)** — a full-codebase UX review turned up several friction points (Select tool overloaded with undiscoverable per-click-target behavior, no undo anywhere, no onboarding, inconsistent delete confirmation, silent AP-suggestion replacement, no survey-type context in Workspace, the Pivot-clipping bug). Being fixed incrementally, each as its own branch: +- **Workspace onboarding + tool legend, and a warmer Home (added 2026-09-02, on branch `feature/workspace-onboarding`, not yet merged to staging):** a "Tool Help" button (flyout, always available) now explains all four canvas tools including Select's three click-target-dependent behaviors. A skippable first-run coach-mark tour (`TeachingTip`, one per tool button, Next/Skip actions) walks a new user through them once — tracked via a new `AppSettings.HasSeenWorkspaceOnboarding` (same `IAppSettingsService` store the update-check feature added), `WorkspaceViewModel.ShouldShowOnboardingAsync`/`MarkOnboardingSeenAsync`. Separately, at the user's request, Home's static "Home" title is now a time-of-day greeting (`HomeViewModel.GreetingDisplay`, morning/afternoon/evening/late-night buckets on `DateTime.Now.Hour`) plus a small, non-interactive `WellbeingMessageDisplay` line picked at random from 4 options each visit — no reply affordance, just a quiet touch. The now-unused static "HomeTitle" resource was removed from both languages since this change made it dead. +- **Undo last delete (added 2026-09-02, on branch `feature/undo-last-delete`, not yet merged to staging):** the Delete tool removed a wall/test point/AP permanently with zero confirmation or recovery — the single highest-risk unconfirmed action in the app. Deliberately not a full undo/redo stack (that would touch nearly every mutation in `WorkspaceViewModel`) — just single-level: `TryFindNearestElement` now also reports *what* it's about to remove, `DeleteNearestElementAsync` stashes `(Floor, object Item)` before removing, and a new `UndoDeleteCommand` re-adds it. An `InfoBar` with an Undo action button appears after a delete (`CanUndoDelete`/`LastDeletedItemDisplay`), overwritten by the next delete and cleared on floor switch so it never fires somewhere other than where the deletion happened. 5 new tests covering restore-after-delete for both a wall and a test point, overwrite-on-second-delete, and floor-switch clearing. + +**WLAN Link Diagnostics — "Swiss army knife" troubleshooting (added 2026-09-02, on branch `feature/diagnostics-and-polish`, not yet merged to staging):** per the user's own framing — "why on cable I have 100-150mbps, on wlan just a few KBPS" — a generic troubleshooting tool that never connects to or configures any network device, only reads this machine's own adapter state and does local checks (gateway ping, DNS timing), the same category of operation the existing WLAN scan already performs. Landed as one branch covering both halves the user asked for: +- **Core:** `LinkDiagnosticsSnapshot`/`NetworkHealthSnapshot` (Abstractions), `ILinkDiagnosticsService`/`INetworkHealthService` interfaces, and `Diagnostics/LinkDiagnosticsAnalyzer` — pure heuristics (weak signal by the same -67dBm-equivalent threshold `GreedyCoverageApPlacementOptimizer` already uses expressed on `wlanSignalQuality`'s percent scale, a legacy-PHY-rate fallback on 5/6GHz, high gateway ping, DNS failure/slowness, and — only when a predicted dBm is supplied — a predicted-vs-actual mismatch) fully unit tested with zero I/O. `CoverageGridCalculator`'s per-point inner loop was extracted (behavior-preserving) into a public `StrongestSignalDbm(point, floor, allFloors, band, propagationModel)` so both the coverage grid and Workspace's new single-point diagnostics comparison share the exact same cross-floor/outdoor skip rules and never disagree. +- **App, Phase 1 (standalone):** `NativeWlan` gained `WlanQueryInterface` plus the real `WLAN_CONNECTION_ATTRIBUTES`/`WLAN_ASSOCIATION_ATTRIBUTES`/`WLAN_SECURITY_ATTRIBUTES` structs and three small enums, struct layout and opcode values (`wlan_intf_opcode_current_connection` = 7, `wlan_intf_opcode_channel_number` = 8) verified live against Microsoft Learn's wlanapi.h reference rather than assumed, per this file's own documented discipline for this interop file; enum members are named without their native `wlan_..._` prefix (CA1712) with the native name kept in a same-line comment. `ulRxRate`/`ulTxRate`'s undocumented unit was confirmed as plain Kbps (not the 100Kbps unit some other wlanapi rate fields use) against the community `ManagedNativeWifi` wrapper's real-world-validated usage, since Microsoft's own docs state no unit at all. Band classification from a live connection is a documented best-effort: VHT/OFDM PHY types are unambiguous (5GHz-only in practice), other PHY types fall back to channel-number ranges that are only reliable outside 6GHz's numeric overlap with 2.4GHz's 1-14 range (real disambiguation would need a center frequency, which this opcode doesn't expose). New `WlanLinkDiagnosticsService`/`NetworkHealthService` (the latter pure `System.Net.NetworkInformation`, no native surface), `DiagnosticsViewModel` (adapter picker reusing `IWlanAdapterService`, a one-shot "Run diagnostics" plus a `PeriodicTimer`-based continuous-monitoring toggle, 5s interval), new `DiagnosticsPage` and a "Diagnose" nav item. Severity color-coding intentionally lives in a small `DiagnosticSeverityToBrushConverter` (resolving to App.xaml's `DangerBrush`/`WarningBrush`/`SuccessBrush` design tokens) rather than a computed `Brush` property on the ViewModel-layer display record — the first attempt did the latter and broke instantly in a plain xunit test with a null `Application.Current`, since nothing else in this app's ViewModel layer had ever touched a WinUI type directly. +- **App, Phase 2 (Workspace integration):** a 5th toolbar tool, `WorkspaceTool.Diagnose` (not folded into the already-overloaded Select tool, per the UX review's own finding about Select), fires `FloorPlanCanvas.DiagnosePositionRequested` on click; `WorkspaceViewModel.DiagnoseAtPointAsync` combines a live link+health read with `CoverageGridCalculator.StrongestSignalDbm` at that point and runs both through the same `LinkDiagnosticsAnalyzer`, surfaced as a `Flyout` anchored at the actual click position (`FloorPlanCanvas.ToScreenPoint`, a new small public wrapper around its existing private meters-to-pixels math) rather than a new docked panel, keeping the existing 3-tab side panel untouched. Tool Help legend gained a 5th entry. +- 104 App tests / 100 Core tests total on this branch, including the `StrongestSignalDbm` extraction's own regression test and full `LinkDiagnosticsAnalyzerTests` coverage. + +**Remaining UX-review polish items, same branch (added 2026-09-02):** closes out the last four friction points from the full-codebase UX review. +- **Suggest-Placements silent replace:** running it a second time on a floor with existing suggested (not user-placed) access points used to discard them with zero warning. `SuggestPlacementsButton`'s `Command` binding became a `Click` handler (`SuggestPlacements_Click`) that checks the new `WorkspaceViewModel.HasReplaceableSuggestions` (true only when the floor has any `AccessPoint` with `IsUserOverride == false`) and shows a `ContentDialog` confirm only when there's actually something to lose. +- **Survey-type context in Workspace:** `WorkspaceViewModel.SurveyTypeDisplay` reuses the exact same `WizardSurveyTypeSummary*` localization keys the wizard's own final step already had, shown as a new subtitle line under the survey name — nothing in Workspace previously reminded a user which mode ("new deployment" vs. "existing network audit", with SSID) their survey was in once they'd left the wizard. +- **Heatmap-cell explanation:** the Coverage tab's static color legend already existed; the actual gap was a specific cell's exact predicted dBm. `FloorPlanCanvas` gained a small floating tooltip (`TooltipLayer`/`HeatmapTooltip`, a `Border`+`TextBlock` positioned via `Canvas.SetLeft/Top`, not `ToolTipService` — avoids that API's own auto-show/hide fighting with manually-driven content) that follows the pointer in `RootGrid_PointerMoved` and shows the nearest heatmap sample's dBm value whenever the pointer is within `HeatmapCellMeters * 0.75` of it, hidden again on `PointerExited`. +- **Snapshot delete:** `SnapshotComparisonPage` had no way to remove a saved snapshot at all. `SnapshotComparisonViewModel` now takes `ISurveyFileService` and the survey's file path (`Initialize(Survey, string filePath)` — the page's navigation parameter changed from a bare `Survey` to `(Survey, string)`, sourced from a new `WorkspaceViewModel.FilePath`), with `DeleteLeftSnapshotCommand`/`DeleteRightSnapshotCommand` (enabled only when that side's picker has an actual snapshot selected, not "Current") persisting the removal and refreshing both sides' options — the side that didn't just delete keeps its own selection unless that was the same snapshot. A `ContentDialog` confirms first, matching this app's established delete-confirmation pattern (Surveys page, Delete tool). +- 113 App tests / 100 Core tests total after these four. diff --git a/README.md b/README.md index 25a946f..d69513b 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,16 @@ ## Contents -- [What it does](#what-it-does) -- [What it deliberately doesn't do](#what-it-deliberately-doesnt-do) -- [Status](#status) -- [Building from source](#building-from-source) -- [Architecture](#architecture) -- [Roadmap](#roadmap) -- [Contributing](#contributing) -- [License](#license) +- [Pulsemap](#pulsemap) + - [Contents](#contents) + - [What it does](#what-it-does) + - [What it deliberately doesn't do](#what-it-deliberately-doesnt-do) + - [Status](#status) + - [Building from source](#building-from-source) + - [Architecture](#architecture) + - [Roadmap](#roadmap) + - [Contributing](#contributing) + - [License](#license) ## What it does @@ -42,7 +44,7 @@ Pulsemap is a local-first WiFi site survey and planning tool covering the full l - Read live data from AP controllers (UniFi, Omada, ...) — this is survey/measurement tooling, not a controller integration - Ship a built-in vendor hardware database — recommendations stay generic (power, channel, position) -- Phone home. No account, no server, no telemetry. Projects are local files; diagnostics are a local log file you choose whether to share (see [ADR-0003](docs/adr/0003-local-diagnostic-logging.md)) +- Send telemetry, require an account, or use a server for your data. Projects are local files; diagnostics are a local log file you choose whether to share (see [ADR-0003](docs/adr/0003-local-diagnostic-logging.md)). The one exception: an optional, on-by-default check against GitHub's public release list looks for newer versions on launch — nothing about you or your surveys is sent, and it's a toggle in Settings (see [ADR-0004](docs/adr/0004-update-check-network-call.md)) ## Status @@ -90,5 +92,5 @@ This project follows a [Code of Conduct](CODE_OF_CONDUCT.md). ---
- Built by Dotify + Crafted with ❤️ by Dotify
diff --git a/docs/adr/0001-winui3-windows-only-platform.md b/docs/adr/0001-winui3-windows-only-platform.md index b1cbf49..f9bdb43 100644 --- a/docs/adr/0001-winui3-windows-only-platform.md +++ b/docs/adr/0001-winui3-windows-only-platform.md @@ -2,7 +2,7 @@ - **Date:** 2026-08-31 - **Status:** Accepted -- **Deciders:** dmiesopust@pm.me (Product Manager) +- **Deciders:** Product Manager - **Affected systems:** Application shell (`Pulsemap.App`), packaging/distribution ## Context diff --git a/docs/adr/0002-installer-innosetup-over-msix.md b/docs/adr/0002-installer-innosetup-over-msix.md index bdac57e..3ef187a 100644 --- a/docs/adr/0002-installer-innosetup-over-msix.md +++ b/docs/adr/0002-installer-innosetup-over-msix.md @@ -2,7 +2,7 @@ - **Date:** 2026-08-31 - **Status:** Accepted -- **Deciders:** dmiesopust@pm.me (Product Manager) +- **Deciders:** Product Manager - **Affected systems:** Packaging, distribution, release CI ## Context diff --git a/docs/adr/0003-local-diagnostic-logging.md b/docs/adr/0003-local-diagnostic-logging.md index 135ba19..b7afb50 100644 --- a/docs/adr/0003-local-diagnostic-logging.md +++ b/docs/adr/0003-local-diagnostic-logging.md @@ -2,7 +2,7 @@ - **Date:** 2026-09-01 - **Status:** Accepted -- **Deciders:** dmiesopust@pm.me +- **Deciders:** Product Manager - **Affected systems:** `Pulsemap.App.Core` (`Logging/`), all catch blocks that previously swallowed or silently handled a failure, Settings page ## Context diff --git a/docs/adr/0004-update-check-network-call.md b/docs/adr/0004-update-check-network-call.md new file mode 100644 index 0000000..d716ead --- /dev/null +++ b/docs/adr/0004-update-check-network-call.md @@ -0,0 +1,61 @@ +# ADR-0004: In-app update check — Pulsemap's first outbound network call + +- **Date:** 2026-09-02 +- **Status:** Accepted +- **Deciders:** Product Manager +- **Affected systems:** `Pulsemap.App` (`Services/GitHubUpdateCheckService`, `HomeViewModel`, `SettingsViewModel`), `Pulsemap.App.Core` (`Abstractions/IUpdateCheckService`, `Updates/`, `Settings/`), README, `scripts/build-installer.ps1` + +## Context + +Pulsemap's project plan states "no database server, nothing phones home," and every prior ADR that touched this line (see ADR-0003) deliberately left it unresolved rather than deciding it unilaterally — ADR-0003's own References section calls out update-checking by name as one of the still-open questions for a later phase. + +Users installing an unsigned, unpackaged Windows app (ADR-0002) have no Store or MSIX auto-update mechanism to tell them a newer version exists. Without some check, a user could stay on an old build indefinitely, missing fixes, with no signal anything changed. + +## Decision + +Add a single, on-by-default, user-disableable check against GitHub's public Releases API on Home page load: `GET https://api.github.com/repos/DotifyBIZ/pulsemap/releases/latest`, comparing its `tag_name` against the running build's own assembly version. No account identifier, machine identifier, survey data, or usage information is attached to the request — it carries nothing but a User-Agent header, the same as opening the releases page in a browser would. If a newer version is published, Home shows a dismissible banner linking to the release; otherwise nothing is shown and nothing is logged beyond a warning on failure. + +This is explicitly **not** telemetry: no data about this installation or its usage leaves the machine, and nothing is ever received back except a version string. It is still an outbound network call, so it gets its own visible Settings toggle (`Check for updates on launch`, default on) rather than being silently bundled into "diagnostics" or assumed to be as uncontroversial as the local-only logging ADR-0003 added. + +## Options Considered + +### GitHub Releases version check, on by default with an opt-out (chosen) + +- Pros: No backend to build or operate — GitHub's existing public API is the only new dependency. Cheap to implement and to reason about (a version-string comparison, nothing else). Matches what most desktop apps without a store do. Stays honest about the one-way, non-identifying nature of the request. +- Cons: Is, undeniably, a network call this app didn't make before — requires updating README's "won't phone home" claim and getting explicit sign-off (this ADR) rather than treating it as self-evidently fine the way ADR-0003's log file was. + +### Silent/automatic background updater (e.g. Velopack, Squirrel) + +- Pros: Removes the manual "go download the new installer" step entirely. +- Cons: Materially bigger scope — delta packaging, background download, install-time relaunch — for a pre-release app that doesn't yet have a signed installer (ADR-0002) or a release cadence that would make automatic delivery clearly worth the added failure surface. Deferred, not rejected. + +### Opt-in only (default off) + +- Pros: The most conservative reading of "nothing phones home" — a user must actively choose to let anything leave the machine. +- Cons: Most users never visit Settings before their first launch, so the feature would go unused by nearly everyone it exists to help. The check carries no identifying data, so the privacy cost of on-by-default is close to zero; opt-out achieves the same user control with actual reach. + +### Status quo — no update check at all + +- Pros: Zero network surface added, no README change needed. +- Cons: Users have no way to learn a fix or feature they're missing exists, beyond manually checking GitHub themselves. + +## Consequences + +README's "won't phone home" bullet is amended to distinguish telemetry/accounts/servers (still absent) from this one, non-identifying, disableable version check — the spirit of the original claim (no data about you or your surveys ever leaves this machine) holds; the literal "zero network calls, ever" reading does not. + +`scripts/build-installer.ps1` now passes `-p:Version=$Version` to `dotnet publish`, stamping semantic-release's actual version number into the published assembly — previously nothing did this, so `Assembly.GetName().Version` would have read a static SDK default forever, making any version comparison meaningless regardless of which approach this ADR chose. + +A new `AppSettings`/`IAppSettingsService` (Core, JSON file at `%LocalAppData%\Pulsemap\settings.json`) is Pulsemap's first app-preferences store, distinct from `FileAppLogger`'s log files and from per-survey data under `MyDocuments`. It exists to hold exactly one setting today; it is not a general-purpose settings framework built ahead of need. + +## Implementation Notes + +- `IUpdateCheckService` lives in `Pulsemap.App.Core/Abstractions/`, matching the existing pattern for platform/infrastructure capabilities (`IWlanAdapterService`) — the interface is Core, the concrete `GitHubUpdateCheckService` (needs `IHttpClientFactory`, an App-layer DI concern) lives in `Pulsemap.App/Services/`. +- Version comparison itself (`SemanticVersionComparer`) is pure and lives in Core so it's unit-testable without a network dependency — it does the "vX.Y.Z" parsing/comparison; the HTTP call and JSON shape are the only things the App-layer service adds. +- A failed check (offline, GitHub down, malformed response) is treated identically to "no update available" — logged as a warning, never surfaced as an error, and never blocks anything else on the Home page. +- `HttpClient` is obtained via `IHttpClientFactory.CreateClient()` per call, never instantiated directly, per this repo's standing rule. + +## References + +- ADR-0002 (installer distribution, no Store/MSIX auto-update available) +- ADR-0003 (local diagnostic logging — the previous "does this touch 'nothing phones home'" decision, which explicitly left this one open) +- `pulsemap-project-plan.md`'s "no database server, nothing phones home" line (amended by this decision; the account/server/telemetry-free part is unchanged) diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1 index 490fcad..70f47a2 100644 --- a/scripts/build-installer.ps1 +++ b/scripts/build-installer.ps1 @@ -15,8 +15,11 @@ param( $ErrorActionPreference = "Stop" # Locked mode, matching how every other restore in this repo's CI runs — fails loudly on -# packages.lock.json drift instead of silently resolving something newer. -dotnet publish src\Pulsemap.App\Pulsemap.App.csproj -c Release -p:PublishProfile=win-x64 -p:RestoreLockedMode=true +# packages.lock.json drift instead of silently resolving something newer. -p:Version stamps this +# release's number into the published assembly so the in-app update check has something real to +# compare against (see docs/adr/0004-update-check-network-call.md) — without it, every build would +# report the SDK's static default version forever. +dotnet publish src\Pulsemap.App\Pulsemap.App.csproj -c Release -p:PublishProfile=win-x64 -p:RestoreLockedMode=true -p:Version=$Version if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed with exit code $LASTEXITCODE" } diff --git a/src/Pulsemap.App.Core/Abstractions/ILinkDiagnosticsService.cs b/src/Pulsemap.App.Core/Abstractions/ILinkDiagnosticsService.cs new file mode 100644 index 0000000..0c34a97 --- /dev/null +++ b/src/Pulsemap.App.Core/Abstractions/ILinkDiagnosticsService.cs @@ -0,0 +1,8 @@ +namespace Pulsemap.App.Core.Abstractions; + +/// Platform-specific live WLAN link state, implemented in Pulsemap.App via native +/// wlanapi.dll P/Invoke (WlanQueryInterface) — same split as . +public interface ILinkDiagnosticsService +{ + Task GetCurrentLinkAsync(Guid adapterId, CancellationToken cancellationToken = default); +} diff --git a/src/Pulsemap.App.Core/Abstractions/INetworkHealthService.cs b/src/Pulsemap.App.Core/Abstractions/INetworkHealthService.cs new file mode 100644 index 0000000..162ae1f --- /dev/null +++ b/src/Pulsemap.App.Core/Abstractions/INetworkHealthService.cs @@ -0,0 +1,9 @@ +namespace Pulsemap.App.Core.Abstractions; + +/// Local network health checks (gateway ping, DNS timing) for the given adapter. Pure BCL +/// networking (System.Net.NetworkInformation) — no native P/Invoke needed, unlike the WLAN-specific +/// abstractions in this folder. +public interface INetworkHealthService +{ + Task CheckHealthAsync(Guid adapterId, CancellationToken cancellationToken = default); +} diff --git a/src/Pulsemap.App.Core/Abstractions/IUpdateCheckService.cs b/src/Pulsemap.App.Core/Abstractions/IUpdateCheckService.cs new file mode 100644 index 0000000..730565a --- /dev/null +++ b/src/Pulsemap.App.Core/Abstractions/IUpdateCheckService.cs @@ -0,0 +1,12 @@ +namespace Pulsemap.App.Core.Abstractions; + +/// +/// Checks GitHub Releases for a newer published version than this build — Pulsemap's only +/// outbound network call. Gated behind AppSettings.CheckForUpdatesEnabled, since the project +/// otherwise commits to "nothing phones home"; see docs/adr/0004-update-check-network-call.md. +/// Implemented in Pulsemap.App via IHttpClientFactory. +/// +public interface IUpdateCheckService +{ + Task CheckForUpdateAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Pulsemap.App.Core/Abstractions/LinkDiagnosticsSnapshot.cs b/src/Pulsemap.App.Core/Abstractions/LinkDiagnosticsSnapshot.cs new file mode 100644 index 0000000..967aed1 --- /dev/null +++ b/src/Pulsemap.App.Core/Abstractions/LinkDiagnosticsSnapshot.cs @@ -0,0 +1,20 @@ +using Pulsemap.App.Core.Models; + +namespace Pulsemap.App.Core.Abstractions; + +/// A point-in-time reading of this machine's current WLAN association — live link state, +/// not a scan of nearby networks (that's ). / +/// are the negotiated PHY rate, not throughput actually achieved. +public sealed record LinkDiagnosticsSnapshot( + bool IsConnected, + string? Ssid, + string? Bssid, + Band? Band, + int Channel, + int SignalPercent, + string? PhyType, + double RxLinkSpeedMbps, + double TxLinkSpeedMbps) +{ + public static LinkDiagnosticsSnapshot Disconnected { get; } = new(false, null, null, null, 0, 0, null, 0, 0); +} diff --git a/src/Pulsemap.App.Core/Abstractions/NetworkHealthSnapshot.cs b/src/Pulsemap.App.Core/Abstractions/NetworkHealthSnapshot.cs new file mode 100644 index 0000000..bc826e2 --- /dev/null +++ b/src/Pulsemap.App.Core/Abstractions/NetworkHealthSnapshot.cs @@ -0,0 +1,10 @@ +namespace Pulsemap.App.Core.Abstractions; + +/// Local network health independent of the WLAN link itself — isolates "it's not your WiFi, +/// it's upstream" from link-quality problems. Null timing fields mean the corresponding check +/// couldn't run at all (no gateway found, no network path), distinct from a check that ran and +/// found a problem ( false, or a very high ping). +public sealed record NetworkHealthSnapshot(string? GatewayAddress, double? GatewayPingMs, double? DnsResolutionMs, bool DnsSucceeded) +{ + public static NetworkHealthSnapshot Unavailable { get; } = new(null, null, null, false); +} diff --git a/src/Pulsemap.App.Core/Abstractions/UpdateCheckResult.cs b/src/Pulsemap.App.Core/Abstractions/UpdateCheckResult.cs new file mode 100644 index 0000000..c8aae44 --- /dev/null +++ b/src/Pulsemap.App.Core/Abstractions/UpdateCheckResult.cs @@ -0,0 +1,6 @@ +namespace Pulsemap.App.Core.Abstractions; + +public sealed record UpdateCheckResult(bool IsUpdateAvailable, string? LatestVersion, string? ReleaseUrl) +{ + public static readonly UpdateCheckResult NoUpdate = new(false, null, null); +} diff --git a/src/Pulsemap.App.Core/Diagnostics/DiagnosticFinding.cs b/src/Pulsemap.App.Core/Diagnostics/DiagnosticFinding.cs new file mode 100644 index 0000000..3ac06ce --- /dev/null +++ b/src/Pulsemap.App.Core/Diagnostics/DiagnosticFinding.cs @@ -0,0 +1,7 @@ +namespace Pulsemap.App.Core.Diagnostics; + +/// One troubleshooting observation. is a localization key, +/// not display text — are passed to the App layer's +/// string.Format/ILocalizationService call so this stays free of any UI/culture +/// concerns, matching how the rest of Core hands display strings back as keys. +public sealed record DiagnosticFinding(DiagnosticSeverity Severity, string MessageKey, IReadOnlyList? FormatArgs = null); diff --git a/src/Pulsemap.App.Core/Diagnostics/DiagnosticSeverity.cs b/src/Pulsemap.App.Core/Diagnostics/DiagnosticSeverity.cs new file mode 100644 index 0000000..75f265b --- /dev/null +++ b/src/Pulsemap.App.Core/Diagnostics/DiagnosticSeverity.cs @@ -0,0 +1,8 @@ +namespace Pulsemap.App.Core.Diagnostics; + +public enum DiagnosticSeverity +{ + Info, + Warning, + Error, +} diff --git a/src/Pulsemap.App.Core/Diagnostics/LinkDiagnosticsAnalyzer.cs b/src/Pulsemap.App.Core/Diagnostics/LinkDiagnosticsAnalyzer.cs new file mode 100644 index 0000000..cb594fa --- /dev/null +++ b/src/Pulsemap.App.Core/Diagnostics/LinkDiagnosticsAnalyzer.cs @@ -0,0 +1,118 @@ +using Pulsemap.App.Core.Abstractions; +using Pulsemap.App.Core.Models; + +namespace Pulsemap.App.Core.Diagnostics; + +/// Pure heuristics turning a link/network snapshot into plain-language troubleshooting +/// findings — no I/O, so this is where the "why is WiFi slow" reasoning actually lives and can be +/// fully unit tested, independent of the native WLAN calls that produce its inputs. +public static class LinkDiagnosticsAnalyzer +{ + // Same -67dBm "reliable data" convention as GreedyCoverageApPlacementOptimizer, expressed in + // WLAN_ASSOCIATION_ATTRIBUTES.wlanSignalQuality's percent scale via its own documented linear + // mapping (0% = -100dBm, 100% = -50dBm): percent = (dBm + 100) / 0.5. + private const int WeakSignalPercentThreshold = 66; + private const int VeryWeakSignalPercentThreshold = 33; + + // A link stuck at or below this negotiated rate on 5GHz/6GHz strongly implies a fallback to a + // legacy/compatibility PHY rate rather than a real 802.11n/ac/ax/be link — a classic cause of + // "fast WAN, painfully slow WiFi" that signal strength alone wouldn't explain. + private const double LowPhyRateMbpsOn5Or6Ghz = 11; + + private const double HighGatewayPingMs = 100; + private const double SlowDnsResolutionMs = 150; + + // How far below the predicted signal an actual reading has to fall before it's flagged as a + // real mismatch rather than ordinary measurement/model noise (Phase 2 only). + private const double PredictedVsActualMismatchDb = 15; + + public static IReadOnlyList Analyze(LinkDiagnosticsSnapshot link, NetworkHealthSnapshot? health, double? predictedSignalDbm = null) + { + ArgumentNullException.ThrowIfNull(link); + + if (!link.IsConnected) + { + return [new DiagnosticFinding(DiagnosticSeverity.Error, "DiagnosticNotConnected")]; + } + + var findings = new List(); + + AnalyzeSignal(link, findings); + AnalyzePhyRate(link, findings); + AnalyzeNetworkHealth(health, findings); + AnalyzePredictedVsActual(link, predictedSignalDbm, findings); + + if (findings.Count == 0) + { + findings.Add(new DiagnosticFinding(DiagnosticSeverity.Info, "DiagnosticNoIssuesFound")); + } + + return findings; + } + + private static void AnalyzeSignal(LinkDiagnosticsSnapshot link, List findings) + { + if (link.SignalPercent <= VeryWeakSignalPercentThreshold) + { + findings.Add(new DiagnosticFinding(DiagnosticSeverity.Error, "DiagnosticVeryWeakSignal", [link.SignalPercent])); + } + else if (link.SignalPercent <= WeakSignalPercentThreshold) + { + findings.Add(new DiagnosticFinding(DiagnosticSeverity.Warning, "DiagnosticWeakSignal", [link.SignalPercent])); + } + } + + private static void AnalyzePhyRate(LinkDiagnosticsSnapshot link, List findings) + { + if (link.Band is Band.FiveGhz or Band.SixGhz && link.RxLinkSpeedMbps > 0 && link.RxLinkSpeedMbps <= LowPhyRateMbpsOn5Or6Ghz) + { + findings.Add(new DiagnosticFinding(DiagnosticSeverity.Warning, "DiagnosticLegacyPhyRate", [link.RxLinkSpeedMbps])); + } + } + + private static void AnalyzeNetworkHealth(NetworkHealthSnapshot? health, List findings) + { + if (health is null) + { + return; + } + + if (!health.DnsSucceeded) + { + findings.Add(new DiagnosticFinding(DiagnosticSeverity.Error, "DiagnosticDnsFailed")); + } + else if (health.DnsResolutionMs is { } dnsMs && dnsMs >= SlowDnsResolutionMs) + { + findings.Add(new DiagnosticFinding(DiagnosticSeverity.Warning, "DiagnosticSlowDns", [dnsMs])); + } + + if (health.GatewayPingMs is { } pingMs) + { + if (pingMs >= HighGatewayPingMs) + { + findings.Add(new DiagnosticFinding(DiagnosticSeverity.Warning, "DiagnosticHighGatewayPing", [pingMs])); + } + } + else if (health.GatewayAddress is not null) + { + findings.Add(new DiagnosticFinding(DiagnosticSeverity.Error, "DiagnosticGatewayUnreachable")); + } + } + + private static void AnalyzePredictedVsActual(LinkDiagnosticsSnapshot link, double? predictedSignalDbm, List findings) + { + if (predictedSignalDbm is not { } predicted) + { + return; + } + + double actualDbm = PercentToDbm(link.SignalPercent); + double gap = predicted - actualDbm; + if (gap >= PredictedVsActualMismatchDb) + { + findings.Add(new DiagnosticFinding(DiagnosticSeverity.Warning, "DiagnosticPredictedVsActualMismatch", [Math.Round(predicted, 1), Math.Round(actualDbm, 1)])); + } + } + + private static double PercentToDbm(int signalPercent) => -100 + (signalPercent * 0.5); +} diff --git a/src/Pulsemap.App.Core/Models/Floor.cs b/src/Pulsemap.App.Core/Models/Floor.cs index 9d44607..f4a6d67 100644 --- a/src/Pulsemap.App.Core/Models/Floor.cs +++ b/src/Pulsemap.App.Core/Models/Floor.cs @@ -29,4 +29,13 @@ public sealed class Floor public List TestPoints { get; init; } = []; public List AccessPoints { get; init; } = []; + + /// Points not yet captured in an in-progress guided measurement walk, so closing and + /// reopening the app mid-walk resumes instead of losing the remaining queue. Empty when no + /// walk is in progress on this floor. + public List PendingGuidedWalkPoints { get; init; } = []; + + /// The band the pending walk above was suggested for — needed to restore + /// SelectedBand on resume, since the suggester's candidate order depends on it. + public Band? PendingGuidedWalkBand { get; set; } } diff --git a/src/Pulsemap.App.Core/Placement/GreedyCoverageApPlacementOptimizer.cs b/src/Pulsemap.App.Core/Placement/GreedyCoverageApPlacementOptimizer.cs index 8dd4e45..83be552 100644 --- a/src/Pulsemap.App.Core/Placement/GreedyCoverageApPlacementOptimizer.cs +++ b/src/Pulsemap.App.Core/Placement/GreedyCoverageApPlacementOptimizer.cs @@ -39,9 +39,15 @@ public sealed class GreedyCoverageApPlacementOptimizer : IApPlacementOptimizer private const double TargetCoverageFraction = 0.95; private const int MaxAccessPoints = 8; - public IReadOnlyList SuggestPlacements(Floor floor, IReadOnlyList bands, IPropagationModel propagationModel) + // Matches CoverageGridCalculator's own inter-floor attenuation figure — kept as a separate + // constant rather than shared across layers, same as WorkspaceViewModel's own duplicate of + // ReliableCoverageThresholdDbm elsewhere in this codebase. + private const double InterFloorAttenuationDbPerLevel = 25; + + public IReadOnlyList SuggestPlacements(Floor floor, IReadOnlyList allFloors, IReadOnlyList bands, IPropagationModel propagationModel) { ArgumentNullException.ThrowIfNull(floor); + ArgumentNullException.ThrowIfNull(allFloors); ArgumentNullException.ThrowIfNull(bands); ArgumentNullException.ThrowIfNull(propagationModel); @@ -84,7 +90,7 @@ public IReadOnlyList SuggestPlacements(Floor floor, IReadOnlyList BuildAccessPoint(position, index, bands, floor.TestPoints)).ToList(); + return placements.Select((position, index) => BuildAccessPoint(position, index, bands, floor, allFloors)).ToList(); } private static (Point2D? Candidate, bool[]? CoverageMask) FindBestCandidate( @@ -154,7 +160,7 @@ private static double EffectiveReliabilityThresholdDbm(Point2D point, Band band, return Math.Max(ReliableCoverageThresholdDbm, strongestNearbyInterferenceDbm + SinrMarginDb); } - private static AccessPoint BuildAccessPoint(Point2D position, int index, IReadOnlyList bands, IReadOnlyList testPoints) + private static AccessPoint BuildAccessPoint(Point2D position, int index, IReadOnlyList bands, Floor floor, IReadOnlyList allFloors) { var accessPoint = new AccessPoint { @@ -164,7 +170,7 @@ private static AccessPoint BuildAccessPoint(Point2D position, int index, IReadOn foreach (var band in bands) { - var channels = RankChannelsByMeasuredInterference(ChannelPlan.ChannelsFor(band), band, testPoints); + var channels = RankChannelsByInterference(ChannelPlan.ChannelsFor(band), band, floor, allFloors); accessPoint.Radios[band] = new BandRadioSettings { TransmitPowerDbm = ChannelPlan.DefaultTransmitPowerDbm(band), @@ -175,23 +181,24 @@ private static AccessPoint BuildAccessPoint(Point2D position, int index, IReadOn return accessPoint; } - // Orders a band's candidate channels least-congested first, using every guided-walk - // interference reading captured on the floor. Round-robin assignment across APs still walks - // this reordered list, so APs keep getting distinct channels — just biased away from whichever - // channels neighboring networks are already using. With no measurements yet (predictive-only - // survey), every channel ties at zero and LINQ's stable OrderBy preserves the original order — - // identical round-robin behavior to before any walk existed. - private static IReadOnlyList RankChannelsByMeasuredInterference(IReadOnlyList channels, Band band, IReadOnlyList testPoints) - { - if (testPoints.Count == 0) - { - return channels; - } - - return channels - .OrderBy(channel => MeasuredInterferenceScore(channel, band, testPoints)) + // Orders a band's candidate channels least-congested first, weighing both this floor's own + // guided-walk interference readings and channels already assigned to APs on nearby floors + // (weighted down by distance the same way CoverageGridCalculator discounts cross-floor signal). + // Round-robin assignment across APs still walks this reordered list, so APs keep getting + // distinct channels — just biased away from whichever channels are already congested, on this + // floor or the ones around it. With no measurements and no other floors' APs yet, every channel + // ties at zero and LINQ's stable OrderBy preserves the original order — identical round-robin + // behavior to before either signal existed. + // + // This only sees APs already placed on other floors at the time this floor's own placement + // runs — since AP suggestion stays a per-floor, user-triggered action (no joint cross-floor + // optimizer), a floor suggested before its neighbor won't retroactively account for channels + // the neighbor picks later. Re-running suggestions on an earlier floor after its neighbors are + // done picks up their channels same as any other re-run. + private static List RankChannelsByInterference(IReadOnlyList channels, Band band, Floor floor, IReadOnlyList allFloors) => + channels + .OrderBy(channel => MeasuredInterferenceScore(channel, band, floor.TestPoints) + CrossFloorChannelUsageScore(channel, band, floor, allFloors)) .ToList(); - } // Linear-power sum (not a dBm average) so a single strong nearby network dominates the score // the way it would dominate real co-channel interference. @@ -201,6 +208,35 @@ private static double MeasuredInterferenceScore(int channel, Band band, IReadOnl .Where(r => r.Band == band && r.Channel == channel) .Sum(r => Math.Pow(10, r.SignalDbm / 10.0)); + // Same skip rules as CoverageGridCalculator (same-level-different-floor and outdoor areas + // don't participate in the flat "stacked at the same origin" cross-floor model), scored in the + // same linear-power units as MeasuredInterferenceScore so the two combine meaningfully. + private static double CrossFloorChannelUsageScore(int channel, Band band, Floor floor, IReadOnlyList allFloors) + { + double score = 0; + + foreach (var otherFloor in allFloors) + { + bool sameFloor = otherFloor.Id == floor.Id; + int levelDifference = Math.Abs(floor.Level - otherFloor.Level); + if (sameFloor || levelDifference == 0 || floor.IsOutdoor || otherFloor.IsOutdoor) + { + continue; + } + + foreach (var accessPoint in otherFloor.AccessPoints) + { + if (accessPoint.Radios.TryGetValue(band, out var radio) && radio.Channel == channel) + { + double leakedSignalDbm = radio.TransmitPowerDbm - (InterFloorAttenuationDbPerLevel * levelDifference); + score += Math.Pow(10, leakedSignalDbm / 10.0); + } + } + } + + return score; + } + private static double CoverageFraction(bool[] covered) => covered.Length == 0 ? 1.0 : covered.Count(c => c) / (double)covered.Length; } diff --git a/src/Pulsemap.App.Core/Placement/IApPlacementOptimizer.cs b/src/Pulsemap.App.Core/Placement/IApPlacementOptimizer.cs index f6cd597..f48cdd7 100644 --- a/src/Pulsemap.App.Core/Placement/IApPlacementOptimizer.cs +++ b/src/Pulsemap.App.Core/Placement/IApPlacementOptimizer.cs @@ -5,6 +5,7 @@ namespace Pulsemap.App.Core.Placement; public interface IApPlacementOptimizer { - /// Suggests AP count and placement for a floor. Always a starting point — every result is user-overridable, never authoritative. - IReadOnlyList SuggestPlacements(Floor floor, IReadOnlyList bands, IPropagationModel propagationModel); + /// Suggests AP count and placement for a floor. Always a starting point — every result is user-overridable, never authoritative. + /// lets channel selection avoid reusing a channel a nearby floor's AP already has — placement itself stays per-floor-only (see GreedyCoverageApPlacementOptimizer's remarks). + IReadOnlyList SuggestPlacements(Floor floor, IReadOnlyList allFloors, IReadOnlyList bands, IPropagationModel propagationModel); } diff --git a/src/Pulsemap.App.Core/Propagation/CoverageGridCalculator.cs b/src/Pulsemap.App.Core/Propagation/CoverageGridCalculator.cs index ba9d441..4ea19b1 100644 --- a/src/Pulsemap.App.Core/Propagation/CoverageGridCalculator.cs +++ b/src/Pulsemap.App.Core/Propagation/CoverageGridCalculator.cs @@ -36,50 +36,60 @@ public static IReadOnlyList ComputeGrid(Floor floor, IReadOnlyLi foreach (var point in gridPoints) { - double? strongest = null; + if (StrongestSignalDbm(point, floor, allFloors, band, propagationModel) is { } value) + { + samples.Add(new CoverageSample(point, value)); + } + } - foreach (var otherFloor in allFloors) + return samples; + } + + /// The strongest predicted signal at a single point, across every access point on + /// and (subject to the same skip rules uses) + /// every other floor in . Shared by the coverage grid (one call per + /// grid point) and by Workspace's single-point diagnostics comparison, so the two never diverge + /// on cross-floor/outdoor skip rules or the per-level attenuation figure. + public static double? StrongestSignalDbm(Point2D point, Floor floor, IReadOnlyList allFloors, Band band, IPropagationModel propagationModel) + { + double? strongest = null; + + foreach (var otherFloor in allFloors) + { + bool sameFloor = otherFloor.Id == floor.Id; + int levelDifference = Math.Abs(floor.Level - otherFloor.Level); + if (!sameFloor && (levelDifference == 0 || floor.IsOutdoor || otherFloor.IsOutdoor)) { - bool sameFloor = otherFloor.Id == floor.Id; - int levelDifference = Math.Abs(floor.Level - otherFloor.Level); - if (!sameFloor && (levelDifference == 0 || floor.IsOutdoor || otherFloor.IsOutdoor)) + // Skip cross-floor contribution when: a different floor shares this one's + // Level (Level is the only signal we have for floor-above/below relationships, + // so an unrelated same-level area shouldn't silently leak in), or either side is + // outdoor — the flat "stacked at the same origin" model this constant represents + // doesn't meaningfully describe an open-air area's relationship to an indoor + // floor, so outdoor areas' coverage stays isolated to their own access points. + continue; + } + + foreach (var accessPoint in otherFloor.AccessPoints) + { + if (!accessPoint.Radios.TryGetValue(band, out var radio)) { - // Skip cross-floor contribution when: a different floor shares this one's - // Level (Level is the only signal we have for floor-above/below relationships, - // so an unrelated same-level area shouldn't silently leak in), or either side is - // outdoor — the flat "stacked at the same origin" model this constant represents - // doesn't meaningfully describe an open-air area's relationship to an indoor - // floor, so outdoor areas' coverage stays isolated to their own access points. continue; } - foreach (var accessPoint in otherFloor.AccessPoints) - { - if (!accessPoint.Radios.TryGetValue(band, out var radio)) - { - continue; - } + // Cross-floor: the other floor's walls aren't in this floor's horizontal path, + // so free-space-only plus a flat per-level penalty stands in for the ceiling/ + // floor slab loss a real building would add. + double signal = sameFloor + ? propagationModel.PredictSignalDbm(accessPoint.Position, radio.TransmitPowerDbm, point, band, floor.Walls) + : propagationModel.PredictSignalDbm(accessPoint.Position, radio.TransmitPowerDbm, point, band, NoWalls) - (InterFloorAttenuationDbPerLevel * levelDifference); - // Cross-floor: the other floor's walls aren't in this floor's horizontal path, - // so free-space-only plus a flat per-level penalty stands in for the ceiling/ - // floor slab loss a real building would add. - double signal = sameFloor - ? propagationModel.PredictSignalDbm(accessPoint.Position, radio.TransmitPowerDbm, point, band, floor.Walls) - : propagationModel.PredictSignalDbm(accessPoint.Position, radio.TransmitPowerDbm, point, band, NoWalls) - (InterFloorAttenuationDbPerLevel * levelDifference); - - if (strongest is null || signal > strongest) - { - strongest = signal; - } + if (strongest is null || signal > strongest) + { + strongest = signal; } } - - if (strongest is { } value) - { - samples.Add(new CoverageSample(point, value)); - } } - return samples; + return strongest; } } diff --git a/src/Pulsemap.App.Core/Settings/AppSettings.cs b/src/Pulsemap.App.Core/Settings/AppSettings.cs new file mode 100644 index 0000000..be9fb68 --- /dev/null +++ b/src/Pulsemap.App.Core/Settings/AppSettings.cs @@ -0,0 +1,15 @@ +namespace Pulsemap.App.Core.Settings; + +/// App-level preferences, distinct from per-survey data — persisted locally, never +/// synced anywhere. +public sealed class AppSettings +{ + /// Default on: checking is a single unauthenticated GET to GitHub's public releases + /// API with no identifying data attached, not telemetry — but it's still an outbound network + /// call, so it stays user-visible and disableable in Settings. + public bool CheckForUpdatesEnabled { get; set; } = true; + + /// Whether the Workspace first-run coach-mark tour has been shown or skipped — + /// suppresses it on every later visit regardless of which survey is open. + public bool HasSeenWorkspaceOnboarding { get; set; } +} diff --git a/src/Pulsemap.App.Core/Settings/FileAppSettingsService.cs b/src/Pulsemap.App.Core/Settings/FileAppSettingsService.cs new file mode 100644 index 0000000..6193b91 --- /dev/null +++ b/src/Pulsemap.App.Core/Settings/FileAppSettingsService.cs @@ -0,0 +1,57 @@ +using System.Text.Json; + +namespace Pulsemap.App.Core.Settings; + +/// +/// Reads/writes app preferences as JSON at %LocalAppData%\Pulsemap\settings.json — same directory +/// family as FileAppLogger's Logs folder, distinct from where surveys (user data) live under +/// MyDocuments. A missing or corrupt file quietly falls back to defaults rather than failing +/// startup; a failed save is just asked again next launch. +/// +public sealed class FileAppSettingsService : IAppSettingsService +{ + private readonly string _filePath; + + public FileAppSettingsService() + : this(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Pulsemap", "settings.json")) + { + } + + public FileAppSettingsService(string filePath) + { + _filePath = filePath; + } + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + try + { + if (!File.Exists(_filePath)) + { + return new AppSettings(); + } + + await using var stream = File.OpenRead(_filePath); + return await JsonSerializer.DeserializeAsync(stream, cancellationToken: cancellationToken) ?? new AppSettings(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + return new AppSettings(); + } + } + + public async Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!); + await using var stream = File.Create(_filePath); + await JsonSerializer.SerializeAsync(stream, settings, cancellationToken: cancellationToken); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A failed save (disk full, permissions) just means the preference reverts to its + // default next launch — not worth failing whatever action triggered the save. + } + } +} diff --git a/src/Pulsemap.App.Core/Settings/IAppSettingsService.cs b/src/Pulsemap.App.Core/Settings/IAppSettingsService.cs new file mode 100644 index 0000000..1ed3a18 --- /dev/null +++ b/src/Pulsemap.App.Core/Settings/IAppSettingsService.cs @@ -0,0 +1,8 @@ +namespace Pulsemap.App.Core.Settings; + +public interface IAppSettingsService +{ + Task LoadAsync(CancellationToken cancellationToken = default); + + Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default); +} diff --git a/src/Pulsemap.App.Core/Updates/SemanticVersionComparer.cs b/src/Pulsemap.App.Core/Updates/SemanticVersionComparer.cs new file mode 100644 index 0000000..36c1e9a --- /dev/null +++ b/src/Pulsemap.App.Core/Updates/SemanticVersionComparer.cs @@ -0,0 +1,27 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Pulsemap.App.Core.Updates; + +/// Compares the running assembly's version against a GitHub release tag in +/// semantic-release's default "vX.Y.Z" format. Pure and side-effect-free so it's testable without +/// a network dependency — the actual HTTP call lives behind IUpdateCheckService. +public static class SemanticVersionComparer +{ + public static bool IsNewer(string currentVersion, string candidateTag) + { + if (!TryParse(currentVersion, out var current) || !TryParse(candidateTag, out var candidate)) + { + return false; + } + + return Normalize(candidate) > Normalize(current); + } + + // Ignores Revision entirely so a 4-part assembly version (MSBuild always pads to X.Y.Z.0) + // compares correctly against a 3-part release tag instead of tripping on System.Version's + // "unspecified component sorts lower" rule for an exact-match version. + private static Version Normalize(Version version) => new(version.Major, version.Minor, Math.Max(version.Build, 0)); + + private static bool TryParse(string raw, [NotNullWhen(true)] out Version? version) => + Version.TryParse(raw.TrimStart('v', 'V'), out version); +} diff --git a/src/Pulsemap.App/App.xaml.cs b/src/Pulsemap.App/App.xaml.cs index 7aca540..f99f297 100644 --- a/src/Pulsemap.App/App.xaml.cs +++ b/src/Pulsemap.App/App.xaml.cs @@ -12,6 +12,7 @@ using Pulsemap.App.Core.Persistence; using Pulsemap.App.Core.Placement; using Pulsemap.App.Core.Propagation; +using Pulsemap.App.Core.Settings; using Pulsemap.App.Services; using Pulsemap.App.ViewModels; @@ -105,7 +106,12 @@ private static ServiceProvider ConfigureServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddHttpClient(); + services.AddSingleton(); // ViewModels — transient, recreated per navigation. services.AddTransient(); @@ -114,6 +120,7 @@ private static ServiceProvider ConfigureServices() services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); return services.BuildServiceProvider(); } diff --git a/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml b/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml index 06da252..4ace7fa 100644 --- a/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml +++ b/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml @@ -8,11 +8,28 @@ mc:Ignorable="d"> - + + + + + + diff --git a/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs b/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs index 231ecac..ffd06ea 100644 --- a/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs +++ b/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs @@ -30,6 +30,9 @@ public sealed partial class FloorPlanCanvas : UserControl private const double WalkTargetRadiusPx = 14; private const double WallStrokeThicknessPx = 4; private const double HeatmapOpacity = 0.55; + private const double OutdoorResizeHandleRadiusPx = 8; + private const double OutdoorHitToleranceMeters = 0.5; // matches WorkspaceViewModel's own DeleteHitToleranceMeters + private const double MinOutdoorSizeMeters = 2; private readonly FloorPlanImageCache _imageCache; @@ -40,6 +43,28 @@ public sealed partial class FloorPlanCanvas : UserControl private double _backgroundHeightMeters; private int _renderVersion; + // Outdoor-bounds drag state — resize/move only ever needs one anchored corner (Min) and one + // dragged corner (Max) rather than four independent handles, the smallest interaction that + // still covers "resize" and "reposition" without a full multi-handle editor. + private OutdoorDragMode _outdoorDragMode = OutdoorDragMode.None; + private Point2D _dragStartPointerMeters; + private Point2D _dragStartMin; + private Point2D _dragStartMax; + private Point2D? _previewBoundsMin; + private Point2D? _previewBoundsMax; + private Floor? _lastFloor; + private IReadOnlyList _lastHeatmap = []; + private Point2D? _lastWalkTarget; + private IReadOnlyCollection? _lastSelectedWalls; + private IReadOnlyList? _lastRemainingWalkPoints; + + private enum OutdoorDragMode + { + None, + Move, + Resize, + } + public FloorPlanCanvas() { InitializeComponent(); @@ -54,6 +79,12 @@ public FloorPlanCanvas() public event EventHandler? DeleteRequested; + public event EventHandler? WallSelectRequested; + + public event EventHandler? DiagnosePositionRequested; + + public event EventHandler<(Point2D Min, Point2D Max)>? OutdoorBoundsChanged; + /// /// Renders walls/points/heatmap immediately, plus (for an image-style floor plan) the /// background image/PDF once it's decoded — decoding only happens the first time a given @@ -61,7 +92,13 @@ public FloorPlanCanvas() /// newer call starts while this one is still decoding, this call /// abandons itself rather than overwriting the newer render's result. /// - public async Task RenderAsync(Floor floor, IReadOnlyList heatmap, Point2D? walkTarget = null, CancellationToken cancellationToken = default) + public async Task RenderAsync( + Floor floor, + IReadOnlyList heatmap, + Point2D? walkTarget = null, + IReadOnlyCollection? selectedWalls = null, + IReadOnlyList? remainingWalkPoints = null, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(floor); ArgumentNullException.ThrowIfNull(heatmap); @@ -104,7 +141,7 @@ public async Task RenderAsync(Floor floor, IReadOnlyList heatmap _backgroundHeightMeters = 0; } - RenderCore(floor, heatmap, walkTarget); + RenderCore(floor, heatmap, walkTarget, selectedWalls, remainingWalkPoints); } private static Task<(int PixelWidth, int PixelHeight)> LoadAndMeasureAsync(BitmapImage bitmap, string filePath) @@ -132,8 +169,19 @@ void OnFailed(object sender, ExceptionRoutedEventArgs e) return completion.Task; } - private void RenderCore(Floor floor, IReadOnlyList heatmap, Point2D? walkTarget) + private void RenderCore( + Floor floor, + IReadOnlyList heatmap, + Point2D? walkTarget, + IReadOnlyCollection? selectedWalls, + IReadOnlyList? remainingWalkPoints) { + _lastFloor = floor; + _lastHeatmap = heatmap; + _lastWalkTarget = walkTarget; + _lastSelectedWalls = selectedWalls; + _lastRemainingWalkPoints = remainingWalkPoints; + _bounds = ComputeBounds(floor); RootGrid.Width = _bounds.WidthMeters * PixelsPerMeter; RootGrid.Height = _bounds.HeightMeters * PixelsPerMeter; @@ -156,7 +204,15 @@ private void RenderCore(Floor floor, IReadOnlyList heatmap, Poin foreach (var wall in floor.Walls) { - WallsLayer.Children.Add(BuildWallLine(wall)); + WallsLayer.Children.Add(BuildWallLine(wall, selectedWalls?.Contains(wall) == true)); + } + + if (floor.IsOutdoor + && (_previewBoundsMin ?? floor.OutdoorBoundsMin) is { } boundsMin + && (_previewBoundsMax ?? floor.OutdoorBoundsMax) is { } boundsMax) + { + MarkersLayer.Children.Add(BuildOutdoorBoundsRectangle(boundsMin, boundsMax)); + MarkersLayer.Children.Add(BuildOutdoorResizeHandle(boundsMax)); } foreach (var testPoint in floor.TestPoints) @@ -169,6 +225,11 @@ private void RenderCore(Floor floor, IReadOnlyList heatmap, Poin MarkersLayer.Children.Add(BuildAccessPointMarker(accessPoint)); } + foreach (var upcoming in remainingWalkPoints ?? []) + { + MarkersLayer.Children.Add(BuildRemainingWalkPointMarker(upcoming)); + } + if (walkTarget is { } target) { MarkersLayer.Children.Add(BuildWalkTargetMarker(target)); @@ -191,7 +252,7 @@ private Rectangle BuildHeatmapCell(CoverageSample sample) return cell; } - private Line BuildWallLine(Wall wall) + private Line BuildWallLine(Wall wall, bool isSelected) { var (x1, y1) = ToPixels(wall.Start); var (x2, y2) = ToPixels(wall.End); @@ -201,8 +262,8 @@ private Line BuildWallLine(Wall wall) Y1 = y1, X2 = x2, Y2 = y2, - Stroke = new SolidColorBrush(Colors.DimGray), - StrokeThickness = WallStrokeThicknessPx, + Stroke = new SolidColorBrush(isSelected ? Colors.DodgerBlue : Colors.DimGray), + StrokeThickness = isSelected ? WallStrokeThicknessPx + 2 : WallStrokeThicknessPx, StrokeStartLineCap = PenLineCap.Round, StrokeEndLineCap = PenLineCap.Round, }; @@ -259,6 +320,58 @@ private Ellipse BuildWalkTargetMarker(Point2D position) return marker; } + private Ellipse BuildRemainingWalkPointMarker(Point2D position) + { + var (px, py) = ToPixels(position); + double diameter = WalkTargetRadiusPx; + var marker = new Ellipse + { + Width = diameter, + Height = diameter, + Stroke = new SolidColorBrush(Colors.MediumPurple), + StrokeThickness = 1.5, + Opacity = 0.5, + }; + Canvas.SetLeft(marker, px - (diameter / 2)); + Canvas.SetTop(marker, py - (diameter / 2)); + return marker; + } + + private Rectangle BuildOutdoorBoundsRectangle(Point2D min, Point2D max) + { + var (x1, y1) = ToPixels(min); + var (x2, y2) = ToPixels(max); + var rectangle = new Rectangle + { + Width = Math.Abs(x2 - x1), + Height = Math.Abs(y2 - y1), + Stroke = new SolidColorBrush(Colors.SeaGreen), + StrokeThickness = 2, + StrokeDashArray = [6, 3], + Fill = new SolidColorBrush(Color.FromArgb(24, 46, 139, 87)), + }; + Canvas.SetLeft(rectangle, Math.Min(x1, x2)); + Canvas.SetTop(rectangle, Math.Min(y1, y2)); + return rectangle; + } + + private Ellipse BuildOutdoorResizeHandle(Point2D max) + { + var (px, py) = ToPixels(max); + double diameter = OutdoorResizeHandleRadiusPx * 2; + var handle = new Ellipse + { + Width = diameter, + Height = diameter, + Fill = new SolidColorBrush(Colors.SeaGreen), + Stroke = new SolidColorBrush(Colors.White), + StrokeThickness = 2, + }; + Canvas.SetLeft(handle, px - OutdoorResizeHandleRadiusPx); + Canvas.SetTop(handle, py - OutdoorResizeHandleRadiusPx); + return handle; + } + private static Color HeatmapColor(double signalDbm) => signalDbm switch { >= -50 => Colors.Green, @@ -297,11 +410,156 @@ private void RootGrid_PointerPressed(object sender, PointerRoutedEventArgs e) break; case WorkspaceTool.Select: + if (TryStartOutdoorBoundsDrag(position, meters)) + { + RootGrid.CapturePointer(e.Pointer); + break; + } + + WallSelectRequested?.Invoke(this, meters); + break; + + case WorkspaceTool.Diagnose: + DiagnosePositionRequested?.Invoke(this, meters); + break; + default: break; } } + // A click on the resize handle or inside the rectangle body starts a drag instead of firing + // WallSelectRequested — but only when nothing selectable (test point/AP) is under the click, + // so precise clicks on markers placed inside an outdoor area still work as before. + private bool TryStartOutdoorBoundsDrag(Windows.Foundation.Point position, Point2D meters) + { + if (_lastFloor is not { IsOutdoor: true } floor + || (_previewBoundsMin ?? floor.OutdoorBoundsMin) is not { } min + || (_previewBoundsMax ?? floor.OutdoorBoundsMax) is not { } max + || IsNearExistingMarker(floor, meters)) + { + return false; + } + + var (maxPx, maxPy) = ToPixels(max); + double handleDx = position.X - maxPx; + double handleDy = position.Y - maxPy; + bool onHandle = Math.Sqrt((handleDx * handleDx) + (handleDy * handleDy)) <= OutdoorResizeHandleRadiusPx; + bool insideBody = meters.X >= min.X && meters.X <= max.X && meters.Y >= min.Y && meters.Y <= max.Y; + + if (!onHandle && !insideBody) + { + return false; + } + + _outdoorDragMode = onHandle ? OutdoorDragMode.Resize : OutdoorDragMode.Move; + _dragStartPointerMeters = meters; + _dragStartMin = min; + _dragStartMax = max; + return true; + } + + private static bool IsNearExistingMarker(Floor floor, Point2D at) => + floor.TestPoints.Any(tp => tp.Position.DistanceTo(at) <= OutdoorHitToleranceMeters) || + floor.AccessPoints.Any(ap => ap.Position.DistanceTo(at) <= OutdoorHitToleranceMeters); + + private void RootGrid_PointerMoved(object sender, PointerRoutedEventArgs e) + { + var pointerPosition = e.GetCurrentPoint(RootGrid).Position; + + if (_outdoorDragMode == OutdoorDragMode.None) + { + UpdateHeatmapTooltip(ToMeters(pointerPosition), pointerPosition); + } + + if (_outdoorDragMode == OutdoorDragMode.None || _lastFloor is null) + { + return; + } + + var meters = ToMeters(pointerPosition); + double deltaX = meters.X - _dragStartPointerMeters.X; + double deltaY = meters.Y - _dragStartPointerMeters.Y; + + if (_outdoorDragMode == OutdoorDragMode.Move) + { + _previewBoundsMin = new Point2D(_dragStartMin.X + deltaX, _dragStartMin.Y + deltaY); + _previewBoundsMax = new Point2D(_dragStartMax.X + deltaX, _dragStartMax.Y + deltaY); + } + else + { + _previewBoundsMin = _dragStartMin; + _previewBoundsMax = new Point2D( + Math.Max(_dragStartMin.X + MinOutdoorSizeMeters, _dragStartMax.X + deltaX), + Math.Max(_dragStartMin.Y + MinOutdoorSizeMeters, _dragStartMax.Y + deltaY)); + } + + RenderCore(_lastFloor, _lastHeatmap, _lastWalkTarget, _lastSelectedWalls, _lastRemainingWalkPoints); + } + + private void RootGrid_PointerExited(object sender, PointerRoutedEventArgs e) => HeatmapTooltip.Visibility = Visibility.Collapsed; + + private void RootGrid_PointerReleased(object sender, PointerRoutedEventArgs e) => EndOutdoorBoundsDrag(raiseChanged: true); + + private void RootGrid_PointerCaptureLost(object sender, PointerRoutedEventArgs e) => EndOutdoorBoundsDrag(raiseChanged: false); + + private void EndOutdoorBoundsDrag(bool raiseChanged) + { + if (_outdoorDragMode == OutdoorDragMode.None) + { + return; + } + + _outdoorDragMode = OutdoorDragMode.None; + + if (raiseChanged && _previewBoundsMin is { } min && _previewBoundsMax is { } max) + { + OutdoorBoundsChanged?.Invoke(this, (min, max)); + } + + _previewBoundsMin = null; + _previewBoundsMax = null; + } + + // Half the heatmap grid spacing — a pointer within this distance of a sample's center is + // considered "over" that cell for tooltip purposes. + private const double HeatmapTooltipToleranceMeters = HeatmapCellMeters * 0.75; + + private void UpdateHeatmapTooltip(Point2D meters, Windows.Foundation.Point pointerPosition) + { + CoverageSample? nearest = null; + double nearestDistance = double.MaxValue; + foreach (var sample in _lastHeatmap) + { + double distance = sample.Position.DistanceTo(meters); + if (distance < nearestDistance) + { + nearestDistance = distance; + nearest = sample; + } + } + + if (nearest is not { } sampleFound || nearestDistance > HeatmapTooltipToleranceMeters) + { + HeatmapTooltip.Visibility = Visibility.Collapsed; + return; + } + + HeatmapTooltipText.Text = string.Format(System.Globalization.CultureInfo.CurrentCulture, "{0:0} dBm", sampleFound.ValueDbm); + Canvas.SetLeft(HeatmapTooltip, pointerPosition.X + 12); + Canvas.SetTop(HeatmapTooltip, pointerPosition.Y + 12); + HeatmapTooltip.Visibility = Visibility.Visible; + } + + /// Converts a floor-plan point to this control's own pixel space — used by + /// WorkspacePage to anchor the diagnostics flyout at the point the user actually clicked, + /// rather than at the control's default position. + public Windows.Foundation.Point ToScreenPoint(Point2D meters) + { + var (x, y) = ToPixels(meters); + return new Windows.Foundation.Point(x, y); + } + private (double X, double Y) ToPixels(Point2D meters) => ((meters.X - _bounds.MinX) * PixelsPerMeter, (meters.Y - _bounds.MinY) * PixelsPerMeter); @@ -344,8 +602,11 @@ private Bounds ComputeBounds(Floor floor) } // An outdoor area has no walls to size the canvas from — same explicit-bounds fallback - // FloorGrid uses for its candidate grid. - if (floor.IsOutdoor && floor.OutdoorBoundsMin is { } outdoorMin && floor.OutdoorBoundsMax is { } outdoorMax) + // FloorGrid uses for its candidate grid. Prefers the live drag preview (if any) so the + // canvas doesn't clip a rectangle being resized/moved past its last-committed extent. + if (floor.IsOutdoor + && (_previewBoundsMin ?? floor.OutdoorBoundsMin) is { } outdoorMin + && (_previewBoundsMax ?? floor.OutdoorBoundsMax) is { } outdoorMax) { xs.Add(outdoorMin.X); xs.Add(outdoorMax.X); diff --git a/src/Pulsemap.App/Controls/WorkspaceTool.cs b/src/Pulsemap.App/Controls/WorkspaceTool.cs index 84722da..b1299e2 100644 --- a/src/Pulsemap.App/Controls/WorkspaceTool.cs +++ b/src/Pulsemap.App/Controls/WorkspaceTool.cs @@ -7,4 +7,5 @@ public enum WorkspaceTool AddTestPoint, DrawWall, DeleteElement, + Diagnose, } diff --git a/src/Pulsemap.App/Converters/DiagnosticSeverityToBrushConverter.cs b/src/Pulsemap.App/Converters/DiagnosticSeverityToBrushConverter.cs new file mode 100644 index 0000000..b77ee9c --- /dev/null +++ b/src/Pulsemap.App/Converters/DiagnosticSeverityToBrushConverter.cs @@ -0,0 +1,20 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; +using Pulsemap.App.Core.Diagnostics; + +namespace Pulsemap.App.Converters; + +/// Maps a to one of App.xaml's design-token brushes — +/// kept as a converter (rather than a computed property on the ViewModel-layer display record) so +/// that record stays free of any WinUI dependency and constructible in a plain unit test. +public sealed class DiagnosticSeverityToBrushConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + string resourceKey = value is DiagnosticSeverity.Error ? "DangerBrush" : value is DiagnosticSeverity.Warning ? "WarningBrush" : "SuccessBrush"; + return (Brush)Application.Current.Resources[resourceKey]; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotSupportedException(); +} diff --git a/src/Pulsemap.App/Interop/NativeWlan.cs b/src/Pulsemap.App/Interop/NativeWlan.cs index 1039661..70003b4 100644 --- a/src/Pulsemap.App/Interop/NativeWlan.cs +++ b/src/Pulsemap.App/Interop/NativeWlan.cs @@ -5,12 +5,16 @@ namespace Pulsemap.App.Interop; /// /// Raw wlanapi.dll P/Invoke declarations and structs. Struct layouts verified against Microsoft /// Learn's wlanapi.h reference (WLAN_INTERFACE_INFO_LIST, WLAN_INTERFACE_INFO, WLAN_BSS_LIST, -/// WLAN_BSS_ENTRY, DOT11_SSID, WLAN_RATE_SET) rather than assumed — a wrong field order/size here -/// silently corrupts memory instead of failing loudly. WLAN_INTERFACE_INFO_LIST and WLAN_BSS_LIST -/// each end in a variable-length array of their entry struct; rather than modeling that array in -/// the struct itself, callers read the fixed header, then walk entries by pointer arithmetic using -/// of the entry type — the standard, safe pattern for this kind -/// of native "count + inline array" structure. +/// WLAN_BSS_ENTRY, DOT11_SSID, WLAN_RATE_SET, WLAN_CONNECTION_ATTRIBUTES, WLAN_ASSOCIATION_ATTRIBUTES) +/// rather than assumed — a wrong field order/size here silently corrupts memory instead of failing +/// loudly. WLAN_INTERFACE_INFO_LIST and WLAN_BSS_LIST each end in a variable-length array of their +/// entry struct; rather than modeling that array in the struct itself, callers read the fixed +/// header, then walk entries by pointer arithmetic using of the +/// entry type — the standard, safe pattern for this kind of native "count + inline array" structure. +/// WLAN_ASSOCIATION_ATTRIBUTES.ulRxRate/ulTxRate are documented by Microsoft's own sample only as +/// "the receiving/transmission rate" with no stated unit; confirmed against the community-maintained +/// ManagedNativeWifi wrapper (which real users have validated produces correct Mbps figures) that +/// the unit is plain Kbps, not the 100Kbps unit some other wlanapi rate fields use. /// internal static class NativeWlan { @@ -21,6 +25,10 @@ internal static class NativeWlan internal const uint ErrorSuccess = 0; internal const uint ErrorAccessDenied = 5; + // WLAN_INTF_OPCODE enum members used with WlanQueryInterface. + internal const uint OpcodeCurrentConnection = 7; + internal const uint OpcodeChannelNumber = 8; + [DllImport(WlanApiDll)] internal static extern uint WlanOpenHandle(uint dwClientVersion, IntPtr pReserved, out uint pdwNegotiatedVersion, out IntPtr phClientHandle); @@ -33,6 +41,16 @@ internal static class NativeWlan [DllImport(WlanApiDll)] internal static extern uint WlanScan(IntPtr hClientHandle, ref Guid pInterfaceGuid, IntPtr pDot11Ssid, IntPtr pIeData, IntPtr pReserved); + [DllImport(WlanApiDll)] + internal static extern uint WlanQueryInterface( + IntPtr hClientHandle, + ref Guid pInterfaceGuid, + uint opCode, + IntPtr pReserved, + out uint pdwDataSize, + out IntPtr ppData, + IntPtr pWlanOpcodeValueType); + [DllImport(WlanApiDll)] internal static extern uint WlanGetNetworkBssList( IntPtr hClientHandle, @@ -112,4 +130,92 @@ internal struct WLAN_BSS_LIST_HEADER public uint dwTotalSize; public uint dwNumberOfItems; } + + // Member names drop the wlanapi.h wlan_interface_state_* prefix (CA1712) — see the mapping in + // each comment for the native name. + internal enum WLAN_INTERFACE_STATE : uint + { + NotReady = 0, // wlan_interface_state_not_ready + Connected = 1, // wlan_interface_state_connected + AdHocNetworkFormed = 2, // wlan_interface_state_ad_hoc_network_formed + Disconnecting = 3, // wlan_interface_state_disconnecting + Disconnected = 4, // wlan_interface_state_disconnected + Associating = 5, // wlan_interface_state_associating + Discovering = 6, // wlan_interface_state_discovering + Authenticating = 7, // wlan_interface_state_authenticating + } + + internal enum WLAN_CONNECTION_MODE : uint + { + Profile = 0, // wlan_connection_mode_profile + TemporaryProfile = 1, // wlan_connection_mode_temporary_profile + DiscoverySecure = 2, // wlan_connection_mode_discovery_secure + DiscoveryUnsecure = 3, // wlan_connection_mode_discovery_unsecure + Auto = 4, // wlan_connection_mode_auto + Invalid = 5, // wlan_connection_mode_invalid + } + + internal enum DOT11_PHY_TYPE : uint + { + Unknown = 0, // dot11_phy_type_unknown + Fhss = 1, // dot11_phy_type_fhss + Dsss = 2, // dot11_phy_type_dsss + IrBaseband = 3, // dot11_phy_type_irbaseband + Ofdm = 4, // dot11_phy_type_ofdm + HrDsss = 5, // dot11_phy_type_hrdsss + Erp = 6, // dot11_phy_type_erp + Ht = 7, // dot11_phy_type_ht + Vht = 8, // dot11_phy_type_vht + Dmg = 9, // dot11_phy_type_dmg + He = 10, // dot11_phy_type_he + Eht = 11, // dot11_phy_type_eht + } + + [StructLayout(LayoutKind.Sequential)] + internal struct WLAN_ASSOCIATION_ATTRIBUTES + { + public DOT11_SSID dot11Ssid; + public uint dot11BssType; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 6)] + public byte[] dot11Bssid; + + public DOT11_PHY_TYPE dot11PhyType; + public uint uDot11PhyIndex; + public uint wlanSignalQuality; + public uint ulRxRate; + public uint ulTxRate; + } + + // WLAN_SECURITY_ATTRIBUTES follows wlanAssociationAttributes in WLAN_CONNECTION_ATTRIBUTES but + // none of its fields (security enabled/802.1X/auth+cipher algorithm) are needed here — modeled + // only so Marshal.PtrToStructure reads the right total size. + [StructLayout(LayoutKind.Sequential)] + internal struct WLAN_SECURITY_ATTRIBUTES + { + [MarshalAs(UnmanagedType.Bool)] + public bool bSecurityEnabled; + + [MarshalAs(UnmanagedType.Bool)] + public bool bOneXEnabled; + + public uint dot11AuthAlgorithm; + public uint dot11CipherAlgorithm; + } + + // WLAN_MAX_NAME_LENGTH, per wlanapi.h. + private const int WlanMaxNameLength = 256; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct WLAN_CONNECTION_ATTRIBUTES + { + public WLAN_INTERFACE_STATE isState; + public WLAN_CONNECTION_MODE wlanConnectionMode; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = WlanMaxNameLength)] + public string strProfileName; + + public WLAN_ASSOCIATION_ATTRIBUTES wlanAssociationAttributes; + public WLAN_SECURITY_ATTRIBUTES wlanSecurityAttributes; + } } diff --git a/src/Pulsemap.App/MainPage.xaml b/src/Pulsemap.App/MainPage.xaml index 5942439..f594b4d 100644 --- a/src/Pulsemap.App/MainPage.xaml +++ b/src/Pulsemap.App/MainPage.xaml @@ -26,6 +26,11 @@ + + + + + diff --git a/src/Pulsemap.App/MainPage.xaml.cs b/src/Pulsemap.App/MainPage.xaml.cs index b505450..18730d7 100644 --- a/src/Pulsemap.App/MainPage.xaml.cs +++ b/src/Pulsemap.App/MainPage.xaml.cs @@ -30,6 +30,9 @@ private void Nav_SelectionChanged(NavigationView sender, NavigationViewSelection case "Surveys": ContentFrame.Navigate(typeof(SurveysPage)); break; + case "Diagnose": + ContentFrame.Navigate(typeof(DiagnosticsPage)); + break; } } } diff --git a/src/Pulsemap.App/Pulsemap.App.csproj b/src/Pulsemap.App/Pulsemap.App.csproj index 6c3f4d2..58e07c1 100644 --- a/src/Pulsemap.App/Pulsemap.App.csproj +++ b/src/Pulsemap.App/Pulsemap.App.csproj @@ -58,6 +58,7 @@ --> + diff --git a/src/Pulsemap.App/Services/GitHubUpdateCheckService.cs b/src/Pulsemap.App/Services/GitHubUpdateCheckService.cs new file mode 100644 index 0000000..afade87 --- /dev/null +++ b/src/Pulsemap.App/Services/GitHubUpdateCheckService.cs @@ -0,0 +1,45 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Pulsemap.App.Core.Abstractions; +using Pulsemap.App.Core.Logging; +using Pulsemap.App.Core.Updates; + +namespace Pulsemap.App.Services; + +/// +/// Queries GitHub's public Releases API for DotifyBIZ/pulsemap's latest tag and compares it +/// against this build's own assembly version. A network/parse failure is treated the same as "no +/// update available" — this check must never surface an error to the user or block anything it's +/// called from. +/// +public sealed class GitHubUpdateCheckService(IHttpClientFactory httpClientFactory, IAppLogger logger) : IUpdateCheckService +{ + private const string ReleasesApiUrl = "https://api.github.com/repos/DotifyBIZ/pulsemap/releases/latest"; + private const string ReleasesPageUrl = "https://github.com/DotifyBIZ/pulsemap/releases/latest"; + + public async Task CheckForUpdateAsync(CancellationToken cancellationToken = default) + { + try + { + var client = httpClientFactory.CreateClient(nameof(GitHubUpdateCheckService)); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Pulsemap-App"); + var release = await client.GetFromJsonAsync(ReleasesApiUrl, cancellationToken); + + string currentVersion = typeof(GitHubUpdateCheckService).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + if (release?.TagName is not { } tag || !SemanticVersionComparer.IsNewer(currentVersion, tag)) + { + return UpdateCheckResult.NoUpdate; + } + + return new UpdateCheckResult(true, tag, ReleasesPageUrl); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or NotSupportedException) + { + await logger.LogWarningAsync($"Update check failed (treated as no update available): {ex.Message}", cancellationToken); + return UpdateCheckResult.NoUpdate; + } + } + + private sealed record GitHubRelease([property: JsonPropertyName("tag_name")] string? TagName); +} diff --git a/src/Pulsemap.App/Services/LocalizationService.cs b/src/Pulsemap.App/Services/LocalizationService.cs index 575e380..2279ed6 100644 --- a/src/Pulsemap.App/Services/LocalizationService.cs +++ b/src/Pulsemap.App/Services/LocalizationService.cs @@ -110,6 +110,62 @@ public string GetString(string key) ["WorkspaceSaveSnapshotDialogLabelHeader"] = "Label", ["WorkspaceSaveSnapshotDialogPrimaryButton"] = "Save", ["WorkspaceSaveSnapshotDialogCloseButton"] = "Cancel", + ["WorkspaceWallSelectionCountFormat"] = "Selected walls: {0}", + ["WorkspaceRecaptureDialogTitle"] = "Recapture this point?", + ["WorkspaceRecaptureDialogContent"] = "This replaces its existing reading with a new scan from here.", + ["WorkspaceRecaptureDialogPrimaryButton"] = "Recapture", + ["WorkspaceRecaptureDialogCloseButton"] = "Cancel", + ["WorkspaceUndoDeleteWallMessage"] = "Wall deleted.", + ["WorkspaceUndoDeleteTestPointMessage"] = "Test point deleted.", + ["WorkspaceUndoDeleteAccessPointMessage"] = "Access point deleted.", + ["WorkspaceDiagnosePredictedFormat"] = "Survey predicted {0:0.0} dBm here.", + ["WorkspaceDiagnoseNoPredictionDisplay"] = "No access points on this band reach this point in the survey model.", + ["WorkspaceReplaceSuggestionsDialogTitle"] = "Replace suggested access points?", + ["WorkspaceReplaceSuggestionsDialogContent"] = "This floor already has suggested access points. Running Suggest Placements again replaces them — any you've manually placed or adjusted are kept.", + ["WorkspaceReplaceSuggestionsDialogPrimaryButton"] = "Replace", + ["WorkspaceReplaceSuggestionsDialogCloseButton"] = "Cancel", + ["SnapshotComparisonDeleteDialogTitle"] = "Delete this snapshot?", + ["SnapshotComparisonDeleteDialogContentFormat"] = "Delete \"{0}\"? This can't be undone.", + ["SnapshotComparisonDeleteDialogPrimaryButton"] = "Delete", + ["SnapshotComparisonDeleteDialogCloseButton"] = "Cancel", + + ["HomeUpdateAvailableFormat"] = "Pulsemap {0} is available.", + ["HomeGreetingMorning"] = "Good morning", + ["HomeGreetingAfternoon"] = "Good afternoon", + ["HomeGreetingEvening"] = "Good evening", + ["HomeGreetingNight"] = "Hello", + ["HomeWellbeingMessage1"] = "Hope you're doing well today.", + ["HomeWellbeingMessage2"] = "Take a moment for yourself if you need one.", + ["HomeWellbeingMessage3"] = "However today's going, we hope it gets a little easier.", + ["HomeWellbeingMessage4"] = "Glad you're here. Take care of yourself out there.", + ["HomeWellbeingMessage5"] = "Remember to drink some water and stretch your legs.", + ["HomeWellbeingMessage6"] = "No rush today. One thing at a time.", + ["HomeWellbeingMessage7"] = "Hope something good happens for you today.", + ["HomeWellbeingMessage8"] = "You're doing better than you think.", + + ["DiagnosticNotConnected"] = "This adapter isn't connected to a network.", + ["DiagnosticNoIssuesFound"] = "No issues found — link and network health both look healthy.", + ["DiagnosticWeakSignal"] = "Signal is weak ({0}%) — expect reduced range and occasional slowdowns.", + ["DiagnosticVeryWeakSignal"] = "Signal is very weak ({0}%) — this is likely limiting your speed significantly.", + ["DiagnosticLegacyPhyRate"] = "Connected at only {0:0} Mbps on 5/6GHz — this looks like a fallback to a legacy rate rather than a real 802.11n/ac/ax/be link.", + ["DiagnosticDnsFailed"] = "DNS lookups are failing — this can look like \"no internet\" even with a strong WiFi link.", + ["DiagnosticSlowDns"] = "DNS lookups are slow ({0:0} ms) — pages may feel slow to start loading even with fast WiFi.", + ["DiagnosticHighGatewayPing"] = "Ping to your router is high ({0:0} ms) — this points to a problem on the local network, not necessarily your internet connection.", + ["DiagnosticGatewayUnreachable"] = "Your router didn't respond to a ping — it may be busy, blocking ping, or there's a local network problem.", + ["DiagnosticPredictedVsActualMismatch"] = "The actual signal here ({1:0.0} dBm) is notably weaker than the survey predicted ({0:0.0} dBm) — the environment may have changed since the survey.", + + ["DiagnosticsStartMonitoringButton"] = "Start monitoring", + ["DiagnosticsStopMonitoringButton"] = "Stop monitoring", + ["DiagnosticsNotConnectedDisplay"] = "Not connected", + ["DiagnosticsBandAndChannelFormat"] = "{0}, channel {1}", + ["DiagnosticsUnknownBandDisplay"] = "unknown band", + ["DiagnosticsLinkSummaryFormat"] = "{0}% signal · {1} · Rx {2:0} Mbps · Tx {3:0} Mbps", + ["DiagnosticsNetworkHealthSummaryFormat"] = "Gateway {0} · {1} · DNS {2}", + ["DiagnosticsPingMsFormat"] = "{0:0} ms ping", + ["DiagnosticsNoPingReplyDisplay"] = "no ping reply", + ["DiagnosticsDnsMsFormat"] = "{0:0} ms", + ["DiagnosticsDnsFailedDisplay"] = "failed", + ["DiagnosticsMbpsFormat"] = "{0:0} Mbps", }, ["pl-PL"] = new Dictionary { @@ -178,6 +234,62 @@ public string GetString(string key) ["WorkspaceSaveSnapshotDialogLabelHeader"] = "Etykieta", ["WorkspaceSaveSnapshotDialogPrimaryButton"] = "Zapisz", ["WorkspaceSaveSnapshotDialogCloseButton"] = "Anuluj", + ["WorkspaceWallSelectionCountFormat"] = "Zaznaczone ściany: {0}", + ["WorkspaceRecaptureDialogTitle"] = "Zarejestrować ten punkt ponownie?", + ["WorkspaceRecaptureDialogContent"] = "Zastąpi to jego dotychczasowy odczyt nowym skanem z tego miejsca.", + ["WorkspaceRecaptureDialogPrimaryButton"] = "Zarejestruj ponownie", + ["WorkspaceRecaptureDialogCloseButton"] = "Anuluj", + ["WorkspaceUndoDeleteWallMessage"] = "Usunięto ścianę.", + ["WorkspaceUndoDeleteTestPointMessage"] = "Usunięto punkt pomiarowy.", + ["WorkspaceUndoDeleteAccessPointMessage"] = "Usunięto punkt dostępowy.", + ["WorkspaceDiagnosePredictedFormat"] = "Pomiar przewidywał tutaj {0:0.0} dBm.", + ["WorkspaceDiagnoseNoPredictionDisplay"] = "Żaden punkt dostępowy na tym paśmie nie dociera do tego miejsca w modelu pomiaru.", + ["WorkspaceReplaceSuggestionsDialogTitle"] = "Zastąpić sugerowane punkty dostępowe?", + ["WorkspaceReplaceSuggestionsDialogContent"] = "To piętro ma już zasugerowane punkty dostępowe. Ponowne uruchomienie sugestii je zastąpi — punkty umieszczone lub zmienione ręcznie zostaną zachowane.", + ["WorkspaceReplaceSuggestionsDialogPrimaryButton"] = "Zastąp", + ["WorkspaceReplaceSuggestionsDialogCloseButton"] = "Anuluj", + ["SnapshotComparisonDeleteDialogTitle"] = "Usunąć ten zapis?", + ["SnapshotComparisonDeleteDialogContentFormat"] = "Usunąć „{0}”? Tej operacji nie można cofnąć.", + ["SnapshotComparisonDeleteDialogPrimaryButton"] = "Usuń", + ["SnapshotComparisonDeleteDialogCloseButton"] = "Anuluj", + + ["HomeUpdateAvailableFormat"] = "Dostępny jest Pulsemap {0}.", + ["HomeGreetingMorning"] = "Dzień dobry", + ["HomeGreetingAfternoon"] = "Dzień dobry", + ["HomeGreetingEvening"] = "Dobry wieczór", + ["HomeGreetingNight"] = "Witaj", + ["HomeWellbeingMessage1"] = "Mamy nadzieję, że wszystko u Ciebie dobrze.", + ["HomeWellbeingMessage2"] = "Jeśli potrzebujesz chwili dla siebie, zrób sobie przerwę.", + ["HomeWellbeingMessage3"] = "Niezależnie od tego, jak mija dzisiejszy dzień, mamy nadzieję, że będzie choć trochę lżej.", + ["HomeWellbeingMessage4"] = "Miło, że tu jesteś. Zadbaj o siebie.", + ["HomeWellbeingMessage5"] = "Pamiętaj, żeby napić się wody i trochę się rozciągnąć.", + ["HomeWellbeingMessage6"] = "Bez pośpiechu. Jedna rzecz na raz.", + ["HomeWellbeingMessage7"] = "Mamy nadzieję, że dzisiaj wydarzy się coś dobrego.", + ["HomeWellbeingMessage8"] = "Radzisz sobie lepiej, niż Ci się wydaje.", + + ["DiagnosticNotConnected"] = "Ten adapter nie jest połączony z żadną siecią.", + ["DiagnosticNoIssuesFound"] = "Nie znaleziono problemów — łącze i stan sieci wyglądają dobrze.", + ["DiagnosticWeakSignal"] = "Słaby sygnał ({0}%) — spodziewaj się mniejszego zasięgu i okazjonalnych spowolnień.", + ["DiagnosticVeryWeakSignal"] = "Bardzo słaby sygnał ({0}%) — to prawdopodobnie znacząco ogranicza prędkość.", + ["DiagnosticLegacyPhyRate"] = "Połączenie z prędkością tylko {0:0} Mb/s na 5/6GHz — wygląda na przejście na starszy tryb transmisji zamiast prawdziwego łącza 802.11n/ac/ax/be.", + ["DiagnosticDnsFailed"] = "Zapytania DNS kończą się niepowodzeniem — może to wyglądać jak „brak internetu”, mimo silnego sygnału WiFi.", + ["DiagnosticSlowDns"] = "Zapytania DNS są wolne ({0:0} ms) — strony mogą wydawać się wolno wczytywać nawet przy szybkim WiFi.", + ["DiagnosticHighGatewayPing"] = "Wysoki ping do routera ({0:0} ms) — wskazuje to na problem w sieci lokalnej, niekoniecznie z połączeniem internetowym.", + ["DiagnosticGatewayUnreachable"] = "Router nie odpowiedział na ping — może być zajęty, blokować ping, albo wystąpił problem w sieci lokalnej.", + ["DiagnosticPredictedVsActualMismatch"] = "Rzeczywisty sygnał w tym miejscu ({1:0.0} dBm) jest zauważalnie słabszy niż przewidywał pomiar ({0:0.0} dBm) — otoczenie mogło się zmienić od czasu pomiaru.", + + ["DiagnosticsStartMonitoringButton"] = "Rozpocznij monitorowanie", + ["DiagnosticsStopMonitoringButton"] = "Zatrzymaj monitorowanie", + ["DiagnosticsNotConnectedDisplay"] = "Niepołączono", + ["DiagnosticsBandAndChannelFormat"] = "{0}, kanał {1}", + ["DiagnosticsUnknownBandDisplay"] = "nieznane pasmo", + ["DiagnosticsLinkSummaryFormat"] = "Sygnał {0}% · {1} · Rx {2:0} Mb/s · Tx {3:0} Mb/s", + ["DiagnosticsNetworkHealthSummaryFormat"] = "Router {0} · {1} · DNS {2}", + ["DiagnosticsPingMsFormat"] = "ping {0:0} ms", + ["DiagnosticsNoPingReplyDisplay"] = "brak odpowiedzi", + ["DiagnosticsDnsMsFormat"] = "{0:0} ms", + ["DiagnosticsDnsFailedDisplay"] = "niepowodzenie", + ["DiagnosticsMbpsFormat"] = "{0:0} Mb/s", }, }; } diff --git a/src/Pulsemap.App/Services/NetworkHealthService.cs b/src/Pulsemap.App/Services/NetworkHealthService.cs new file mode 100644 index 0000000..9d38683 --- /dev/null +++ b/src/Pulsemap.App/Services/NetworkHealthService.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; +using System.Net.NetworkInformation; +using Pulsemap.App.Core.Abstractions; + +namespace Pulsemap.App.Services; + +/// +/// Local network health (gateway ping, DNS resolution timing) via pure BCL networking — no native +/// P/Invoke needed, unlike the WLAN-specific services in this folder. Every check here is local or +/// to the gateway/DNS the adapter is already configured to use; nothing here is a new outbound call +/// beyond what the OS's own network stack already does for the same adapter. +/// +public sealed class NetworkHealthService : INetworkHealthService +{ + private const int PingTimeoutMs = 2000; + private const string DnsProbeHostName = "www.msftconnecttest.com"; + + public async Task CheckHealthAsync(Guid adapterId, CancellationToken cancellationToken = default) + { + string? gatewayAddress = FindGatewayAddress(adapterId); + + double? pingMs = gatewayAddress is null ? null : await PingGatewayAsync(gatewayAddress, cancellationToken).ConfigureAwait(false); + var (dnsMs, dnsSucceeded) = await ResolveDnsAsync(cancellationToken).ConfigureAwait(false); + + return new NetworkHealthSnapshot(gatewayAddress, pingMs, dnsMs, dnsSucceeded); + } + + private static string? FindGatewayAddress(Guid adapterId) + { + foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) + { + if (!Guid.TryParse(nic.Id, out var nicId) || nicId != adapterId) + { + continue; + } + + var gateway = nic.GetIPProperties().GatewayAddresses.FirstOrDefault(g => g.Address is not null); + return gateway?.Address.ToString(); + } + + return null; + } + + private static async Task PingGatewayAsync(string gatewayAddress, CancellationToken cancellationToken) + { + try + { + using var ping = new Ping(); + var reply = await ping.SendPingAsync(gatewayAddress, PingTimeoutMs).WaitAsync(cancellationToken).ConfigureAwait(false); + return reply.Status == IPStatus.Success ? reply.RoundtripTime : null; + } + catch (PingException) + { + return null; + } + } + + private static async Task<(double? Ms, bool Succeeded)> ResolveDnsAsync(CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + try + { + _ = await System.Net.Dns.GetHostEntryAsync(DnsProbeHostName, cancellationToken).ConfigureAwait(false); + stopwatch.Stop(); + return (stopwatch.Elapsed.TotalMilliseconds, true); + } + catch (System.Net.Sockets.SocketException) + { + return (null, false); + } + } +} diff --git a/src/Pulsemap.App/Services/WlanLinkDiagnosticsService.cs b/src/Pulsemap.App/Services/WlanLinkDiagnosticsService.cs new file mode 100644 index 0000000..0c73afb --- /dev/null +++ b/src/Pulsemap.App/Services/WlanLinkDiagnosticsService.cs @@ -0,0 +1,139 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; +using Pulsemap.App.Core.Abstractions; +using Pulsemap.App.Core.Models; +using Pulsemap.App.Interop; + +namespace Pulsemap.App.Services; + +/// +/// Native wlanapi.dll-backed live link state (WlanQueryInterface, wlan_intf_opcode_current_connection). +/// Opens and closes a client handle per call, matching 's style — +/// diagnostics reads are user-triggered/infrequent, not hot-path. +/// +public sealed class WlanLinkDiagnosticsService : ILinkDiagnosticsService +{ + public Task GetCurrentLinkAsync(Guid adapterId, CancellationToken cancellationToken = default) => + Task.Run(() => GetCurrentLink(adapterId), cancellationToken); + + private static LinkDiagnosticsSnapshot GetCurrentLink(Guid adapterId) + { + uint openResult = NativeWlan.WlanOpenHandle(NativeWlan.ClientVersion, IntPtr.Zero, out _, out var clientHandle); + if (openResult != NativeWlan.ErrorSuccess) + { + return LinkDiagnosticsSnapshot.Disconnected; + } + + try + { + var interfaceGuid = adapterId; + uint result = NativeWlan.WlanQueryInterface( + clientHandle, ref interfaceGuid, NativeWlan.OpcodeCurrentConnection, IntPtr.Zero, out _, out var dataPtr, IntPtr.Zero); + + if (result != NativeWlan.ErrorSuccess || dataPtr == IntPtr.Zero) + { + // ERROR_INVALID_STATE (and any other failure) means "not connected" for this + // opcode, per WlanQueryInterface's own documented behavior — not a real error. + return LinkDiagnosticsSnapshot.Disconnected; + } + + try + { + var attributes = Marshal.PtrToStructure(dataPtr); + if (attributes.isState != NativeWlan.WLAN_INTERFACE_STATE.Connected) + { + return LinkDiagnosticsSnapshot.Disconnected; + } + + int channel = QueryChannelNumber(clientHandle, adapterId); + return ToSnapshot(attributes, channel); + } + finally + { + NativeWlan.WlanFreeMemory(dataPtr); + } + } + finally + { + _ = NativeWlan.WlanCloseHandle(clientHandle, IntPtr.Zero); + } + } + + // wlan_intf_opcode_channel_number returns a plain ULONG — WLAN_ASSOCIATION_ATTRIBUTES itself has + // no channel field (only uDot11PhyIndex, an index into the driver's internal rate table). + private static int QueryChannelNumber(IntPtr clientHandle, Guid adapterId) + { + var interfaceGuid = adapterId; + uint result = NativeWlan.WlanQueryInterface( + clientHandle, ref interfaceGuid, NativeWlan.OpcodeChannelNumber, IntPtr.Zero, out _, out var dataPtr, IntPtr.Zero); + + if (result != NativeWlan.ErrorSuccess || dataPtr == IntPtr.Zero) + { + return 0; + } + + try + { + return Marshal.ReadInt32(dataPtr); + } + finally + { + NativeWlan.WlanFreeMemory(dataPtr); + } + } + + private static LinkDiagnosticsSnapshot ToSnapshot(NativeWlan.WLAN_CONNECTION_ATTRIBUTES attributes, int channel) + { + var association = attributes.wlanAssociationAttributes; + int ssidLength = Math.Min((int)association.dot11Ssid.uSSIDLength, association.dot11Ssid.ucSSID.Length); + string ssid = Encoding.UTF8.GetString(association.dot11Ssid.ucSSID, 0, ssidLength); + string bssid = string.Join(":", association.dot11Bssid.Select(b => b.ToString("X2", CultureInfo.InvariantCulture))); + + return new LinkDiagnosticsSnapshot( + IsConnected: true, + Ssid: ssid, + Bssid: bssid, + Band: ClassifyBand(channel, association.dot11PhyType), + Channel: channel, + SignalPercent: (int)Math.Min(association.wlanSignalQuality, 100), + PhyType: DisplayPhyType(association.dot11PhyType), + RxLinkSpeedMbps: association.ulRxRate / 1000.0, + TxLinkSpeedMbps: association.ulTxRate / 1000.0); + } + + // Channel numbering alone is ambiguous for 6GHz (whose channels 1/5/9/13/... numerically overlap + // 2.4GHz's 1-14 range) — real disambiguation needs the center frequency, which this opcode + // doesn't expose (unlike WlanGetNetworkBssList's WLAN_BSS_ENTRY, used for the interference scan + // elsewhere in this app). VHT/OFDM PHY types only ever run on 5GHz, so those are unambiguous + // regardless of channel number; HE/EHT (WiFi 6/7, which do span all three bands) fall back to + // channel-range heuristics that are only reliable outside the numeric overlap. + private static Band? ClassifyBand(int channel, NativeWlan.DOT11_PHY_TYPE phyType) => phyType switch + { + NativeWlan.DOT11_PHY_TYPE.Ofdm or NativeWlan.DOT11_PHY_TYPE.Vht => Band.FiveGhz, + NativeWlan.DOT11_PHY_TYPE.Dsss or NativeWlan.DOT11_PHY_TYPE.HrDsss or NativeWlan.DOT11_PHY_TYPE.Erp => Band.TwoPointFourGhz, + _ => channel switch + { + >= 1 and <= 14 => Band.TwoPointFourGhz, + >= 36 and <= 177 => Band.FiveGhz, + > 177 => Band.SixGhz, + _ => null, + }, + }; + + private static string DisplayPhyType(NativeWlan.DOT11_PHY_TYPE phyType) => phyType switch + { + NativeWlan.DOT11_PHY_TYPE.Fhss => "FHSS", + NativeWlan.DOT11_PHY_TYPE.Dsss => "DSSS (802.11)", + NativeWlan.DOT11_PHY_TYPE.IrBaseband => "IR baseband", + NativeWlan.DOT11_PHY_TYPE.Ofdm => "OFDM (802.11a)", + NativeWlan.DOT11_PHY_TYPE.HrDsss => "HRDSSS (802.11b)", + NativeWlan.DOT11_PHY_TYPE.Erp => "ERP (802.11g)", + NativeWlan.DOT11_PHY_TYPE.Ht => "HT (802.11n)", + NativeWlan.DOT11_PHY_TYPE.Vht => "VHT (802.11ac)", + NativeWlan.DOT11_PHY_TYPE.Dmg => "DMG (802.11ad)", + NativeWlan.DOT11_PHY_TYPE.He => "HE (802.11ax / WiFi 6)", + NativeWlan.DOT11_PHY_TYPE.Eht => "EHT (802.11be / WiFi 7)", + _ => "Unknown", + }; +} diff --git a/src/Pulsemap.App/Strings/en-US/Resources.resw b/src/Pulsemap.App/Strings/en-US/Resources.resw index 07f29ff..15e5764 100644 --- a/src/Pulsemap.App/Strings/en-US/Resources.resw +++ b/src/Pulsemap.App/Strings/en-US/Resources.resw @@ -20,14 +20,14 @@ Surveys + + Diagnose + Settings - - Home - Your surveys, all in one place. @@ -37,6 +37,9 @@ Recent surveys + + View release + @@ -74,6 +77,15 @@ Open Logs Folder + + Updates + + + On launch, Pulsemap checks GitHub's public release list for a newer version — a single request with nothing about you or your surveys attached. Turn it off if you'd rather it never happen. + + + Check for updates on launch + @@ -266,6 +278,93 @@ Delete + + Diagnose + + + Tool Help + + + What each tool does + + + Select + + + Click a wall to add it to a multi-select for batch material/thickness editing. Click a test point to rescan and replace its reading. On an outdoor area, drag its edge to resize or drag inside it to move the whole area. + + + Add Test Point + + + Click anywhere on the canvas to place a manual test point without doing a full guided walk. + + + Draw Wall + + + Click one endpoint, then click the other to draw a wall between them. + + + Delete + + + Click near a wall, test point, or access point to remove it. An Undo option appears right after, until your next delete. + + + Diagnose + + + Click any point on the plan to compare this machine's live WiFi signal there against what the survey predicted. + + + Welcome to the Workspace + + + Select does different things depending on what you click: a wall, a test point, or an outdoor area's edge. The Tool Help button always explains this if you forget. + + + Next + + + Skip tour + + + Add Test Point + + + Click anywhere to drop a manual test point — or use the Adapter tab's guided walk to be led through suggested points instead. + + + Next + + + Skip tour + + + Draw Wall + + + Click one end, then the other, to draw a wall. Give it a material afterward with Select for a more accurate coverage prediction. + + + Next + + + Skip tour + + + Delete + + + Click near anything to remove it — an Undo option appears right after, so a misclick isn't permanent. + + + Got it + + + Skip tour + Back @@ -347,9 +446,24 @@ Confirm Arrival & Capture + + Skip + Cancel Walk + + Unspecified + + + Apply material + + + Clear selection + + + Undo + @@ -370,6 +484,9 @@ Band + + Delete + @@ -423,4 +540,25 @@ {0} — {1} + + Diagnose + + + Check this machine's live WiFi link and local network health — no survey needed. + + + Adapter + + + Run diagnostics + + + Link + + + Findings + + + Monitoring + diff --git a/src/Pulsemap.App/Strings/pl-PL/Resources.resw b/src/Pulsemap.App/Strings/pl-PL/Resources.resw index 2ee29de..c527836 100644 --- a/src/Pulsemap.App/Strings/pl-PL/Resources.resw +++ b/src/Pulsemap.App/Strings/pl-PL/Resources.resw @@ -20,14 +20,14 @@ Badania + + Diagnostyka + Ustawienia - - Strona główna - Wszystkie Twoje badania w jednym miejscu. @@ -37,6 +37,9 @@ Ostatnie badania + + Zobacz wydanie + @@ -74,6 +77,15 @@ Otwórz folder dzienników + + Aktualizacje + + + Przy uruchomieniu Pulsemap sprawdza publiczną listę wydań na GitHubie w poszukiwaniu nowszej wersji — pojedyncze żądanie bez żadnych danych o Tobie ani Twoich badaniach. Możesz to wyłączyć. + + + Sprawdzaj aktualizacje przy uruchomieniu + @@ -266,6 +278,93 @@ Usuń + + Diagnostyka + + + Pomoc dot. narzędzi + + + Co robi każde narzędzie + + + Zaznacz + + + Kliknij ścianę, aby dodać ją do zaznaczenia wielokrotnego w celu zbiorczej edycji materiału/grubości. Kliknij punkt pomiarowy, aby ponownie zeskanować i zastąpić jego odczyt. W obszarze zewnętrznym przeciągnij krawędź, aby zmienić rozmiar, lub przeciągnij wnętrze, aby przesunąć cały obszar. + + + Dodaj punkt pomiarowy + + + Kliknij dowolne miejsce na płótnie, aby ręcznie umieścić punkt pomiarowy bez pełnego pomiaru terenowego. + + + Rysuj ścianę + + + Kliknij jeden koniec, a następnie drugi, aby narysować między nimi ścianę. + + + Usuń + + + Kliknij w pobliżu ściany, punktu pomiarowego lub punktu dostępowego, aby go usunąć. Zaraz potem pojawi się opcja cofnięcia, ważna do kolejnego usunięcia. + + + Diagnostyka + + + Kliknij dowolny punkt na planie, aby porównać aktualny sygnał WiFi tego urządzenia z tym, co przewidział pomiar. + + + Witaj w Obszarze roboczym + + + Narzędzie Zaznacz robi różne rzeczy w zależności od tego, co klikniesz: ścianę, punkt pomiarowy lub krawędź obszaru zewnętrznego. Przycisk Pomoc dot. narzędzi zawsze to wyjaśni, gdybyś zapomniał. + + + Dalej + + + Pomiń + + + Dodaj punkt pomiarowy + + + Kliknij dowolne miejsce, aby umieścić ręczny punkt pomiarowy — albo skorzystaj z pomiaru terenowego w zakładce Adapter, aby zostać poprowadzonym przez sugerowane punkty. + + + Dalej + + + Pomiń + + + Rysuj ścianę + + + Kliknij jeden koniec, a potem drugi, aby narysować ścianę. Nadaj jej materiał później za pomocą Zaznacz, aby uzyskać dokładniejszą prognozę pokrycia. + + + Dalej + + + Pomiń + + + Usuń + + + Kliknij w pobliżu dowolnego elementu, aby go usunąć — zaraz potem pojawi się opcja cofnięcia, więc pomyłka nie jest trwała. + + + Rozumiem + + + Pomiń + Wstecz @@ -347,9 +446,24 @@ Potwierdź przybycie i zarejestruj + + Pomiń + Anuluj pomiar + + Nieokreślony + + + Zastosuj materiał + + + Wyczyść zaznaczenie + + + Cofnij + @@ -370,6 +484,9 @@ Pasmo + + Usuń + @@ -423,4 +540,25 @@ {0} — {1} + + Diagnostyka + + + Sprawdź aktualne połączenie WiFi tego urządzenia i stan sieci lokalnej — bez potrzeby pomiaru. + + + Adapter + + + Uruchom diagnostykę + + + Połączenie + + + Wyniki + + + Monitorowanie + diff --git a/src/Pulsemap.App/ViewModels/DiagnosticFindingDisplay.cs b/src/Pulsemap.App/ViewModels/DiagnosticFindingDisplay.cs new file mode 100644 index 0000000..564df35 --- /dev/null +++ b/src/Pulsemap.App/ViewModels/DiagnosticFindingDisplay.cs @@ -0,0 +1,10 @@ +using Pulsemap.App.Core.Diagnostics; + +namespace Pulsemap.App.ViewModels; + +/// A with its message key already resolved to display text +/// — is the only place that has both the localization service and +/// the finding's format args at hand. Deliberately has no WinUI dependency (unlike a Brush-typed +/// property would) so it stays constructible in a plain unit test — +/// resolves to a design-token brush itself, via a converter. +public sealed record DiagnosticFindingDisplay(DiagnosticSeverity Severity, string Message); diff --git a/src/Pulsemap.App/ViewModels/DiagnosticsViewModel.cs b/src/Pulsemap.App/ViewModels/DiagnosticsViewModel.cs new file mode 100644 index 0000000..323c206 --- /dev/null +++ b/src/Pulsemap.App/ViewModels/DiagnosticsViewModel.cs @@ -0,0 +1,219 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Pulsemap.App.Core.Abstractions; +using Pulsemap.App.Core.Diagnostics; +using Pulsemap.App.Services; + +namespace Pulsemap.App.ViewModels; + +/// Standalone "Swiss army knife" WLAN troubleshooting — no survey required. Reads this +/// machine's own live link state, checks local network health, and runs both through +/// . Workspace's own diagnostics (Phase 2, not this class) adds +/// a predicted-vs-actual comparison once a survey's propagation model is available. +public sealed partial class DiagnosticsViewModel( + IWlanAdapterService wlanAdapterService, + ILinkDiagnosticsService linkDiagnosticsService, + INetworkHealthService networkHealthService, + ILocalizationService localizationService) : ObservableObject, IDisposable +{ + private static readonly TimeSpan MonitoringInterval = TimeSpan.FromSeconds(5); + + private CancellationTokenSource? _monitoringCts; + + public ObservableCollection Adapters { get; } = []; + + public ObservableCollection Findings { get; } = []; + + public ObservableCollection MonitoringSamples { get; } = []; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(RunDiagnosticsCommand))] + [NotifyCanExecuteChangedFor(nameof(ToggleMonitoringCommand))] + public partial NetworkAdapterInfo? SelectedAdapter { get; set; } + + [ObservableProperty] + public partial bool IsRunning { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ToggleMonitoringButtonText))] + public partial bool IsMonitoring { get; set; } + + [ObservableProperty] + public partial bool HasResult { get; set; } + + [ObservableProperty] + public partial string? SsidDisplay { get; set; } + + [ObservableProperty] + public partial string? BandAndChannelDisplay { get; set; } + + [ObservableProperty] + public partial string? LinkSummaryDisplay { get; set; } + + [ObservableProperty] + public partial string? NetworkHealthSummaryDisplay { get; set; } + + public string ToggleMonitoringButtonText => localizationService.GetString(IsMonitoring ? "DiagnosticsStopMonitoringButton" : "DiagnosticsStartMonitoringButton"); + + [RelayCommand] + private async Task LoadAdaptersAsync(CancellationToken cancellationToken) + { + var adapters = await wlanAdapterService.GetAdaptersAsync(cancellationToken); + Adapters.Clear(); + foreach (var adapter in adapters) + { + Adapters.Add(adapter); + } + + SelectedAdapter = Adapters.Count > 0 ? Adapters[0] : null; + } + + private bool CanRunDiagnostics() => SelectedAdapter is not null && !IsRunning; + + [RelayCommand(CanExecute = nameof(CanRunDiagnostics))] + private async Task RunDiagnosticsAsync(CancellationToken cancellationToken) + { + if (SelectedAdapter is not { } adapter) + { + return; + } + + IsRunning = true; + try + { + var (link, health) = await CaptureAsync(adapter.Id, cancellationToken); + ApplySnapshot(link, health); + } + finally + { + IsRunning = false; + } + } + + private async Task<(LinkDiagnosticsSnapshot Link, NetworkHealthSnapshot Health)> CaptureAsync(Guid adapterId, CancellationToken cancellationToken) + { + var link = await linkDiagnosticsService.GetCurrentLinkAsync(adapterId, cancellationToken); + var health = link.IsConnected + ? await networkHealthService.CheckHealthAsync(adapterId, cancellationToken) + : NetworkHealthSnapshot.Unavailable; + + return (link, health); + } + + private void ApplySnapshot(LinkDiagnosticsSnapshot link, NetworkHealthSnapshot health) + { + HasResult = true; + SsidDisplay = link.IsConnected ? link.Ssid : localizationService.GetString("DiagnosticsNotConnectedDisplay"); + BandAndChannelDisplay = link.IsConnected + ? string.Format(CultureInfo.CurrentCulture, localizationService.GetString("DiagnosticsBandAndChannelFormat"), BandDisplayName(link.Band), link.Channel) + : null; + LinkSummaryDisplay = link.IsConnected + ? string.Format(CultureInfo.CurrentCulture, localizationService.GetString("DiagnosticsLinkSummaryFormat"), link.SignalPercent, link.PhyType, link.RxLinkSpeedMbps, link.TxLinkSpeedMbps) + : null; + NetworkHealthSummaryDisplay = BuildNetworkHealthSummary(health); + + var findings = LinkDiagnosticsAnalyzer.Analyze(link, health); + Findings.Clear(); + foreach (var finding in findings) + { + Findings.Add(new DiagnosticFindingDisplay(finding.Severity, FormatFinding(finding))); + } + } + + private string? BuildNetworkHealthSummary(NetworkHealthSnapshot health) + { + if (health.GatewayAddress is null) + { + return null; + } + + string pingDisplay = health.GatewayPingMs is { } pingMs + ? string.Format(CultureInfo.CurrentCulture, localizationService.GetString("DiagnosticsPingMsFormat"), pingMs) + : localizationService.GetString("DiagnosticsNoPingReplyDisplay"); + string dnsDisplay = health.DnsSucceeded && health.DnsResolutionMs is { } dnsMs + ? string.Format(CultureInfo.CurrentCulture, localizationService.GetString("DiagnosticsDnsMsFormat"), dnsMs) + : localizationService.GetString("DiagnosticsDnsFailedDisplay"); + + return string.Format(CultureInfo.CurrentCulture, localizationService.GetString("DiagnosticsNetworkHealthSummaryFormat"), health.GatewayAddress, pingDisplay, dnsDisplay); + } + + private string FormatFinding(DiagnosticFinding finding) + { + string template = localizationService.GetString(finding.MessageKey); + return finding.FormatArgs is null ? template : string.Format(CultureInfo.CurrentCulture, template, [.. finding.FormatArgs]); + } + + private string BandDisplayName(Core.Models.Band? band) => band switch + { + Core.Models.Band.TwoPointFourGhz => "2.4 GHz", + Core.Models.Band.FiveGhz => "5 GHz", + Core.Models.Band.SixGhz => "6 GHz", + _ => localizationService.GetString("DiagnosticsUnknownBandDisplay"), + }; + + private bool CanToggleMonitoring() => SelectedAdapter is not null; + + [RelayCommand(CanExecute = nameof(CanToggleMonitoring))] + private void ToggleMonitoring() + { + if (IsMonitoring) + { + StopMonitoring(); + } + else + { + StartMonitoring(); + } + } + + private void StartMonitoring() + { + if (SelectedAdapter is not { } adapter || IsMonitoring) + { + return; + } + + MonitoringSamples.Clear(); + _monitoringCts = new CancellationTokenSource(); + IsMonitoring = true; + _ = MonitorLoopAsync(adapter.Id, _monitoringCts.Token); + } + + public void StopMonitoring() + { + _monitoringCts?.Cancel(); + _monitoringCts?.Dispose(); + _monitoringCts = null; + IsMonitoring = false; + } + + private async Task MonitorLoopAsync(Guid adapterId, CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(MonitoringInterval); + try + { + do + { + var (link, health) = await CaptureAsync(adapterId, cancellationToken); + MonitoringSamples.Add(new MonitoringSampleDisplay( + DateTimeOffset.Now.ToString("T", CultureInfo.CurrentCulture), + string.Format(CultureInfo.CurrentCulture, "{0}%", link.SignalPercent), + string.Format(CultureInfo.CurrentCulture, localizationService.GetString("DiagnosticsMbpsFormat"), link.RxLinkSpeedMbps), + health.GatewayPingMs is { } pingMs ? string.Format(CultureInfo.CurrentCulture, localizationService.GetString("DiagnosticsPingMsFormat"), pingMs) : localizationService.GetString("DiagnosticsNoPingReplyDisplay"))); + } + while (await timer.WaitForNextTickAsync(cancellationToken)); + } + catch (OperationCanceledException) + { + // Expected when StopMonitoring cancels the token — not an error. + } + } + + public void Dispose() + { + _monitoringCts?.Cancel(); + _monitoringCts?.Dispose(); + } +} diff --git a/src/Pulsemap.App/ViewModels/HomeViewModel.cs b/src/Pulsemap.App/ViewModels/HomeViewModel.cs index 0a20002..4017160 100644 --- a/src/Pulsemap.App/ViewModels/HomeViewModel.cs +++ b/src/Pulsemap.App/ViewModels/HomeViewModel.cs @@ -1,20 +1,61 @@ using System.Collections.ObjectModel; +using System.Globalization; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Pulsemap.App.Core.Abstractions; +using Pulsemap.App.Core.Settings; using Pulsemap.App.Services; +using Windows.System; namespace Pulsemap.App.ViewModels; -public partial class HomeViewModel(ISurveyLibraryService surveyLibraryService) : ObservableObject +public partial class HomeViewModel( + ISurveyLibraryService surveyLibraryService, + IUpdateCheckService updateCheckService, + IAppSettingsService appSettingsService, + ILocalizationService localizationService) : ObservableObject { // Home is a dashboard, not the library — the Surveys nav tab shows the full, unlimited list. private const int RecentSurveysLimit = 3; + // A quiet, non-interactive touch — no reply expected or possible, just a small sign the app + // isn't purely transactional. Picked once per Home visit (HomeViewModel is transient, so a + // fresh navigation gets a fresh pick) rather than rotated on a timer or tracked across visits. + private static readonly string[] WellbeingMessageKeys = + [ + "HomeWellbeingMessage1", + "HomeWellbeingMessage2", + "HomeWellbeingMessage3", + "HomeWellbeingMessage4", + "HomeWellbeingMessage5", + "HomeWellbeingMessage6", + "HomeWellbeingMessage7", + "HomeWellbeingMessage8", + ]; + + private string? _releaseUrl; + public ObservableCollection Surveys { get; } = []; + public string GreetingDisplay => DateTime.Now.Hour switch + { + >= 5 and < 12 => localizationService.GetString("HomeGreetingMorning"), + >= 12 and < 17 => localizationService.GetString("HomeGreetingAfternoon"), + >= 17 and < 22 => localizationService.GetString("HomeGreetingEvening"), + _ => localizationService.GetString("HomeGreetingNight"), + }; + + public string WellbeingMessageDisplay { get; } = localizationService.GetString(WellbeingMessageKeys[Random.Shared.Next(WellbeingMessageKeys.Length)]); + [ObservableProperty] public partial bool IsLoading { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasUpdateAvailable))] + public partial string? UpdateBannerMessage { get; set; } + + public bool HasUpdateAvailable => UpdateBannerMessage is not null; + [RelayCommand] private async Task LoadAsync() { @@ -27,10 +68,37 @@ private async Task LoadAsync() { Surveys.Add(summary); } + + await CheckForUpdateAsync(); } finally { IsLoading = false; } } + + private async Task CheckForUpdateAsync() + { + var settings = await appSettingsService.LoadAsync(); + if (!settings.CheckForUpdatesEnabled) + { + return; + } + + var result = await updateCheckService.CheckForUpdateAsync(); + if (result.IsUpdateAvailable) + { + UpdateBannerMessage = string.Format(CultureInfo.CurrentCulture, localizationService.GetString("HomeUpdateAvailableFormat"), result.LatestVersion); + _releaseUrl = result.ReleaseUrl; + } + } + + [RelayCommand] + private async Task OpenReleaseAsync() + { + if (_releaseUrl is { } url) + { + await Launcher.LaunchUriAsync(new Uri(url)); + } + } } diff --git a/src/Pulsemap.App/ViewModels/MonitoringSampleDisplay.cs b/src/Pulsemap.App/ViewModels/MonitoringSampleDisplay.cs new file mode 100644 index 0000000..e5c7e50 --- /dev/null +++ b/src/Pulsemap.App/ViewModels/MonitoringSampleDisplay.cs @@ -0,0 +1,6 @@ +namespace Pulsemap.App.ViewModels; + +/// One row of continuous-monitoring output — deliberately a flat display record (not the +/// raw Core snapshots) so can bind a plain ListView table +/// with no value converters. +public sealed record MonitoringSampleDisplay(string TimestampDisplay, string SignalPercentDisplay, string RxLinkSpeedDisplay, string GatewayPingDisplay); diff --git a/src/Pulsemap.App/ViewModels/SettingsViewModel.cs b/src/Pulsemap.App/ViewModels/SettingsViewModel.cs index d1f9c77..588d8a0 100644 --- a/src/Pulsemap.App/ViewModels/SettingsViewModel.cs +++ b/src/Pulsemap.App/ViewModels/SettingsViewModel.cs @@ -1,5 +1,7 @@ +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Pulsemap.App.Core.Logging; +using Pulsemap.App.Core.Settings; using Pulsemap.App.Services; using Windows.System; @@ -7,7 +9,7 @@ namespace Pulsemap.App.ViewModels; /// Pulsemap follows Windows' own display language setting rather than offering an /// in-app override — see ILocalizationService for why. -public sealed partial class SettingsViewModel(ILocalizationService localizationService, IAppLogger logger) +public sealed partial class SettingsViewModel(ILocalizationService localizationService, IAppLogger logger, IAppSettingsService appSettingsService) : ObservableObject { public string CurrentLanguageDisplay => string.Format(System.Globalization.CultureInfo.CurrentCulture, localizationService.GetString("SettingsCurrentLanguageFormat"), localizationService.CurrentLanguage); @@ -34,4 +36,30 @@ private async Task OpenLogsFolderAsync() await logger.LogErrorAsync("Failed to open the logs folder.", ex); } } + + // Pulsemap's only network call (see docs/adr/0004-update-check-network-call.md) — a version + // check against GitHub, on by default but visibly disableable here. + private bool _suppressSave; + + [ObservableProperty] + public partial bool CheckForUpdatesEnabled { get; set; } = true; + + [RelayCommand] + private async Task LoadAsync() + { + var settings = await appSettingsService.LoadAsync(); + _suppressSave = true; + CheckForUpdatesEnabled = settings.CheckForUpdatesEnabled; + _suppressSave = false; + } + + partial void OnCheckForUpdatesEnabledChanged(bool value) + { + if (_suppressSave) + { + return; + } + + _ = appSettingsService.SaveAsync(new AppSettings { CheckForUpdatesEnabled = value }); + } } diff --git a/src/Pulsemap.App/ViewModels/SnapshotComparisonViewModel.cs b/src/Pulsemap.App/ViewModels/SnapshotComparisonViewModel.cs index 3fe8daf..3c9dfcf 100644 --- a/src/Pulsemap.App/ViewModels/SnapshotComparisonViewModel.cs +++ b/src/Pulsemap.App/ViewModels/SnapshotComparisonViewModel.cs @@ -1,6 +1,8 @@ using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using Pulsemap.App.Core.Interpolation; using Pulsemap.App.Core.Models; +using Pulsemap.App.Core.Persistence; using Pulsemap.App.Core.Propagation; namespace Pulsemap.App.ViewModels; @@ -10,10 +12,12 @@ namespace Pulsemap.App.ViewModels; /// canvas. Heatmaps are recomputed live from whichever floor/interference data that side's state /// actually had — nothing is cached from snapshot time, since the propagation engine is pure and /// deterministic. -public sealed partial class SnapshotComparisonViewModel(IPropagationModel propagationModel) : ObservableObject +public sealed partial class SnapshotComparisonViewModel(IPropagationModel propagationModel, ISurveyFileService surveyFileService) : ObservableObject { private const double HeatmapGridSpacingMeters = 0.5; + private string? _filePath; + /// Fires whenever either side's resolved floor or heatmap changes — the page re-renders /// both canvases in response, mirroring WorkspaceViewModel's FloorChanged. public event EventHandler? Changed; @@ -27,9 +31,13 @@ public sealed partial class SnapshotComparisonViewModel(IPropagationModel propag public IReadOnlyList AvailableBands => Survey?.TargetBands ?? []; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanDeleteLeftSnapshot))] + [NotifyCanExecuteChangedFor(nameof(DeleteLeftSnapshotCommand))] public partial SnapshotOption? LeftOption { get; set; } [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanDeleteRightSnapshot))] + [NotifyCanExecuteChangedFor(nameof(DeleteRightSnapshotCommand))] public partial SnapshotOption? RightOption { get; set; } [ObservableProperty] @@ -46,9 +54,29 @@ public sealed partial class SnapshotComparisonViewModel(IPropagationModel propag public IReadOnlyList RightHeatmap { get; private set; } = []; - public void Initialize(Survey survey) + /// "Current" (SnapshotId null) isn't a real, deletable snapshot — only an actual saved + /// is. + public bool CanDeleteLeftSnapshot => LeftOption?.SnapshotId is not null; + + public bool CanDeleteRightSnapshot => RightOption?.SnapshotId is not null; + + public void Initialize(Survey survey, string filePath) { Survey = survey; + _filePath = filePath; + + RefreshOptions(); + + SelectedFloor = survey.Floors.Count > 0 ? survey.Floors[0] : null; + SelectedBand = survey.TargetBands.Count > 0 ? survey.TargetBands[0] : Band.TwoPointFourGhz; + } + + private void RefreshOptions(Guid? preferredLeftId = null, Guid? preferredRightId = null) + { + if (Survey is not { } survey) + { + return; + } List options = [ @@ -61,10 +89,30 @@ public void Initialize(Survey survey) OnPropertyChanged(nameof(Floors)); OnPropertyChanged(nameof(AvailableBands)); - LeftOption = options.Count > 0 ? options[0] : null; - RightOption = options.Count > 0 ? options[0] : null; - SelectedFloor = survey.Floors.Count > 0 ? survey.Floors[0] : null; - SelectedBand = survey.TargetBands.Count > 0 ? survey.TargetBands[0] : Band.TwoPointFourGhz; + LeftOption = options.FirstOrDefault(o => o.SnapshotId == preferredLeftId) ?? (options.Count > 0 ? options[0] : null); + RightOption = options.FirstOrDefault(o => o.SnapshotId == preferredRightId) ?? (options.Count > 0 ? options[0] : null); + } + + [RelayCommand(CanExecute = nameof(CanDeleteLeftSnapshot))] + private async Task DeleteLeftSnapshotAsync() => await DeleteSnapshotAsync(LeftOption, keepRightId: RightOption?.SnapshotId, keepLeftId: null); + + [RelayCommand(CanExecute = nameof(CanDeleteRightSnapshot))] + private async Task DeleteRightSnapshotAsync() => await DeleteSnapshotAsync(RightOption, keepRightId: null, keepLeftId: LeftOption?.SnapshotId); + + private async Task DeleteSnapshotAsync(SnapshotOption? toDelete, Guid? keepLeftId, Guid? keepRightId) + { + if (Survey is not { } survey || _filePath is null || toDelete?.SnapshotId is not { } snapshotId) + { + return; + } + + survey.Snapshots.RemoveAll(s => s.Id == snapshotId); + await surveyFileService.SaveAsync(survey, _filePath); + + // The side that wasn't just deleted keeps pointing at whatever it already had (falling back + // to "Current" only if that snapshot was also removed, e.g. both sides had it selected); the + // deleted side always falls back to "Current" via RefreshOptions' own null-preferred-id default. + RefreshOptions(preferredLeftId: keepLeftId, preferredRightId: keepRightId); } partial void OnLeftOptionChanged(SnapshotOption? value) => Recompute(); diff --git a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs index 10cd7e5..3669437 100644 --- a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs +++ b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs @@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Pulsemap.App.Core.Abstractions; +using Pulsemap.App.Core.Diagnostics; using Pulsemap.App.Core.Export; using Pulsemap.App.Core.Interpolation; using Pulsemap.App.Core.Logging; @@ -11,6 +12,7 @@ using Pulsemap.App.Core.Persistence; using Pulsemap.App.Core.Placement; using Pulsemap.App.Core.Propagation; +using Pulsemap.App.Core.Settings; using Pulsemap.App.Services; using Windows.System; @@ -28,9 +30,8 @@ public partial class WorkspaceViewModel : ObservableObject private const double HeatmapGridSpacingMeters = 0.5; private const double DeleteHitToleranceMeters = 0.5; - // A new outdoor area's extent, since there's no dedicated bounds-editing UI yet — the user can - // redraw/relocate what it covers by editing OutdoorBoundsMin/Max later if this default doesn't - // fit (e.g. via re-export/re-import), a known limitation of this first pass at outdoor areas. + // A new outdoor area's starting extent — resizable/movable afterward via the canvas's drag + // handle (see FloorPlanCanvas/UpdateOutdoorBoundsAsync), so this is just a reasonable default. private const double DefaultOutdoorBoundsSizeMeters = 40; private readonly ISurveyFileService _surveyFileService; @@ -43,9 +44,14 @@ public partial class WorkspaceViewModel : ObservableObject private readonly IReportExporter _reportExporter; private readonly ISurveyExportFilePickerService _exportFilePickerService; private readonly IKrigingInterpolator _krigingInterpolator; + private readonly IAppSettingsService _appSettingsService; + private readonly ILinkDiagnosticsService _linkDiagnosticsService; + private readonly INetworkHealthService _networkHealthService; private string? _filePath; + public string? FilePath => _filePath; + public WorkspaceViewModel( ISurveyFileService surveyFileService, IPropagationModel propagationModel, @@ -56,7 +62,10 @@ public WorkspaceViewModel( ISurveyDataExporter surveyDataExporter, IReportExporter reportExporter, ISurveyExportFilePickerService exportFilePickerService, - IKrigingInterpolator krigingInterpolator) + IKrigingInterpolator krigingInterpolator, + IAppSettingsService appSettingsService, + ILinkDiagnosticsService linkDiagnosticsService, + INetworkHealthService networkHealthService) { _surveyFileService = surveyFileService; _propagationModel = propagationModel; @@ -68,6 +77,9 @@ public WorkspaceViewModel( _reportExporter = reportExporter; _exportFilePickerService = exportFilePickerService; _krigingInterpolator = krigingInterpolator; + _appSettingsService = appSettingsService; + _linkDiagnosticsService = linkDiagnosticsService; + _networkHealthService = networkHealthService; } public event EventHandler? FloorChanged; @@ -107,6 +119,17 @@ public WorkspaceViewModel( public string SurveyNameDisplay => Survey?.Name ?? string.Empty; + /// Same survey-type summary the wizard shows on its final step — repeated here since + /// nothing in Workspace otherwise reminds a user which mode ("new deployment" vs. "existing + /// network audit") their survey is in once they've left the wizard. + public string SurveyTypeDisplay => Survey switch + { + null => string.Empty, + { Type: SurveyType.NewDeployment } => _localizationService.GetString("WizardSurveyTypeSummaryNewDeployment"), + { TargetNetworkSsid: null or "" } => _localizationService.GetString("WizardSurveyTypeSummaryExistingAuditNoSsid"), + _ => string.Format(CultureInfo.CurrentCulture, _localizationService.GetString("WizardSurveyTypeSummaryExistingAuditWithSsidFormat"), Survey.TargetNetworkSsid), + }; + public string CoveragePercentDisplay => string.Format(CultureInfo.CurrentCulture, _localizationService.GetString("WorkspaceCoveragePercentFormat"), CoveragePercent); @@ -176,6 +199,7 @@ public string AccessPointSummaryDisplay [NotifyCanExecuteChangedFor(nameof(StartGuidedWalkCommand))] [NotifyCanExecuteChangedFor(nameof(ConfirmWalkPointCommand))] [NotifyCanExecuteChangedFor(nameof(CancelGuidedWalkCommand))] + [NotifyCanExecuteChangedFor(nameof(SkipWalkPointCommand))] public partial bool IsGuidedWalkActive { get; set; } public bool IsGuidedWalkIdle => !IsGuidedWalkActive; @@ -187,6 +211,7 @@ public string AccessPointSummaryDisplay [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConfirmWalkPointCommand))] + [NotifyCanExecuteChangedFor(nameof(SkipWalkPointCommand))] public partial bool IsCapturingWalkPoint { get; set; } [ObservableProperty] @@ -205,6 +230,39 @@ public string AccessPointSummaryDisplay point.Y) : _localizationService.GetString("WorkspaceGuidedWalkNotWalking"); + // Points still queued (after the current one) in an active guided walk, for the canvas to + // show as upcoming-point markers. + public IReadOnlyList RemainingWalkPoints => IsGuidedWalkActive ? [.. _guidedWalkQueue.Skip(1)] : []; + + // Wall material editing: the Select tool toggles walls in and out of this set by clicking + // them, then a batch action applies one material/thickness to everything selected. + private readonly List _selectedWalls = []; + + public IReadOnlyList SelectedWalls => _selectedWalls; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasWallSelection))] + [NotifyPropertyChangedFor(nameof(WallSelectionCountDisplay))] + public partial int SelectedWallCount { get; set; } + + public bool HasWallSelection => SelectedWallCount > 0; + + public string WallSelectionCountDisplay => + string.Format(CultureInfo.CurrentCulture, _localizationService.GetString("WorkspaceWallSelectionCountFormat"), SelectedWallCount); + + public async Task ShouldShowOnboardingAsync() + { + var settings = await _appSettingsService.LoadAsync(); + return !settings.HasSeenWorkspaceOnboarding; + } + + public async Task MarkOnboardingSeenAsync() + { + var settings = await _appSettingsService.LoadAsync(); + settings.HasSeenWorkspaceOnboarding = true; + await _appSettingsService.SaveAsync(settings); + } + public async Task LoadAsync(string filePath, CancellationToken cancellationToken = default) { _filePath = filePath; @@ -217,6 +275,7 @@ public async Task LoadAsync(string filePath, CancellationToken cancellationToken SelectedBand = AvailableBands.Count > 0 ? AvailableBands[0] : Band.TwoPointFourGhz; SelectedFloor = Survey.Floors.Count > 0 ? Survey.Floors[0] : null; OnPropertyChanged(nameof(SurveyNameDisplay)); + OnPropertyChanged(nameof(SurveyTypeDisplay)); OnPropertyChanged(nameof(AvailableBands)); OnPropertyChanged(nameof(Floors)); OnPropertyChanged(nameof(AccessPointSummaryDisplay)); @@ -308,7 +367,7 @@ private static async Task OpenLocationSettingsAsync() => private bool CanStartGuidedWalk() => !IsGuidedWalkActive && SelectedAdapter is not null && Survey is not null && SelectedFloor is not null; [RelayCommand(CanExecute = nameof(CanStartGuidedWalk))] - private void StartGuidedWalk() + private async Task StartGuidedWalkAsync() { if (Survey is null || SelectedFloor is null) { @@ -326,6 +385,10 @@ private void StartGuidedWalk() _guidedWalkTotalPoints = points.Count; GuidedWalkStatusMessage = null; IsGuidedWalkActive = true; + SelectedFloor.PendingGuidedWalkPoints.Clear(); + SelectedFloor.PendingGuidedWalkPoints.AddRange(points); + SelectedFloor.PendingGuidedWalkBand = SelectedBand; + await SaveAndRefreshAsync(); AdvanceGuidedWalk(); } @@ -352,9 +415,11 @@ private async Task ConfirmWalkPointAsync() var testPoint = TestPointCapture.BuildTestPoint(position, scanResult, Survey, SelectedAdapter.Name, DateTimeOffset.Now); SelectedFloor.TestPoints.Add(testPoint); + _guidedWalkQueue.Dequeue(); + SelectedFloor.PendingGuidedWalkPoints.Clear(); + SelectedFloor.PendingGuidedWalkPoints.AddRange(_guidedWalkQueue); await SaveAndRefreshAsync(); - _guidedWalkQueue.Dequeue(); AdvanceGuidedWalk(); } finally @@ -366,15 +431,39 @@ private async Task ConfirmWalkPointAsync() private bool CanCancelGuidedWalk() => IsGuidedWalkActive; [RelayCommand(CanExecute = nameof(CanCancelGuidedWalk))] - private void CancelGuidedWalk() + private async Task CancelGuidedWalkAsync() { _guidedWalkQueue.Clear(); IsGuidedWalkActive = false; CurrentWalkPoint = null; GuidedWalkStatusMessage = _localizationService.GetString("WorkspaceGuidedWalkCanceled"); + if (SelectedFloor is not null) + { + SelectedFloor.PendingGuidedWalkPoints.Clear(); + await SaveAndRefreshAsync(); + } + + OnPropertyChanged(nameof(RemainingWalkPoints)); FloorChanged?.Invoke(this, EventArgs.Empty); } + private bool CanSkipWalkPoint() => IsGuidedWalkActive && !IsCapturingWalkPoint; + + [RelayCommand(CanExecute = nameof(CanSkipWalkPoint))] + private async Task SkipWalkPointAsync() + { + if (SelectedFloor is null || _guidedWalkQueue.Count == 0) + { + return; + } + + _guidedWalkQueue.Dequeue(); + SelectedFloor.PendingGuidedWalkPoints.Clear(); + SelectedFloor.PendingGuidedWalkPoints.AddRange(_guidedWalkQueue); + await SaveAndRefreshAsync(); + AdvanceGuidedWalk(); + } + private void AdvanceGuidedWalk() { if (_guidedWalkQueue.Count == 0) @@ -388,6 +477,7 @@ private void AdvanceGuidedWalk() CurrentWalkPoint = _guidedWalkQueue.Peek(); } + OnPropertyChanged(nameof(RemainingWalkPoints)); FloorChanged?.Invoke(this, EventArgs.Empty); } @@ -413,17 +503,201 @@ public async Task AddWallAsync(Point2D start, Point2D end) await SaveAndRefreshAsync(); } + public async Task UpdateOutdoorBoundsAsync(Point2D min, Point2D max) + { + if (SelectedFloor is not { IsOutdoor: true } floor) + { + return; + } + + floor.OutdoorBoundsMin = min; + floor.OutdoorBoundsMax = max; + await SaveAndRefreshAsync(); + } + + // Single-level undo, scoped to the Delete tool only — the highest-risk edit in the app since + // it's the one permanent, unconfirmed action on the canvas. Not a general undo/redo stack: + // overwritten by the next delete, and cleared on floor switch so it never applies somewhere + // other than where the deletion actually happened. + private (Floor Floor, object Item)? _lastDeletedElement; + + public bool CanUndoDelete => _lastDeletedElement is not null; + + public string LastDeletedItemDisplay => _lastDeletedElement?.Item switch + { + Wall => _localizationService.GetString("WorkspaceUndoDeleteWallMessage"), + TestPoint => _localizationService.GetString("WorkspaceUndoDeleteTestPointMessage"), + AccessPoint => _localizationService.GetString("WorkspaceUndoDeleteAccessPointMessage"), + _ => string.Empty, + }; + public async Task DeleteNearestElementAsync(Point2D at) { - if (SelectedFloor is null || !TryFindNearestElement(SelectedFloor, at, out var remove)) + if (SelectedFloor is null || !TryFindNearestElement(SelectedFloor, at, out var remove, out var removedItem)) { return; } remove(); + _selectedWalls.RemoveAll(wall => !SelectedFloor.Walls.Contains(wall)); + SelectedWallCount = _selectedWalls.Count; + _lastDeletedElement = removedItem is not null ? (SelectedFloor, removedItem) : null; + OnPropertyChanged(nameof(CanUndoDelete)); + OnPropertyChanged(nameof(LastDeletedItemDisplay)); + UndoDeleteCommand.NotifyCanExecuteChanged(); + await SaveAndRefreshAsync(); + } + + [RelayCommand(CanExecute = nameof(CanUndoDelete))] + private async Task UndoDeleteAsync() + { + if (_lastDeletedElement is not { } deleted) + { + return; + } + + switch (deleted.Item) + { + case Wall wall: + deleted.Floor.Walls.Add(wall); + break; + case TestPoint testPoint: + deleted.Floor.TestPoints.Add(testPoint); + break; + case AccessPoint accessPoint: + deleted.Floor.AccessPoints.Add(accessPoint); + break; + } + + _lastDeletedElement = null; + OnPropertyChanged(nameof(CanUndoDelete)); + OnPropertyChanged(nameof(LastDeletedItemDisplay)); + UndoDeleteCommand.NotifyCanExecuteChanged(); + await SaveAndRefreshAsync(); + } + + /// Nearest wall or test point to a Select-tool click, within the same hit tolerance + /// the Delete tool uses — a wall is offered for material editing, a test point for recapture. + /// Access points aren't selectable here; neither has a per-element edit affordance today. + public object? FindNearestSelectable(Point2D at) + { + if (SelectedFloor is not { } floor) + { + return null; + } + + var (testPoint, testPointDistance) = NearestTestPoint(floor, at); + var (wall, wallDistance) = NearestWall(floor, at); + double best = Math.Min(testPointDistance, wallDistance); + if (best > DeleteHitToleranceMeters) + { + return null; + } + + return best == testPointDistance ? testPoint : wall; + } + + public void ToggleWallSelection(Wall wall) + { + if (!_selectedWalls.Remove(wall)) + { + _selectedWalls.Add(wall); + } + + SelectedWallCount = _selectedWalls.Count; + FloorChanged?.Invoke(this, EventArgs.Empty); + } + + public void ClearWallSelection() + { + if (_selectedWalls.Count == 0) + { + return; + } + + _selectedWalls.Clear(); + SelectedWallCount = 0; + FloorChanged?.Invoke(this, EventArgs.Empty); + } + + public async Task ApplyMaterialToSelectedWallsAsync(WallMaterial? material, double? thicknessMeters) + { + if (_selectedWalls.Count == 0) + { + return; + } + + foreach (var wall in _selectedWalls) + { + wall.Material = material; + wall.ThicknessMeters = thicknessMeters; + } + + ClearWallSelection(); await SaveAndRefreshAsync(); } + public async Task RecaptureTestPointAsync(TestPoint testPoint) + { + if (Survey is null || SelectedFloor is null || SelectedAdapter is null) + { + return; + } + + var scanResult = await _wlanAdapterService.ScanAsync(SelectedAdapter.Id); + if (scanResult.Status != WlanScanStatus.Success) + { + ErrorMessage = DescribeScanStatus(scanResult.Status); + return; + } + + var rebuilt = TestPointCapture.BuildTestPoint(testPoint.Position, scanResult, Survey, SelectedAdapter.Name, DateTimeOffset.Now); + SelectedFloor.TestPoints.Remove(testPoint); + SelectedFloor.TestPoints.Add(rebuilt); + await SaveAndRefreshAsync(); + } + + public ObservableCollection DiagnoseFindings { get; } = []; + + [ObservableProperty] + public partial string? DiagnoseSummaryDisplay { get; set; } + + /// Compares this machine's live link against what the survey's own propagation model + /// predicted at the clicked point — the Workspace-only half of diagnostics that the standalone + /// Diagnose page can't offer, since it has no survey/floor to predict against. Reuses the exact + /// same cross-floor/outdoor skip rules as the coverage heatmap via + /// , so the two never disagree. + public async Task DiagnoseAtPointAsync(Point2D position) + { + if (Survey is null || SelectedFloor is null || SelectedAdapter is null) + { + return; + } + + double? predicted = CoverageGridCalculator.StrongestSignalDbm(position, SelectedFloor, Survey.Floors, SelectedBand, _propagationModel); + + var link = await _linkDiagnosticsService.GetCurrentLinkAsync(SelectedAdapter.Id); + var health = link.IsConnected ? await _networkHealthService.CheckHealthAsync(SelectedAdapter.Id) : NetworkHealthSnapshot.Unavailable; + + DiagnoseSummaryDisplay = predicted is { } predictedDbm + ? string.Format(CultureInfo.CurrentCulture, _localizationService.GetString("WorkspaceDiagnosePredictedFormat"), predictedDbm) + : _localizationService.GetString("WorkspaceDiagnoseNoPredictionDisplay"); + + var findings = LinkDiagnosticsAnalyzer.Analyze(link, health, predicted); + DiagnoseFindings.Clear(); + foreach (var finding in findings) + { + string template = _localizationService.GetString(finding.MessageKey); + string message = finding.FormatArgs is null ? template : string.Format(CultureInfo.CurrentCulture, template, [.. finding.FormatArgs]); + DiagnoseFindings.Add(new DiagnosticFindingDisplay(finding.Severity, message)); + } + } + + /// Whether running Suggest Placements again would discard previously suggested (not + /// user-placed) access points on this floor — lets the page ask for confirmation only when + /// there's actually something to lose, rather than always or never. + public bool HasReplaceableSuggestions => SelectedFloor?.AccessPoints.Any(ap => !ap.IsUserOverride) == true; + [RelayCommand] private async Task SuggestPlacementsAsync() { @@ -433,7 +707,7 @@ private async Task SuggestPlacementsAsync() } SelectedFloor.AccessPoints.RemoveAll(ap => !ap.IsUserOverride); - var suggestions = _placementOptimizer.SuggestPlacements(SelectedFloor, Survey.TargetBands, _propagationModel); + var suggestions = _placementOptimizer.SuggestPlacements(SelectedFloor, Survey.Floors, Survey.TargetBands, _propagationModel); SelectedFloor.AccessPoints.AddRange(suggestions); await SaveAndRefreshAsync(); } @@ -567,6 +841,25 @@ partial void OnSelectedBandChanged(Band value) partial void OnSelectedFloorChanged(Floor? value) { + ClearWallSelection(); + + _lastDeletedElement = null; + OnPropertyChanged(nameof(CanUndoDelete)); + UndoDeleteCommand.NotifyCanExecuteChanged(); + + _guidedWalkQueue.Clear(); + IsGuidedWalkActive = false; + CurrentWalkPoint = null; + if (value is { PendingGuidedWalkPoints.Count: > 0 } floor) + { + _guidedWalkQueue = new Queue(floor.PendingGuidedWalkPoints); + _guidedWalkTotalPoints = _guidedWalkQueue.Count; + SelectedBand = floor.PendingGuidedWalkBand ?? SelectedBand; + IsGuidedWalkActive = true; + CurrentWalkPoint = _guidedWalkQueue.Peek(); + } + + OnPropertyChanged(nameof(RemainingWalkPoints)); Recompute(); FloorChanged?.Invoke(this, EventArgs.Empty); } @@ -599,72 +892,96 @@ private void Recompute() : Heatmap.Count(s => s.ValueDbm >= ReliableCoverageThresholdDbm) / (double)Heatmap.Count * 100; } - private static bool TryFindNearestElement(Floor floor, Point2D at, out Action remove) + private static bool TryFindNearestElement(Floor floor, Point2D at, out Action remove, out object? removedItem) { - TestPoint? nearestTestPoint = null; - double nearestTestPointDist = double.MaxValue; - foreach (var testPoint in floor.TestPoints) - { - double distance = testPoint.Position.DistanceTo(at); - if (distance < nearestTestPointDist) - { - nearestTestPointDist = distance; - nearestTestPoint = testPoint; - } - } - - AccessPoint? nearestAccessPoint = null; - double nearestAccessPointDist = double.MaxValue; - foreach (var accessPoint in floor.AccessPoints) - { - double distance = accessPoint.Position.DistanceTo(at); - if (distance < nearestAccessPointDist) - { - nearestAccessPointDist = distance; - nearestAccessPoint = accessPoint; - } - } - - Wall? nearestWall = null; - double nearestWallDist = double.MaxValue; - foreach (var wall in floor.Walls) - { - double distance = DistanceToSegment(at, wall.Start, wall.End); - if (distance < nearestWallDist) - { - nearestWallDist = distance; - nearestWall = wall; - } - } + var (nearestTestPoint, nearestTestPointDist) = NearestTestPoint(floor, at); + var (nearestAccessPoint, nearestAccessPointDist) = NearestAccessPoint(floor, at); + var (nearestWall, nearestWallDist) = NearestWall(floor, at); double best = Math.Min(nearestTestPointDist, Math.Min(nearestAccessPointDist, nearestWallDist)); if (best > DeleteHitToleranceMeters) { remove = static () => { }; + removedItem = null; return false; } if (best == nearestTestPointDist && nearestTestPoint is not null) { remove = () => floor.TestPoints.Remove(nearestTestPoint); + removedItem = nearestTestPoint; } else if (best == nearestAccessPointDist && nearestAccessPoint is not null) { remove = () => floor.AccessPoints.Remove(nearestAccessPoint); + removedItem = nearestAccessPoint; } else if (nearestWall is not null) { remove = () => floor.Walls.Remove(nearestWall); + removedItem = nearestWall; } else { remove = static () => { }; + removedItem = null; return false; } return true; } + private static (TestPoint? Item, double Distance) NearestTestPoint(Floor floor, Point2D at) + { + TestPoint? nearest = null; + double nearestDistance = double.MaxValue; + foreach (var testPoint in floor.TestPoints) + { + double distance = testPoint.Position.DistanceTo(at); + if (distance < nearestDistance) + { + nearestDistance = distance; + nearest = testPoint; + } + } + + return (nearest, nearestDistance); + } + + private static (AccessPoint? Item, double Distance) NearestAccessPoint(Floor floor, Point2D at) + { + AccessPoint? nearest = null; + double nearestDistance = double.MaxValue; + foreach (var accessPoint in floor.AccessPoints) + { + double distance = accessPoint.Position.DistanceTo(at); + if (distance < nearestDistance) + { + nearestDistance = distance; + nearest = accessPoint; + } + } + + return (nearest, nearestDistance); + } + + private static (Wall? Item, double Distance) NearestWall(Floor floor, Point2D at) + { + Wall? nearest = null; + double nearestDistance = double.MaxValue; + foreach (var wall in floor.Walls) + { + double distance = DistanceToSegment(at, wall.Start, wall.End); + if (distance < nearestDistance) + { + nearestDistance = distance; + nearest = wall; + } + } + + return (nearest, nearestDistance); + } + private static double DistanceToSegment(Point2D point, Point2D segmentStart, Point2D segmentEnd) { double dx = segmentEnd.X - segmentStart.X; diff --git a/src/Pulsemap.App/Views/DiagnosticsPage.xaml b/src/Pulsemap.App/Views/DiagnosticsPage.xaml new file mode 100644 index 0000000..7f5d207 --- /dev/null +++ b/src/Pulsemap.App/Views/DiagnosticsPage.xaml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + - + - + + @@ -46,5 +47,17 @@ Command="{x:Bind ViewModel.OpenLogsFolderCommand}" /> + + + + + + + + diff --git a/src/Pulsemap.App/Views/SettingsPage.xaml.cs b/src/Pulsemap.App/Views/SettingsPage.xaml.cs index aa1d1ab..e7bb2ab 100644 --- a/src/Pulsemap.App/Views/SettingsPage.xaml.cs +++ b/src/Pulsemap.App/Views/SettingsPage.xaml.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Navigation; using Pulsemap.App.ViewModels; namespace Pulsemap.App.Views; @@ -13,4 +14,10 @@ public SettingsPage() ViewModel = App.Services.GetRequiredService(); InitializeComponent(); } + + protected override async void OnNavigatedTo(NavigationEventArgs e) + { + base.OnNavigatedTo(e); + await ViewModel.LoadCommand.ExecuteAsync(null); + } } diff --git a/src/Pulsemap.App/Views/SnapshotComparisonPage.xaml b/src/Pulsemap.App/Views/SnapshotComparisonPage.xaml index f6ff8f8..95e6f15 100644 --- a/src/Pulsemap.App/Views/SnapshotComparisonPage.xaml +++ b/src/Pulsemap.App/Views/SnapshotComparisonPage.xaml @@ -33,12 +33,20 @@ ItemsSource="{x:Bind ViewModel.Options, Mode=OneWay}" SelectedItem="{x:Bind ViewModel.LeftOption, Mode=TwoWay}" DisplayMemberPath="Label" /> + @@ -65,6 +108,23 @@ +