Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9875403
feat(workspace): per-wall material editing and guided-walk improvements
dmiesopust Sep 2, 2026
aa8555e
docs(adr): drop personal email from ADR decider fields
dmiesopust Sep 2, 2026
d2242ad
feat(app): in-app update check against GitHub Releases
dmiesopust Sep 2, 2026
18dcff8
Merge pull request #7 from DotifyBIZ/feature/update-check-notify
dmiesopust Sep 2, 2026
a03977d
Merge branch 'staging' into feature/wall-material-and-guided-walk
dmiesopust Sep 2, 2026
af16c9b
Merge pull request #8 from DotifyBIZ/feature/wall-material-and-guided…
dmiesopust Sep 2, 2026
0f93258
feat(core): coordinate AP channel assignment across floors
dmiesopust Sep 2, 2026
9cfcc65
feat(workspace): drag-to-resize/reposition outdoor area bounds
dmiesopust Sep 2, 2026
e3f6b7f
Merge pull request #9 from DotifyBIZ/feature/outdoor-bounds-editor
dmiesopust Sep 2, 2026
99ba525
Merge branch 'staging' into feature/cross-floor-channel-coordination
dmiesopust Sep 2, 2026
946dfdc
Merge pull request #10 from DotifyBIZ/feature/cross-floor-channel-coo…
dmiesopust Sep 2, 2026
2fc361f
fix(workspace): replace Pivot with TabView to fix tab-header clipping
dmiesopust Sep 2, 2026
60c23df
fix(workspace): add single-level undo for the Delete tool
dmiesopust Sep 2, 2026
d308432
feat(app): Workspace onboarding/tool help, and a warmer Home greeting
dmiesopust Sep 2, 2026
5a2bee2
Merge pull request #11 from DotifyBIZ/feature/workspace-tabview-migra…
dmiesopust Sep 2, 2026
6a21c09
Merge pull request #12 from DotifyBIZ/feature/undo-last-delete
dmiesopust Sep 2, 2026
7b7b693
Merge branch 'staging' into feature/workspace-onboarding
dmiesopust Sep 2, 2026
ede44f9
Merge pull request #13 from DotifyBIZ/feature/workspace-onboarding
dmiesopust Sep 2, 2026
8a1bf00
feat: WLAN link diagnostics, warmer Home messages, and a warmer Home
dmiesopust Sep 2, 2026
15f96c7
fix(app): close out remaining UX-review polish items
dmiesopust Sep 2, 2026
d498a26
Merge pull request #14 from DotifyBIZ/feature/diagnostics-and-polish
dmiesopust Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

22 changes: 12 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -90,5 +92,5 @@ This project follows a [Code of Conduct](CODE_OF_CONDUCT.md).
---

<div align="center">
<sub>Built by <a href="https://dotify.biz">Dotify</a></sub>
<sub>Crafted with ❤️ by <a href="https://dotify.biz">Dotify</a></sub>
</div>
2 changes: 1 addition & 1 deletion docs/adr/0001-winui3-windows-only-platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0002-installer-innosetup-over-msix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0003-local-diagnostic-logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions docs/adr/0004-update-check-network-call.md
Original file line number Diff line number Diff line change
@@ -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)
7 changes: 5 additions & 2 deletions scripts/build-installer.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
8 changes: 8 additions & 0 deletions src/Pulsemap.App.Core/Abstractions/ILinkDiagnosticsService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Pulsemap.App.Core.Abstractions;

/// <summary>Platform-specific live WLAN link state, implemented in Pulsemap.App via native
/// wlanapi.dll P/Invoke (WlanQueryInterface) — same split as <see cref="IWlanAdapterService"/>.</summary>
public interface ILinkDiagnosticsService
{
Task<LinkDiagnosticsSnapshot> GetCurrentLinkAsync(Guid adapterId, CancellationToken cancellationToken = default);
}
9 changes: 9 additions & 0 deletions src/Pulsemap.App.Core/Abstractions/INetworkHealthService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Pulsemap.App.Core.Abstractions;

