diff --git a/CLAUDE.md b/CLAUDE.md index 4708dd0..0c2d9c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,3 +67,16 @@ Don't invent a convention to fill a gap. Name what's missing, propose the smalle **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/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/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/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/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/App.xaml.cs b/src/Pulsemap.App/App.xaml.cs index afc539e..f99f297 100644 --- a/src/Pulsemap.App/App.xaml.cs +++ b/src/Pulsemap.App/App.xaml.cs @@ -106,6 +106,8 @@ private static ServiceProvider ConfigureServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddHttpClient(); @@ -118,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 bf536b2..4ace7fa 100644 --- a/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml +++ b/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml @@ -14,11 +14,22 @@ PointerPressed="RootGrid_PointerPressed" PointerMoved="RootGrid_PointerMoved" PointerReleased="RootGrid_PointerReleased" - PointerCaptureLost="RootGrid_PointerCaptureLost"> + PointerCaptureLost="RootGrid_PointerCaptureLost" + PointerExited="RootGrid_PointerExited"> + + + + + diff --git a/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs b/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs index 9fbbfec..ffd06ea 100644 --- a/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs +++ b/src/Pulsemap.App/Controls/FloorPlanCanvas.xaml.cs @@ -81,6 +81,8 @@ public FloorPlanCanvas() public event EventHandler? WallSelectRequested; + public event EventHandler? DiagnosePositionRequested; + public event EventHandler<(Point2D Min, Point2D Max)>? OutdoorBoundsChanged; /// @@ -417,6 +419,10 @@ private void RootGrid_PointerPressed(object sender, PointerRoutedEventArgs e) WallSelectRequested?.Invoke(this, meters); break; + case WorkspaceTool.Diagnose: + DiagnosePositionRequested?.Invoke(this, meters); + break; + default: break; } @@ -459,12 +465,19 @@ private static bool IsNearExistingMarker(Floor floor, Point2D at) => 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(e.GetCurrentPoint(RootGrid).Position); + var meters = ToMeters(pointerPosition); double deltaX = meters.X - _dragStartPointerMeters.X; double deltaY = meters.Y - _dragStartPointerMeters.Y; @@ -484,6 +497,8 @@ private void RootGrid_PointerMoved(object sender, PointerRoutedEventArgs e) 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); @@ -506,6 +521,45 @@ private void EndOutdoorBoundsDrag(bool raiseChanged) _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); 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/Services/LocalizationService.cs b/src/Pulsemap.App/Services/LocalizationService.cs index 981ecea..2279ed6 100644 --- a/src/Pulsemap.App/Services/LocalizationService.cs +++ b/src/Pulsemap.App/Services/LocalizationService.cs @@ -118,6 +118,16 @@ public string GetString(string key) ["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", @@ -128,6 +138,34 @@ public string GetString(string key) ["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 { @@ -204,6 +242,16 @@ public string GetString(string key) ["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", @@ -214,6 +262,34 @@ public string GetString(string key) ["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 497316e..15e5764 100644 --- a/src/Pulsemap.App/Strings/en-US/Resources.resw +++ b/src/Pulsemap.App/Strings/en-US/Resources.resw @@ -20,6 +20,9 @@ Surveys + + Diagnose + Settings @@ -275,6 +278,9 @@ Delete + + Diagnose + Tool Help @@ -305,6 +311,12 @@ 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 @@ -472,6 +484,9 @@ Band + + Delete + @@ -525,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 db3438d..c527836 100644 --- a/src/Pulsemap.App/Strings/pl-PL/Resources.resw +++ b/src/Pulsemap.App/Strings/pl-PL/Resources.resw @@ -20,6 +20,9 @@ Badania + + Diagnostyka + Ustawienia @@ -275,6 +278,9 @@ Usuń + + Diagnostyka + Pomoc dot. narzędzi @@ -305,6 +311,12 @@ 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 @@ -472,6 +484,9 @@ Pasmo + + Usuń + @@ -525,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 6bf3cdd..4017160 100644 --- a/src/Pulsemap.App/ViewModels/HomeViewModel.cs +++ b/src/Pulsemap.App/ViewModels/HomeViewModel.cs @@ -27,6 +27,10 @@ public partial class HomeViewModel( "HomeWellbeingMessage2", "HomeWellbeingMessage3", "HomeWellbeingMessage4", + "HomeWellbeingMessage5", + "HomeWellbeingMessage6", + "HomeWellbeingMessage7", + "HomeWellbeingMessage8", ]; private string? _releaseUrl; 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/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 e795cc9..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; @@ -44,9 +45,13 @@ public partial class WorkspaceViewModel : ObservableObject 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, @@ -58,7 +63,9 @@ public WorkspaceViewModel( IReportExporter reportExporter, ISurveyExportFilePickerService exportFilePickerService, IKrigingInterpolator krigingInterpolator, - IAppSettingsService appSettingsService) + IAppSettingsService appSettingsService, + ILinkDiagnosticsService linkDiagnosticsService, + INetworkHealthService networkHealthService) { _surveyFileService = surveyFileService; _propagationModel = propagationModel; @@ -71,6 +78,8 @@ public WorkspaceViewModel( _exportFilePickerService = exportFilePickerService; _krigingInterpolator = krigingInterpolator; _appSettingsService = appSettingsService; + _linkDiagnosticsService = linkDiagnosticsService; + _networkHealthService = networkHealthService; } public event EventHandler? FloorChanged; @@ -110,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); @@ -255,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)); @@ -636,6 +657,47 @@ public async Task RecaptureTestPointAsync(TestPoint testPoint) 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() { 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 @@ + + + + + + + + + + + + + + + + + + + +