/// <summary>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.</summary>
public interface INetworkHealthService
{
Task<NetworkHealthSnapshot> CheckHealthAsync(Guid adapterId, CancellationToken cancellationToken = default);
}
12 changes: 12 additions & 0 deletions src/Pulsemap.App.Core/Abstractions/IUpdateCheckService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Pulsemap.App.Core.Abstractions;

/// <summary>
/// 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.
/// </summary>
public interface IUpdateCheckService
{
Task<UpdateCheckResult> CheckForUpdateAsync(CancellationToken cancellationToken = default);
}
20 changes: 20 additions & 0 deletions src/Pulsemap.App.Core/Abstractions/LinkDiagnosticsSnapshot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Pulsemap.App.Core.Models;

namespace Pulsemap.App.Core.Abstractions;

/// <summary>A point-in-time reading of this machine's current WLAN association — live link state,
/// not a scan of nearby networks (that's <see cref="WlanScanResult"/>). <paramref name="RxLinkSpeedMbps"/>/
/// <paramref name="TxLinkSpeedMbps"/> are the negotiated PHY rate, not throughput actually achieved.</summary>
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);
}
10 changes: 10 additions & 0 deletions src/Pulsemap.App.Core/Abstractions/NetworkHealthSnapshot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Pulsemap.App.Core.Abstractions;

/// <summary>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 (<see cref="DnsSucceeded"/> false, or a very high ping).</summary>
public sealed record NetworkHealthSnapshot(string? GatewayAddress, double? GatewayPingMs, double? DnsResolutionMs, bool DnsSucceeded)
{
public static NetworkHealthSnapshot Unavailable { get; } = new(null, null, null, false);
}
6 changes: 6 additions & 0 deletions src/Pulsemap.App.Core/Abstractions/UpdateCheckResult.cs
Original file line number Diff line number Diff line change
@@ -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);
}
7 changes: 7 additions & 0 deletions src/Pulsemap.App.Core/Diagnostics/DiagnosticFinding.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Pulsemap.App.Core.Diagnostics;

/// <summary>One troubleshooting observation. <paramref name="MessageKey"/> is a localization key,
/// not display text — <see cref="FormatArgs"/> are passed to the App layer's
/// <c>string.Format</c>/<c>ILocalizationService</c> call so this stays free of any UI/culture
/// concerns, matching how the rest of Core hands display strings back as keys.</summary>
public sealed record DiagnosticFinding(DiagnosticSeverity Severity, string MessageKey, IReadOnlyList<object>? FormatArgs = null);
8 changes: 8 additions & 0 deletions src/Pulsemap.App.Core/Diagnostics/DiagnosticSeverity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Pulsemap.App.Core.Diagnostics;

public enum DiagnosticSeverity
{
Info,
Warning,
Error,
}
118 changes: 118 additions & 0 deletions src/Pulsemap.App.Core/Diagnostics/LinkDiagnosticsAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using Pulsemap.App.Core.Abstractions;
using Pulsemap.App.Core.Models;

namespace Pulsemap.App.Core.Diagnostics;

/// <summary>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.</summary>
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<DiagnosticFinding> Analyze(LinkDiagnosticsSnapshot link, NetworkHealthSnapshot? health, double? predictedSignalDbm = null)
{
ArgumentNullException.ThrowIfNull(link);

if (!link.IsConnected)
{
return [new DiagnosticFinding(DiagnosticSeverity.Error, "DiagnosticNotConnected")];
}

var findings = new List<DiagnosticFinding>();

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<DiagnosticFinding> 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<DiagnosticFinding> 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<DiagnosticFinding> 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<DiagnosticFinding> 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);
}
9 changes: 9 additions & 0 deletions src/Pulsemap.App.Core/Models/Floor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,13 @@ public sealed class Floor
public List<TestPoint> TestPoints { get; init; } = [];

public List<AccessPoint> AccessPoints { get; init; } = [];

/// <summary>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.</summary>
public List<Point2D> PendingGuidedWalkPoints { get; init; } = [];

/// <summary>The band the pending walk above was suggested for — needed to restore
/// <c>SelectedBand</c> on resume, since the suggester's candidate order depends on it.</summary>
public Band? PendingGuidedWalkBand { get; set; }
}
Loading
Loading