diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..e6ffc57
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,42 @@
+# ──────────────────────────────────────────────────────────────────────────────
+# RigCheck by ShackDesk
+# Project : https://github.com/Computer-Tsu/ShackDesk-RigCheck
+# Author : Mark McDow (N4TEK) — My Computer Guru LLC
+# License : GPLv3 (see LICENSE)
+#
+# File : .github/dependabot.yml
+# Purpose : Dependabot opens pull requests when NuGet packages or GitHub
+# Actions have newer versions, and patches known vulnerabilities
+# without manual monitoring.
+#
+# Schedule : Weekly, Monday mornings (UTC). PRs target the develop branch.
+# Labels : "dependencies", "automated" — filter Dependabot PRs by these.
+# ──────────────────────────────────────────────────────────────────────────────
+version: 2
+
+updates:
+ # NuGet packages (Serilog, CommunityToolkit.Mvvm, Newtonsoft.Json, etc.)
+ - package-ecosystem: nuget
+ directory: /
+ target-branch: develop
+ schedule:
+ interval: weekly
+ day: monday
+ labels:
+ - dependencies
+ - automated
+ commit-message:
+ prefix: "chore(deps)"
+
+ # GitHub Actions (actions/checkout, actions/setup-dotnet, etc.)
+ - package-ecosystem: github-actions
+ directory: /
+ target-branch: develop
+ schedule:
+ interval: weekly
+ day: monday
+ labels:
+ - dependencies
+ - automated
+ commit-message:
+ prefix: "chore(actions)"
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c4ed687..90ecfda 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -5,14 +5,30 @@
# License : GPLv3 (see LICENSE)
#
# Workflow : Build
-# Purpose : Compiles RigCheck and publishes the self-contained Windows
-# executable as a workflow artifact. This is the project's only
-# build path — there is no local toolchain.
+# Purpose : Compiles RigCheck, publishes the self-contained Windows exe, and
+# on the develop branch publishes it as an alpha pre-release.
+# This is the project's only build path — there is no local toolchain.
#
# Triggers :
-# - push to develop or main — build and publish exe artifact
+# - push to develop — build, publish artifact, publish alpha release
+# - push to main — build, publish artifact (stable releases are tag-driven)
# - pull_request — build only
-# - workflow_dispatch (manual) — build; optionally publish the exe artifact
+# - schedule (weekly, Monday) — rebuild develop so a fresh alpha exists
+# before the previous one expires (30 days)
+# - workflow_dispatch (manual) — build; optionally publish artifact and release
+#
+# Channel : develop → alpha (30-day expiry), main → stable. The channel and
+# the UTC build date are passed to MSBuild and stamped into the
+# assembly; BuildInfo.cs reads them at runtime.
+#
+# Artifact : RigCheck-{version}-{channel}-{yyyyMMdd}-{sha7}.exe plus .sha256
+# Release : tag alpha/{yyyyMMdd}-{sha7} (same stamp as the filename), marked
+# pre-release. Alpha releases
+# older than 30 days are deleted after each new one is published,
+# since their builds have expired anyway.
+#
+# Scheduled runs always execute from the default branch's copy of this file,
+# so the checkout step selects develop explicitly for them.
#
# Manual trigger : Actions tab → "Build" → "Run workflow" → select branch
# ──────────────────────────────────────────────────────────────────────────────
@@ -27,6 +43,7 @@ on:
- '.github/ISSUE_TEMPLATE/**'
- '.github/PULL_REQUEST_TEMPLATE.md'
- '.github/SECURITY.md'
+ - '.github/dependabot.yml'
pull_request:
branches: [ develop, main ]
paths-ignore:
@@ -35,16 +52,24 @@ on:
- '.github/ISSUE_TEMPLATE/**'
- '.github/PULL_REQUEST_TEMPLATE.md'
- '.github/SECURITY.md'
+ - '.github/dependabot.yml'
+ schedule:
+ - cron: '0 6 * * 1'
workflow_dispatch:
inputs:
publish_artifact:
- description: 'Publish and upload RigCheck.exe artifact'
+ description: 'Publish and upload the exe artifact'
required: false
default: true
type: boolean
+ publish_release:
+ description: 'Also publish an alpha release (develop only)'
+ required: false
+ default: false
+ type: boolean
env:
- DOTNET_VERSION: '8.0.x'
+ DOTNET_VERSION: '10.0.x'
PROJECT: 'RigCheck.csproj'
RID: 'win-x64'
APP_EXE: 'RigCheck.exe'
@@ -54,25 +79,73 @@ jobs:
name: Build
runs-on: windows-latest
+ outputs:
+ channel: ${{ steps.meta.outputs.channel }}
+ version: ${{ steps.meta.outputs.version }}
+ date: ${{ steps.meta.outputs.date }}
+ stamp: ${{ steps.meta.outputs.stamp }}
+ name: ${{ steps.meta.outputs.name }}
+ sha: ${{ steps.meta.outputs.sha }}
+ sha7: ${{ steps.meta.outputs.sha7 }}
+ publish: ${{ steps.meta.outputs.publish }}
+ release: ${{ steps.meta.outputs.release }}
+
steps:
- name: Checkout
uses: actions/checkout@v6
with:
+ # Scheduled runs come from main's workflow file but must build develop
+ ref: ${{ github.event_name == 'schedule' && 'develop' || github.ref }}
fetch-depth: 0
- name: Setup .NET ${{ env.DOTNET_VERSION }}
- uses: actions/setup-dotnet@v4
+ uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
+ - name: Determine channel, version, build date, and what to publish
+ id: meta
+ shell: pwsh
+ run: |
+ $branch = (git rev-parse --abbrev-ref HEAD)
+ $sha = (git rev-parse HEAD)
+ $sha7 = $sha.Substring(0, 7)
+ $channel = if ($branch -eq 'main') { 'stable' } else { 'alpha' }
+ $version = (Select-String -Path '${{ env.PROJECT }}' -Pattern '([^<]+)').Matches[0].Groups[1].Value
+ $date = [DateTime]::UtcNow.ToString('yyyy-MM-dd')
+ $stamp = [DateTime]::UtcNow.ToString('yyyyMMdd')
+ $name = if ($channel -eq 'stable') { "RigCheck-$version" } else { "RigCheck-$version-$channel-$stamp-$sha7" }
+
+ $event = '${{ github.event_name }}'
+ $publish = $event -ne 'pull_request' -and ($event -ne 'workflow_dispatch' -or '${{ inputs.publish_artifact }}' -eq 'true')
+ $release = $branch -eq 'develop' -and (
+ $event -eq 'push' -or $event -eq 'schedule' -or
+ ($event -eq 'workflow_dispatch' -and '${{ inputs.publish_release }}' -eq 'true'))
+
+ "channel=$channel" >> $env:GITHUB_OUTPUT
+ "version=$version" >> $env:GITHUB_OUTPUT
+ "date=$date" >> $env:GITHUB_OUTPUT
+ "stamp=$stamp" >> $env:GITHUB_OUTPUT
+ "name=$name" >> $env:GITHUB_OUTPUT
+ "sha=$sha" >> $env:GITHUB_OUTPUT
+ "sha7=$sha7" >> $env:GITHUB_OUTPUT
+ "publish=$($publish.ToString().ToLower())" >> $env:GITHUB_OUTPUT
+ "release=$($release.ToString().ToLower())" >> $env:GITHUB_OUTPUT
+ Write-Host "Branch: $branch Channel: $channel Version: $version Built: $date Artifact: $name Publish: $publish Release: $release"
+
- name: Restore NuGet packages
run: dotnet restore ${{ env.PROJECT }}
- name: Build — Release
- run: dotnet build ${{ env.PROJECT }} --configuration Release --no-restore
+ run: >
+ dotnet build ${{ env.PROJECT }}
+ --configuration Release
+ --no-restore
+ -p:Channel=${{ steps.meta.outputs.channel }}
+ -p:BuildDateUtc=${{ steps.meta.outputs.date }}
- name: Publish — single self-contained exe
- if: ${{ github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || inputs.publish_artifact) }}
+ if: steps.meta.outputs.publish == 'true'
run: >
dotnet publish ${{ env.PROJECT }}
--configuration Release
@@ -81,21 +154,114 @@ jobs:
--no-restore
-p:PublishSingleFile=true
-p:IncludeNativeLibrariesForSelfExtract=true
+ -p:Channel=${{ steps.meta.outputs.channel }}
+ -p:BuildDateUtc=${{ steps.meta.outputs.date }}
--output ./publish
- - name: Compute SHA-256
- if: ${{ github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || inputs.publish_artifact) }}
+ - name: Name the exe and compute SHA-256
+ if: steps.meta.outputs.publish == 'true'
shell: pwsh
run: |
- $hash = (Get-FileHash -Algorithm SHA256 './publish/${{ env.APP_EXE }}').Hash.ToLower()
- $hash | Out-File -FilePath './publish/${{ env.APP_EXE }}.sha256' -NoNewline
+ $name = '${{ steps.meta.outputs.name }}'
+ Rename-Item './publish/${{ env.APP_EXE }}' "$name.exe"
+ $hash = (Get-FileHash -Algorithm SHA256 "./publish/$name.exe").Hash.ToLower()
+ "$hash $name.exe" | Out-File -FilePath "./publish/$name.exe.sha256" -NoNewline
Write-Host "SHA-256: $hash"
- name: Upload exe artifact
- if: ${{ github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || inputs.publish_artifact) }}
+ if: steps.meta.outputs.publish == 'true'
uses: actions/upload-artifact@v7
with:
- name: RigCheck-${{ github.sha }}
+ name: ${{ steps.meta.outputs.name }}
path: |
- ./publish/${{ env.APP_EXE }}
- ./publish/${{ env.APP_EXE }}.sha256
+ ./publish/${{ steps.meta.outputs.name }}.exe
+ ./publish/${{ steps.meta.outputs.name }}.exe.sha256
+
+ # ─────────────────────────────────────────────────────────────────────
+ release:
+ name: Publish alpha release
+ runs-on: ubuntu-latest
+ needs: build
+ if: needs.build.outputs.release == 'true'
+
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.build.outputs.sha }}
+
+ - name: Download exe artifact
+ uses: actions/download-artifact@v8
+ with:
+ name: ${{ needs.build.outputs.name }}
+ path: ./release
+
+ - name: Compose release notes
+ id: notes
+ shell: bash
+ run: |
+ NAME='${{ needs.build.outputs.name }}'
+ VERSION='${{ needs.build.outputs.version }}'
+ DATE='${{ needs.build.outputs.date }}'
+ EXPIRES=$(date -u -d "$DATE + 30 days" +%Y-%m-%d)
+ HASH=$(cut -d' ' -f1 "./release/$NAME.exe.sha256")
+
+ # Top section of CHANGELOG.md: from the first "## [" to the next one
+ CHANGES=$(awk '/^## \[/{n++} n==1' CHANGELOG.md | tail -n +2)
+
+ {
+ echo "Alpha build from the \`develop\` branch. For testing — not for everyday use."
+ echo
+ echo "**This build expires on $EXPIRES.** Alpha builds stop running 30 days after they are built so testers stay on current code. The expiry date is shown in the window title and in Help > About. Download a newer alpha from the [releases page](${{ github.server_url }}/${{ github.repository }}/releases) before then."
+ echo
+ echo "## What's in it"
+ echo
+ echo "$CHANGES"
+ echo
+ echo "## Requirements"
+ echo
+ echo "- Windows 10 (21H2 or later) or Windows 11, 64-bit. Nothing else to install — the .NET runtime is inside the exe."
+ echo "- Hamlib, from WSJT-X, Fldigi, or the standalone Windows build. RigCheck finds it automatically."
+ echo
+ echo "## This build is unsigned"
+ echo
+ echo "Windows SmartScreen and your browser may warn about it. Verify the download instead:"
+ echo
+ echo '```'
+ echo "Get-FileHash $NAME.exe -Algorithm SHA256"
+ echo '```'
+ echo
+ echo "Expected: \`$HASH\`"
+ echo
+ echo "Download help: https://shackdesk.com/rigcheck/download/ · Problems: [Issues](${{ github.server_url }}/${{ github.repository }}/issues) · Questions: [Discussions](${{ github.server_url }}/${{ github.repository }}/discussions)"
+ } > ./release_notes.md
+
+ echo "expires=$EXPIRES" >> "$GITHUB_OUTPUT"
+
+ - name: Create pre-release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: alpha/${{ needs.build.outputs.stamp }}-${{ needs.build.outputs.sha7 }}
+ target_commitish: ${{ needs.build.outputs.sha }}
+ name: RigCheck ${{ needs.build.outputs.version }}-alpha (${{ needs.build.outputs.date }})
+ body_path: ./release_notes.md
+ prerelease: true
+ files: |
+ ./release/${{ needs.build.outputs.name }}.exe
+ ./release/${{ needs.build.outputs.name }}.exe.sha256
+
+ - name: Delete alpha releases older than 30 days
+ env:
+ GH_TOKEN: ${{ github.token }}
+ shell: bash
+ run: |
+ CUTOFF=$(date -u -d '30 days ago' +%Y-%m-%d)
+ gh release list --repo "${{ github.repository }}" --limit 200 --json tagName,createdAt,isPrerelease \
+ --jq ".[] | select(.isPrerelease and (.tagName | startswith(\"alpha/\")) and (.createdAt < \"${CUTOFF}T00:00:00Z\")) | .tagName" |
+ while read -r TAG; do
+ echo "Deleting expired alpha: $TAG"
+ gh release delete "$TAG" --repo "${{ github.repository }}" --cleanup-tag --yes
+ done
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..c7ec65b
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,85 @@
+# ──────────────────────────────────────────────────────────────────────────────
+# RigCheck by ShackDesk
+# Project : https://github.com/Computer-Tsu/ShackDesk-RigCheck
+# Author : Mark McDow (N4TEK) — My Computer Guru LLC
+# License : GPLv3 (see LICENSE)
+#
+# Workflow : CodeQL Security Analysis
+# Purpose : Static analysis of the C# source for security and quality
+# issues. Findings appear under the repository's Security tab.
+#
+# Triggers :
+# - push to develop or main — analyze the new code
+# - pull_request — analyze before merge
+# - schedule (weekly, Monday) — catch newly published query rules
+# - workflow_dispatch — manual run
+#
+# CodeQL must observe the build to trace C#, so the init step runs before
+# dotnet build rather than after.
+# ──────────────────────────────────────────────────────────────────────────────
+name: CodeQL
+
+on:
+ push:
+ branches: [ develop, main ]
+ paths-ignore:
+ - '**/*.md'
+ - 'LICENSE'
+ - '.github/ISSUE_TEMPLATE/**'
+ - '.github/PULL_REQUEST_TEMPLATE.md'
+ - '.github/SECURITY.md'
+ - '.github/dependabot.yml'
+ pull_request:
+ branches: [ develop, main ]
+ paths-ignore:
+ - '**/*.md'
+ - 'LICENSE'
+ - '.github/ISSUE_TEMPLATE/**'
+ - '.github/PULL_REQUEST_TEMPLATE.md'
+ - '.github/SECURITY.md'
+ - '.github/dependabot.yml'
+ schedule:
+ - cron: '0 6 * * 1'
+ workflow_dispatch:
+
+env:
+ DOTNET_VERSION: '10.0.x'
+ PROJECT: 'RigCheck.csproj'
+
+jobs:
+ analyze:
+ name: Analyze C#
+ runs-on: windows-latest
+
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET ${{ env.DOTNET_VERSION }}
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: csharp
+ queries: security-and-quality
+
+ - name: Restore NuGet packages
+ run: dotnet restore ${{ env.PROJECT }}
+
+ - name: Build for analysis
+ run: dotnet build ${{ env.PROJECT }} --configuration Debug --no-restore
+
+ - name: Perform CodeQL analysis
+ uses: github/codeql-action/analyze@v4
+ with:
+ category: '/language:csharp'
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
new file mode 100644
index 0000000..265a1de
--- /dev/null
+++ b/.github/workflows/dependency-review.yml
@@ -0,0 +1,35 @@
+# ──────────────────────────────────────────────────────────────────────────────
+# RigCheck by ShackDesk
+# Project : https://github.com/Computer-Tsu/ShackDesk-RigCheck
+# Author : Mark McDow (N4TEK) — My Computer Guru LLC
+# License : GPLv3 (see LICENSE)
+#
+# Workflow : Dependency Review
+# Purpose : Blocks a pull request that introduces a dependency with a known
+# vulnerability or an incompatible license. Runs only on PRs
+# because it compares the PR's dependency set against the base.
+# ──────────────────────────────────────────────────────────────────────────────
+name: Dependency Review
+
+on:
+ pull_request:
+ branches: [ develop, main ]
+
+permissions:
+ contents: read
+
+jobs:
+ review:
+ name: Review dependency changes
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Dependency review
+ uses: actions/dependency-review-action@v4
+ with:
+ fail-on-severity: moderate
+ # GPLv3 project: block source-available licenses that cannot be combined with it
+ deny-licenses: 'SSPL-1.0, BUSL-1.1'
diff --git a/App.xaml b/App.xaml
index 3d61172..912cdce 100644
--- a/App.xaml
+++ b/App.xaml
@@ -6,7 +6,6 @@
-
diff --git a/App.xaml.cs b/App.xaml.cs
index 9ff3eb2..b07a4d5 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -1,12 +1,15 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+using RigCheck.Localization;
using RigCheck.Logging;
using RigCheck.Services;
using RigCheck.ViewModels;
using RigCheck.Views;
using Serilog;
using System.IO;
+using System.Net.Http;
using System.Windows;
+using System.Windows.Threading;
namespace RigCheck;
@@ -18,19 +21,67 @@ protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
- ConfigureSerilog();
+ // Settings are read before the host exists because the log level lives there.
+ var earlySettings = new SettingsService();
+ AppLogger.Configure(AppLogger.ResolveLevel(earlySettings.Current.LogLevel));
_host = Host.CreateDefaultBuilder()
.UseSerilog()
- .ConfigureServices(RegisterServices)
+ .ConfigureServices(s => RegisterServices(s, earlySettings))
.Build();
await _host.StartAsync();
+ Log.Information("{App} {Version} ({Channel}, built {Built}) starting",
+ BrandingInfo.AppName, BrandingInfo.Version, BuildInfo.Channel, BuildInfo.BuildDate);
+
+ // An expired alpha shows only the expiry notice and exits. Beta builds
+ // keep running past expiry and warn in the status strip instead.
+ if (BuildInfo.IsExpired && BuildInfo.BlocksWhenExpired)
+ {
+ Log.Warning("Alpha build expired on {Expiry}; refusing to start", BuildInfo.ExpiryDate);
+ new ExpiredDialog().ShowDialog();
+ Shutdown();
+ return;
+ }
+
+ var settings = _host.Services.GetRequiredService();
+ var telemetry = _host.Services.GetRequiredService();
+ Resources["Settings"] = settings;
+
+ DispatcherUnhandledException += (_, e) => OnUnhandledException(e, telemetry);
+
+ // Create the main window before any dialog. ShutdownMode is
+ // OnMainWindowClose, and WPF treats the first window shown as the
+ // main window — so a dialog shown first would end the app when closed.
var mainWindow = _host.Services.GetRequiredService();
+ MainWindow = mainWindow;
+
+ // Ask once about anonymous diagnostics before anything is sent.
+ if (!settings.Current.TelemetryPrompted)
+ new FirstRunDialog(settings).ShowDialog();
+
mainWindow.Show();
- Log.Information("{App} {Version} started", BrandingInfo.AppName, BrandingInfo.Version);
+ // Fire-and-forget: neither call may delay the window or fail loudly.
+ _ = telemetry.FlushPendingAsync();
+ _ = telemetry.ReportStartupAsync(_host.Services.GetRequiredService());
+ }
+
+ // Log, report if allowed, tell the operator, and exit. Swallowing the
+ // exception would leave the app in an unknown state.
+ private void OnUnhandledException(DispatcherUnhandledExceptionEventArgs e, TelemetryService telemetry)
+ {
+ Log.Fatal(e.Exception, "Unhandled exception");
+ try { telemetry.ReportCrashAsync(e.Exception).Wait(TimeSpan.FromSeconds(3)); } catch { /* best effort */ }
+
+ MessageBox.Show(
+ Strings.Format("Crash_Message", BrandingInfo.AppName),
+ Strings.Format("Crash_Title", BrandingInfo.AppName),
+ MessageBoxButton.OK, MessageBoxImage.Error);
+
+ e.Handled = true;
+ Shutdown(1);
}
protected override async void OnExit(ExitEventArgs e)
@@ -49,17 +100,18 @@ protected override async void OnExit(ExitEventArgs e)
// ── Service registration ─────────────────────────────────────────────
- private static void RegisterServices(IServiceCollection services)
+ private static void RegisterServices(IServiceCollection services, SettingsService earlySettings)
{
// Infrastructure
services.AddSingleton();
- services.AddSingleton();
+ services.AddSingleton(earlySettings);
+ services.AddSingleton(new HttpClient { Timeout = TimeSpan.FromSeconds(5) });
+ services.AddSingleton();
// Hamlib / rig control
services.AddSingleton();
services.AddSingleton();
services.AddTransient();
- services.AddTransient();
// Domain
services.AddSingleton();
@@ -73,34 +125,9 @@ private static void RegisterServices(IServiceCollection services)
services.AddTransient();
services.AddTransient();
services.AddTransient();
- services.AddTransient();
- services.AddSingleton();
- services.AddSingleton();
// Views
services.AddTransient();
}
- // ── Logging setup ────────────────────────────────────────────────────
-
- private static void ConfigureSerilog()
- {
- var logDir = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- BrandingInfo.SuiteName,
- BrandingInfo.AppName,
- BrandingInfo.LogFolder);
-
- Directory.CreateDirectory(logDir);
-
- Log.Logger = new LoggerConfiguration()
- .MinimumLevel.Debug()
- .WriteTo.File(
- path: Path.Combine(logDir, BrandingInfo.LogFileName),
- rollingInterval: RollingInterval.Day,
- retainedFileCountLimit: 7,
- outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
- .WriteTo.Debug()
- .CreateLogger();
- }
}
diff --git a/Assets/radio_presets.json b/Assets/radio_presets.json
new file mode 100644
index 0000000..e88e72e
--- /dev/null
+++ b/Assets/radio_presets.json
@@ -0,0 +1,124 @@
+[
+ {
+ "_comment": "RigCheck by ShackDesk — quick-start radio presets. Selecting one fills the connection fields; the operator can override any field. HamlibModelId is from `rigctl -l`. BaudRate 0 means let Hamlib use the model default. Popularity orders the list (higher first). Validate JSON at jsonlint.com before submitting. Community entries welcome — see CONTRIBUTING.md."
+ },
+ {
+ "Name": "Icom IC-7300",
+ "Manufacturer": "Icom",
+ "HamlibModelId": 3073,
+ "BaudRate": 19200,
+ "PttMethod": "CAT",
+ "Popularity": 100,
+ "Notes": "CI-V address default 0x94. USB cable to the radio's USB port; driver is Silicon Labs CP210x."
+ },
+ {
+ "Name": "Icom IC-705",
+ "Manufacturer": "Icom",
+ "HamlibModelId": 3085,
+ "BaudRate": 115200,
+ "PttMethod": "CAT",
+ "Popularity": 90,
+ "Notes": "CI-V address default 0xA4. USB cable to the radio's USB port."
+ },
+ {
+ "Name": "Yaesu FT-991A",
+ "Manufacturer": "Yaesu",
+ "HamlibModelId": 1035,
+ "BaudRate": 38400,
+ "PttMethod": "CAT",
+ "Popularity": 85,
+ "Notes": "USB cable to the radio's USB port. Set CAT RATE to 38400 in the radio menu."
+ },
+ {
+ "Name": "Yaesu FT-891",
+ "Manufacturer": "Yaesu",
+ "HamlibModelId": 1036,
+ "BaudRate": 38400,
+ "PttMethod": "CAT",
+ "Popularity": 70,
+ "Notes": "USB cable to the radio's USB port. Set CAT RATE in the radio menu to match."
+ },
+ {
+ "Name": "Yaesu FT-DX10",
+ "Manufacturer": "Yaesu",
+ "HamlibModelId": 1042,
+ "BaudRate": 38400,
+ "PttMethod": "CAT",
+ "Popularity": 60,
+ "Notes": "USB cable to the radio's USB port."
+ },
+ {
+ "Name": "Yaesu FT-DX101D",
+ "Manufacturer": "Yaesu",
+ "HamlibModelId": 1040,
+ "BaudRate": 38400,
+ "PttMethod": "CAT",
+ "Popularity": 40,
+ "Notes": "USB cable to the radio's USB port."
+ },
+ {
+ "Name": "Icom IC-7610",
+ "Manufacturer": "Icom",
+ "HamlibModelId": 3078,
+ "BaudRate": 115200,
+ "PttMethod": "CAT",
+ "Popularity": 55,
+ "Notes": "CI-V address default 0x98. The radio presents two COM ports; use the first (CI-V) one."
+ },
+ {
+ "Name": "Icom IC-9700",
+ "Manufacturer": "Icom",
+ "HamlibModelId": 3081,
+ "BaudRate": 115200,
+ "PttMethod": "CAT",
+ "Popularity": 50,
+ "Notes": "CI-V address default 0xA2. The radio presents two COM ports; use the first (CI-V) one."
+ },
+ {
+ "Name": "Kenwood TS-590SG",
+ "Manufacturer": "Kenwood",
+ "HamlibModelId": 2037,
+ "BaudRate": 115200,
+ "FlowControl": "Hardware",
+ "PttMethod": "CAT",
+ "Popularity": 50,
+ "Notes": "Enable hardware flow control (RTS/CTS) in the radio menu to match."
+ },
+ {
+ "Name": "Kenwood TS-890S",
+ "Manufacturer": "Kenwood",
+ "HamlibModelId": 2041,
+ "BaudRate": 115200,
+ "FlowControl": "Hardware",
+ "PttMethod": "CAT",
+ "Popularity": 35,
+ "Notes": ""
+ },
+ {
+ "Name": "Elecraft K3",
+ "Manufacturer": "Elecraft",
+ "HamlibModelId": 2029,
+ "BaudRate": 38400,
+ "PttMethod": "CAT",
+ "Popularity": 45,
+ "Notes": ""
+ },
+ {
+ "Name": "Elecraft KX3",
+ "Manufacturer": "Elecraft",
+ "HamlibModelId": 2045,
+ "BaudRate": 38400,
+ "PttMethod": "CAT",
+ "Popularity": 45,
+ "Notes": ""
+ },
+ {
+ "Name": "Elecraft KX2",
+ "Manufacturer": "Elecraft",
+ "HamlibModelId": 2044,
+ "BaudRate": 38400,
+ "PttMethod": "CAT",
+ "Popularity": 35,
+ "Notes": ""
+ }
+]
diff --git a/Assets/rigcheck.ico b/Assets/rigcheck.ico
new file mode 100644
index 0000000..5a9888c
Binary files /dev/null and b/Assets/rigcheck.ico differ
diff --git a/Assets/usb_devices.json b/Assets/usb_devices.json
new file mode 100644
index 0000000..f761aec
--- /dev/null
+++ b/Assets/usb_devices.json
@@ -0,0 +1,86 @@
+[
+ {
+ "_comment": "RigCheck by ShackDesk — USB serial device database. VID/PID identify the USB-to-serial chip in a cable or radio, not usually the radio itself; entries hint at what the chip is commonly found in and suggest presets to try. CableHint may be plain text or a Strings.resx key. Validate JSON at jsonlint.com before submitting. Community entries welcome — see CONTRIBUTING.md."
+ },
+ {
+ "Vid": "10C4",
+ "Pid": "EA60",
+ "Manufacturer": "Silicon Labs",
+ "Chip": "CP2102 / CP2102N",
+ "CableHint": "Silicon Labs CP210x — built into many Icom radios (IC-7300, IC-705) and used by DigiRig",
+ "RadioFamily": null,
+ "SuggestedPresets": ["Icom IC-7300", "Icom IC-705"]
+ },
+ {
+ "Vid": "10C4",
+ "Pid": "EA70",
+ "Manufacturer": "Silicon Labs",
+ "Chip": "CP2105 (dual port)",
+ "CableHint": "Silicon Labs CP2105 dual serial — built into Icom radios with two virtual COM ports (IC-7610, IC-9700)",
+ "RadioFamily": null,
+ "SuggestedPresets": ["Icom IC-7610", "Icom IC-9700"]
+ },
+ {
+ "Vid": "10C4",
+ "Pid": "EA71",
+ "Manufacturer": "Silicon Labs",
+ "Chip": "CP2108 (quad port)",
+ "CableHint": "Silicon Labs CP2108 quad serial — multi-port radio interfaces",
+ "RadioFamily": null,
+ "SuggestedPresets": []
+ },
+ {
+ "Vid": "0403",
+ "Pid": "6001",
+ "Manufacturer": "FTDI",
+ "Chip": "FT232R",
+ "CableHint": "FTDI FT232R — very common in CAT and PTT cables for all radio brands",
+ "RadioFamily": null,
+ "SuggestedPresets": []
+ },
+ {
+ "Vid": "0403",
+ "Pid": "6015",
+ "Manufacturer": "FTDI",
+ "Chip": "FT231X",
+ "CableHint": "FTDI FT231X — newer FTDI serial chip, common in CAT cables",
+ "RadioFamily": null,
+ "SuggestedPresets": []
+ },
+ {
+ "Vid": "1A86",
+ "Pid": "7523",
+ "Manufacturer": "QinHeng",
+ "Chip": "CH340",
+ "CableHint": "CH340 — inexpensive USB serial, common in low-cost CAT cables; needs the CH340 driver",
+ "RadioFamily": null,
+ "SuggestedPresets": []
+ },
+ {
+ "Vid": "1A86",
+ "Pid": "55D4",
+ "Manufacturer": "QinHeng",
+ "Chip": "CH9102",
+ "CableHint": "CH9102 — newer QinHeng serial chip",
+ "RadioFamily": null,
+ "SuggestedPresets": []
+ },
+ {
+ "Vid": "067B",
+ "Pid": "2303",
+ "Manufacturer": "Prolific",
+ "Chip": "PL2303",
+ "CableHint": "Prolific PL2303 — generic USB serial adapter; older chips need a legacy driver on Windows 10/11",
+ "RadioFamily": null,
+ "SuggestedPresets": []
+ },
+ {
+ "Vid": "0839",
+ "Pid": "000A",
+ "Manufacturer": "Yaesu",
+ "Chip": "SCU-17",
+ "CableHint": "Yaesu SCU-17 USB interface — CAT and audio for Yaesu radios",
+ "RadioFamily": "Yaesu",
+ "SuggestedPresets": ["Yaesu FT-991A", "Yaesu FT-DX10"]
+ }
+]
diff --git a/BrandingInfo.cs b/BrandingInfo.cs
index e4fc97d..e38c650 100644
--- a/BrandingInfo.cs
+++ b/BrandingInfo.cs
@@ -12,20 +12,39 @@ public static class BrandingInfo
public const string AppName = "RigCheck";
public const string SuiteName = "ShackDesk";
public const string FullName = "RigCheck by ShackDesk";
- public const string Version = "0.6.0";
+ public const string Version = "0.6.4";
public const string Tagline = "Know your rig is ready";
// ── Developer / publisher ────────────────────────────────────
public const string Developer = "Mark McDow N4TEK";
public const string Company = "My Computer Guru LLC";
public const string Callsign = "N4TEK";
+ public const string Copyright = "© 2025–2026 My Computer Guru LLC";
// ── URLs ─────────────────────────────────────────────────────
- public const string SuiteUrl = "https://shackdesk.com";
- public const string AppUrl = "https://shackdesk.com/rigcheck";
- public const string GitHubOrg = "https://github.com/Computer-Tsu";
- public const string GitHubRepo = "https://github.com/Computer-Tsu/ShackDesk-RigCheck";
- public const string IssueUrl = "https://github.com/Computer-Tsu/ShackDesk-RigCheck/issues";
+ public const string SuiteUrl = "https://shackdesk.com";
+ public const string AppUrl = "https://shackdesk.com/rigcheck/";
+ public const string HelpUrl = "https://shackdesk.com/faq/#rigcheck";
+ public const string GitHubOrg = "https://github.com/Computer-Tsu";
+ public const string GitHubRepo = "https://github.com/Computer-Tsu/ShackDesk-RigCheck";
+ public const string ReleasesUrl = "https://github.com/Computer-Tsu/ShackDesk-RigCheck/releases";
+ public const string IssueUrl = "https://github.com/Computer-Tsu/ShackDesk-RigCheck/issues";
+ public const string PrivacyUrl = "https://shackdesk.com/privacy/";
+
+ // ── Telemetry ─────────────────────────────────────────────────
+ // Shared ShackDesk endpoint; see ShackDesk-Backend for the schema.
+ public const string TelemetryEndpoint = "https://telemetry.shackdesk.com/report";
+
+ // ── Build channels and expiry ─────────────────────────────────
+ // Alpha builds stop running after AlphaExpiryDays so testers stay on
+ // current builds and end users are steered away from alphas. Beta
+ // builds warn but keep working. Stable builds never expire.
+ public const string ChannelAlpha = "alpha";
+ public const string ChannelBeta = "beta";
+ public const string ChannelStable = "stable";
+ public const int AlphaExpiryDays = 30;
+ public const int BetaExpiryDays = 90;
+ public const int ExpiryWarnDays = 9; // warn from day 21 of a 30-day alpha
// ── Hamlib / rigctl resources ─────────────────────────────────
public const string HamlibDownloadUrl = "https://github.com/Hamlib/Hamlib/releases";
@@ -33,14 +52,19 @@ public static class BrandingInfo
public const string HamlibDocsUrl = "https://hamlib.github.io";
// ── Defaults ──────────────────────────────────────────────────
+ // Serial settings default to "let Hamlib use the radio model's own
+ // defaults" — most operators don't know these values, and the model
+ // database usually does. 0 / RadioDefault means the flag is omitted
+ // from the rigctl command.
public const string DefaultRigctldHost = "localhost";
public const int DefaultRigctldPort = 4532;
- public const int DefaultBaudRate = 9600;
- public const string DefaultDataBits = "8";
- public const string DefaultParity = "None";
- public const string DefaultStopBits = "1";
- public const string DefaultFlowCtrl = "None";
- public const string DefaultPttMethod = "CAT";
+ public const int DefaultBaudRate = 0;
+ public const string RadioDefault = "Default";
+ public const string DefaultDataBits = RadioDefault;
+ public const string DefaultParity = RadioDefault;
+ public const string DefaultStopBits = RadioDefault;
+ public const string DefaultFlowCtrl = RadioDefault;
+ public const string DefaultPttMethod = RadioDefault;
// ── Window / layout ───────────────────────────────────────────
public const double DefaultWindowWidth = 720;
diff --git a/BuildInfo.cs b/BuildInfo.cs
new file mode 100644
index 0000000..ccb954e
--- /dev/null
+++ b/BuildInfo.cs
@@ -0,0 +1,58 @@
+using System.Reflection;
+
+namespace RigCheck;
+
+///
+/// Channel, build date, and expiry for the running binary.
+///
+/// Both values are stamped into the assembly at compile time from MSBuild
+/// properties (see RigCheck.csproj) because deterministic builds strip
+/// file timestamps, so nothing else about the binary says when it was built.
+/// CI passes Channel and BuildDateUtc explicitly; a local build defaults
+/// to channel "dev" with today's date and never expires.
+///
+public static class BuildInfo
+{
+ public static string Channel { get; }
+ public static DateOnly? BuildDate { get; }
+
+ static BuildInfo()
+ {
+ var meta = typeof(BuildInfo).Assembly
+ .GetCustomAttributes()
+ .ToDictionary(a => a.Key, a => a.Value ?? string.Empty);
+
+ Channel = meta.GetValueOrDefault("Channel", "dev").ToLowerInvariant();
+
+ BuildDate = DateOnly.TryParseExact(
+ meta.GetValueOrDefault("BuildDateUtc", string.Empty),
+ "yyyy-MM-dd", out var date) ? date : null;
+ }
+
+ public static bool IsAlpha => Channel == BrandingInfo.ChannelAlpha;
+ public static bool IsBeta => Channel == BrandingInfo.ChannelBeta;
+ public static bool IsStable => Channel == BrandingInfo.ChannelStable;
+
+ /// Version with channel suffix for display, e.g. "0.6.2-alpha". Stable shows the bare version.
+ public static string VersionLabel =>
+ IsStable ? BrandingInfo.Version : $"{BrandingInfo.Version}-{Channel}";
+
+ /// Last day this build runs, or null for channels that never expire.
+ public static DateOnly? ExpiryDate => BuildDate is not { } built ? null : Channel switch
+ {
+ BrandingInfo.ChannelAlpha => built.AddDays(BrandingInfo.AlphaExpiryDays),
+ BrandingInfo.ChannelBeta => built.AddDays(BrandingInfo.BetaExpiryDays),
+ _ => null,
+ };
+
+ public static int? DaysUntilExpiry =>
+ ExpiryDate is { } exp ? exp.DayNumber - DateOnly.FromDateTime(DateTime.UtcNow).DayNumber : null;
+
+ public static bool IsExpired => DaysUntilExpiry is < 0;
+
+ /// True in the final days before expiry, when the UI should start warning.
+ public static bool IsExpiringSoon => DaysUntilExpiry is >= 0 and <= BrandingInfo.ExpiryWarnDays;
+
+ /// Alpha builds refuse to run once expired; beta builds only warn.
+ public static bool BlocksWhenExpired => IsAlpha;
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 140ee0e..a8086b0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,121 @@ All notable changes to RigCheck are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Version numbers: patch (x.x.1) for landed features and fixes, minor (x.1.0) for milestones.
+## [Unreleased]
+
+### Fixed
+- The app closed after Continue on the first-run diagnostics dialog, because that dialog was the
+ first window shown and WPF treated it as the main window
+
+## [0.6.4] - 2026-09-21
+
+Radio and cable databases as data files.
+
+### Added
+- `Assets/radio_presets.json` (13 rigs, ordered by popularity) and `Assets/usb_devices.json`
+ (9 USB-serial chips with cable hints and suggested presets), embedded in the exe
+- Data files can be overridden without a rebuild: a copy next to the exe, in `Assets\` beside it,
+ or in `%LOCALAPPDATA%\ShackDesk\Data\` takes precedence over the embedded one
+- Presets for Yaesu FT-891 and Elecraft KX2
+- A cable hint in the USB database may be a translation key or plain text
+
+### Changed
+- Presets are no longer compiled into the program
+- Alpha release tags use the same `yyyyMMdd` stamp as the filename
+
+### Removed
+- The "Flex 6300" preset, which pointed at Hamlib model 1 — the dummy rig — and would have
+ reported every test as passing without touching a radio
+
+### Fixed
+- Five presets had the wrong Hamlib model number and would have driven the wrong radio protocol:
+ FT-DX10 (1053 → 1042), FT-DX101D (1049 → 1040), TS-590SG (2031 → 2037),
+ TS-890S (2047 → 2041), KX3 (2030 → 2045). All 13 now verified against Hamlib's `riglist.h`.
+
+## [0.6.3] - 2026-09-21
+
+Expiry, diagnostics, settings, and automated alpha releases.
+
+### Added
+- Every push to `develop` is published automatically as a dated alpha pre-release; alpha releases
+ older than 30 days are removed. A weekly scheduled build keeps a current alpha available.
+- Build channel and date stamped into the assembly at compile time; alpha builds expire
+ 30 days after they are built and refuse to start afterwards, beta builds warn after 90 days,
+ stable builds never expire
+- Expiry date shown in the window title, in Help > About, and in the status strip during
+ the final nine days
+- Help > Download latest build (opens the releases page)
+- Alpha/beta/stable channels and expiry documented in the README
+- Anonymous diagnostics, opt-in via a first-run prompt or Settings, sent to the shared ShackDesk
+ telemetry endpoint: a startup report and a per-test-run report with radio model, serial settings,
+ USB cable identifiers, and test outcomes. Every report is stored locally and viewable under
+ Help > View collected data. Support ID shown in Help > About.
+- Settings window: diagnostics toggle, log level (Off / Errors only / Normal / Detailed —
+ Detailed by default on test builds), log folder path with Open and Delete buttons, Support ID
+ with Copy and Reset. Changes apply on OK; Cancel discards them.
+- Unhandled exceptions are logged, reported if diagnostics are on, and shown to the operator
+ before the app closes
+
+### Changed
+- Help > Contents opens the RigCheck product page instead of the site's general FAQ
+- CI artifacts are named `RigCheck-{version}-{channel}-{date}-{sha}.exe` and the SHA-256 file
+ lists the filename it applies to
+- `actions/setup-dotnet` updated to v5 (Node 20 runtime deprecation)
+
+## [0.6.2] - 2026-09-21
+
+First launch feedback and the move to .NET 10.
+
+### Added
+- Menu bar: Settings (placeholder) and Help with Contents (F1, opens the online FAQ) and About
+- About window showing branding, version, developer, license, and links
+- Localization foundation: `Strings.resx` with a `Strings` accessor and `{loc:Str}` XAML markup extension. New UI text is in the resource file; existing text migrates next.
+- External-link glyph on every control that opens a web browser
+- Placeholder text in the radio and COM port dropdowns when nothing is selected
+- Status strip tells the operator what still needs choosing before tests can run
+- Dependabot, CodeQL analysis, and dependency review workflows
+
+### Changed
+- Target runtime moved from .NET 8 to .NET 10 (LTS, supported through 2028-11-14).
+ No change to supported Windows versions; see the Platform section of the README.
+- Microsoft.Extensions, System.Management, System.IO.Ports, and Serilog.Extensions.Hosting
+ packages updated to their 10.x releases
+- Radio and COM port start unselected instead of defaulting to Hamlib's dummy rig and the first port.
+ Run Tests is disabled until both are chosen.
+- Baud rate, PTT, data bits, parity, stop bits, and flow control default to "Radio default",
+ which omits the setting so Hamlib uses the model's own defaults
+- Dropdowns use a dark-themed template; items and the selected value are readable
+- Serial-setting dropdowns use minimum widths so translated labels fit
+
+### Fixed
+- S-meter reading interpreted Hamlib's dB-relative-to-S9 value as dBm, so an S9 signal displayed as "S9+73"
+
+## [0.6.1] - 2026-09-21
+
+First successful build. The application compiles in CI and produces a
+self-contained executable.
+
+### Added
+- CI build workflow producing the self-contained exe and its SHA-256 as an artifact
+- `app.manifest` — runs as standard user, per-monitor DPI aware
+- Placeholder application icon (to be replaced with final artwork)
+- Help topics shown alongside timeout and no-response failures
+- Main window code-behind: window position persistence, console Enter/Up/Down key handling
+- Raw console command history persists across sessions
+- Dark theme: color palette, control styles, and value converters for the main window
+
+### Changed
+- Zoom applies a layout transform to the window content instead of a render transform on the window, so content reflows rather than clipping
+- `DiagnosticResult` and `HelpTopic` moved to the Models namespace
+- Zoom buttons bind to ViewModel commands instead of code-behind handlers
+
+### Removed
+- Dependency-injection registrations for services and ViewModels not yet implemented
+
+### Fixed
+- rigctl timeout was never detected — `Task.WaitAsync` throws on timeout rather than returning false
+- Missing `System.IO.Ports` and `Serilog.Extensions.Hosting` package references
+
## [0.6.0] - 2026-09-20
Project structure and licensing milestone. No functional changes to the application.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4961707..89a9f14 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -17,6 +17,7 @@ Thank you for your interest in contributing to RigCheck by ShackDesk.
| Bug report | Open an issue with your exported RigCheck log attached |
| Feature request | Open an issue describing the problem you are trying to solve |
| Radio or cable database entry | Open an issue or submit a PR editing the data files under `Assets/` |
+| Translation | See [TRANSLATING.md](TRANSLATING.md) — no programming needed |
| Code | Fork, branch, and submit a pull request (see below) |
## CLA
@@ -31,7 +32,7 @@ All contributors must agree to the [Contributor License Agreement](CLA.md) befor
- **Logging via Serilog only.** No `Console.WriteLine`, `Debug.WriteLine`, or `Trace`.
- **Offline-first.** Nothing may block startup or a diagnostic run on network access. Network features fail silently when offline.
- **Query-only during discovery.** Code that probes an unidentified serial device must never send a command that changes radio state.
-- **C# 12 / .NET 8** target only.
+- **.NET 10** target only. See the Platform section of the README for why.
## Pull Request Process
diff --git a/Localization/StrExtension.cs b/Localization/StrExtension.cs
new file mode 100644
index 0000000..52d4ae7
--- /dev/null
+++ b/Localization/StrExtension.cs
@@ -0,0 +1,20 @@
+using System.Windows.Markup;
+
+namespace RigCheck.Localization;
+
+///
+/// XAML markup extension that resolves a Strings.resx key at load time.
+/// Usage: Header="{loc:Str Menu_Help}" with
+/// xmlns:loc="clr-namespace:RigCheck.Localization".
+///
+[MarkupExtensionReturnType(typeof(string))]
+public sealed class StrExtension : MarkupExtension
+{
+ public string Key { get; set; } = string.Empty;
+
+ public StrExtension() { }
+ public StrExtension(string key) => Key = key;
+
+ public override object ProvideValue(IServiceProvider serviceProvider) =>
+ Strings.Get(Key);
+}
diff --git a/Localization/Strings.cs b/Localization/Strings.cs
new file mode 100644
index 0000000..82e718e
--- /dev/null
+++ b/Localization/Strings.cs
@@ -0,0 +1,52 @@
+using System.Globalization;
+using System.Resources;
+
+namespace RigCheck.Localization;
+
+///
+/// Access to user-visible text in Resources/Strings.resx and its per-language
+/// variants (Strings.de.resx, Strings.ja.resx, …).
+///
+/// A missing key returns "!Key!" rather than throwing, so a typo is visible
+/// in the UI instead of crashing the app. Brand names are never stored here —
+/// they are injected through format placeholders from BrandingInfo so
+/// translators cannot alter them.
+///
+public static class Strings
+{
+ private static readonly ResourceManager Manager =
+ new("RigCheck.Resources.Strings", typeof(Strings).Assembly);
+
+ /// Text for in the current UI language.
+ public static string Get(string key) =>
+ Manager.GetString(key, CultureInfo.CurrentUICulture) ?? $"!{key}!";
+
+ ///
+ /// Text for if it exists. Lets data files carry
+ /// either a resource key (translated) or literal text (passed through).
+ ///
+ public static bool TryGet(string key, out string text)
+ {
+ var found = string.IsNullOrEmpty(key) ? null : Manager.GetString(key, CultureInfo.CurrentUICulture);
+ text = found ?? string.Empty;
+ return found is not null;
+ }
+
+ /// Text for with {0}, {1}… placeholders filled.
+ public static string Format(string key, params object[] args) =>
+ string.Format(CultureInfo.CurrentCulture, Get(key), args);
+
+ ///
+ /// Override the UI language for this session. Called at startup from the
+ /// saved setting; null or empty restores the Windows UI language.
+ ///
+ public static void SetUiCulture(string? cultureName)
+ {
+ var culture = string.IsNullOrWhiteSpace(cultureName)
+ ? CultureInfo.InstalledUICulture
+ : CultureInfo.GetCultureInfo(cultureName);
+
+ CultureInfo.CurrentUICulture = culture;
+ CultureInfo.DefaultThreadCurrentUICulture = culture;
+ }
+}
diff --git a/Logging/AppLogger.cs b/Logging/AppLogger.cs
index e182e58..27732a9 100644
--- a/Logging/AppLogger.cs
+++ b/Logging/AppLogger.cs
@@ -1,29 +1,103 @@
using Serilog;
+using Serilog.Events;
+using System.IO;
namespace RigCheck.Logging;
///
-/// Thin wrapper around Serilog for structured logging.
-/// Provides a contextual logger for classes that need one.
-/// Mirrors the PortPane AppLogger pattern.
+/// Owns the Serilog configuration so the level can change at runtime from
+/// Settings and the log files can be released and deleted on request.
+/// Instance members are a thin contextual wrapper for classes that take
+/// a logger by injection.
///
public class AppLogger
{
- public ILogger ForContext() =>
- Log.ForContext();
+ // ── Levels offered in Settings ────────────────────────────────────────
+ // Stored as these strings; empty means "channel default".
+ public const string LevelOff = "Off";
+ public const string LevelErrors = "Error";
+ public const string LevelNormal = "Information";
+ public const string LevelDetailed = "Debug";
- public ILogger ForContext(string propertyName, object value) =>
- Log.ForContext(propertyName, value);
+ public static string LogDirectory { get; } = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ BrandingInfo.SuiteName, BrandingInfo.AppName, BrandingInfo.LogFolder);
- public void Info(string messageTemplate, params object[] args) =>
- Log.Information(messageTemplate, args);
+ public static string CurrentLevel { get; private set; } = LevelNormal;
- public void Debug(string messageTemplate, params object[] args) =>
- Log.Debug(messageTemplate, args);
+ /// Test-channel builds log in detail by default; stable logs normally.
+ public static string ChannelDefaultLevel =>
+ BuildInfo.IsAlpha || BuildInfo.IsBeta ? LevelDetailed : LevelNormal;
- public void Warn(string messageTemplate, params object[] args) =>
- Log.Warning(messageTemplate, args);
+ /// Resolve a stored setting ("" = channel default) to a concrete level.
+ public static string ResolveLevel(string? stored) =>
+ string.IsNullOrEmpty(stored) ? ChannelDefaultLevel : stored;
- public void Error(Exception ex, string messageTemplate, params object[] args) =>
- Log.Error(ex, messageTemplate, args);
+ // ── Configuration ─────────────────────────────────────────────────────
+
+ /// Build (or rebuild) the global logger at the given level. Safe to call repeatedly.
+ public static void Configure(string level)
+ {
+ Log.CloseAndFlush();
+ CurrentLevel = level;
+
+ if (level == LevelOff)
+ {
+ Log.Logger = new LoggerConfiguration().CreateLogger();
+ return;
+ }
+
+ Directory.CreateDirectory(LogDirectory);
+
+ var minimum = level switch
+ {
+ LevelErrors => LogEventLevel.Error,
+ LevelDetailed => LogEventLevel.Debug,
+ _ => LogEventLevel.Information,
+ };
+
+ Log.Logger = new LoggerConfiguration()
+ .MinimumLevel.Is(minimum)
+ .WriteTo.File(
+ path: Path.Combine(LogDirectory, BrandingInfo.LogFileName),
+ rollingInterval: RollingInterval.Day,
+ retainedFileCountLimit: 7,
+ outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
+ .WriteTo.Debug()
+ .CreateLogger();
+ }
+
+ ///
+ /// Delete every log file. The logger is closed first so the current
+ /// day's file is released, then reopened at the same level.
+ /// Returns the number of files removed.
+ ///
+ public static int DeleteLogs()
+ {
+ Log.CloseAndFlush();
+
+ var removed = 0;
+ if (Directory.Exists(LogDirectory))
+ {
+ foreach (var file in Directory.GetFiles(LogDirectory, "*.log"))
+ {
+ try { File.Delete(file); removed++; }
+ catch { /* a file held by another process stays; the rest go */ }
+ }
+ }
+
+ Configure(CurrentLevel);
+ Log.Information("Log files deleted by user ({Count} removed)", removed);
+ return removed;
+ }
+
+ // ── Contextual wrapper ────────────────────────────────────────────────
+
+ public ILogger ForContext() => Log.ForContext();
+ public ILogger ForContext(string propertyName, object value) => Log.ForContext(propertyName, value);
+
+ public void Info(string messageTemplate, params object[] args) => Log.Information(messageTemplate, args);
+ public void Debug(string messageTemplate, params object[] args) => Log.Debug(messageTemplate, args);
+ public void Warn(string messageTemplate, params object[] args) => Log.Warning(messageTemplate, args);
+ public void Error(Exception ex, string messageTemplate, params object[] args) => Log.Error(ex, messageTemplate, args);
}
diff --git a/Models/Models.cs b/Models/Models.cs
index afc5069..c31656f 100644
--- a/Models/Models.cs
+++ b/Models/Models.cs
@@ -53,18 +53,19 @@ public enum TestStatus
/// DisplayCommand is the rigctl command the user can copy and run themselves.
///
public record TestResult(
- TestId Id,
- TestStatus Status,
- string Message,
- string DisplayCommand,
- DiagnosticResult? Diagnosis = null)
+ TestId Id,
+ TestStatus Status,
+ string Message,
+ string DisplayCommand,
+ DiagnosticResult? Diagnosis = null,
+ Services.RigctlError? Error = null)
{
public static TestResult Pass(TestId id, string message, string displayCommand) =>
new(id, TestStatus.Pass, message, displayCommand);
public static TestResult Fail(TestId id, string message, string displayCommand,
- DiagnosticResult diagnosis) =>
- new(id, TestStatus.Fail, message, displayCommand, diagnosis);
+ DiagnosticResult diagnosis, Services.RigctlError? error = null) =>
+ new(id, TestStatus.Fail, message, displayCommand, diagnosis, error);
public static TestResult Warning(TestId id, string message, string displayCommand) =>
new(id, TestStatus.Warning, message, displayCommand);
@@ -123,6 +124,7 @@ public record RadioPreset
public string FlowControl { get; init; } = "None";
public string PttMethod { get; init; } = "CAT";
public string Notes { get; init; } = string.Empty; // e.g. "CI-V address default 94"
+ public int Popularity { get; init; } = 0; // higher sorts first in the list
}
// ── COM port info ─────────────────────────────────────────────────────────
@@ -155,11 +157,35 @@ public record ComPortInfo(
public bool HasRadioHint => RadioFamily is not null;
}
-// ── Diagnostic result (referenced by TestResult) ──────────────────────────
-// Defined in DiagnosisEngine.cs — declared here for the record type reference
-// to avoid circular namespace issues. The concrete record is in Services.
-// Re-exported here so Models namespace is self-contained for consumers.
+// ── Diagnosis ─────────────────────────────────────────────────────────────
-// NOTE: DiagnosticResult is defined in RigCheck.Services to keep service
-// logic together. TestResult holds a nullable reference to it.
-// No re-export needed — consumers reference RigCheck.Services directly.
+///
+/// A short help article surfaced alongside a failed test.
+///
+public record HelpTopic(string Title, string Body);
+
+///
+/// Plain-English explanation of a failed test: what happened, what to check,
+/// and an optional command or link that may fix it.
+///
+public record DiagnosticResult(
+ string Summary,
+ string[] Checks,
+ string? FixCommand,
+ string? LearnMoreUrl,
+ string? RawError = null,
+ HelpTopic[] HelpTopics = null!)
+{
+ public HelpTopic[] HelpTopics { get; init; } = HelpTopics ?? [];
+
+ public static readonly DiagnosticResult Ok = new(
+ Summary: string.Empty,
+ Checks: [],
+ FixCommand: null,
+ LearnMoreUrl: null);
+
+ public bool HasChecks => Checks.Length > 0;
+ public bool HasFixCommand => FixCommand is not null;
+ public bool HasHelpTopics => HelpTopics.Length > 0;
+ public bool IsOk => string.IsNullOrEmpty(Summary);
+}
diff --git a/README.md b/README.md
index 3acfa0f..a763ab9 100644
--- a/README.md
+++ b/README.md
@@ -11,10 +11,108 @@ Brand: ShackDesk
Developer: Mark McDow N4TEK / My Computer Guru LLC
GitHub: github.com/Computer-Tsu
Suite site: shackdesk.com
-Technology: C# WPF .NET 8, MVVM architecture,
+Technology: C# WPF .NET 10, MVVM architecture,
dependency injection, Serilog logging,
GitHub Actions CI/CD, no local compiler
+## Platform
+
+RigCheck runs on Windows 10 (version 21H2 or later)
+and Windows 11, 64-bit. Nothing else needs to be
+installed: the .NET runtime is bundled inside the
+single RigCheck.exe.
+
+Windows 11 on ARM runs it through the built-in x64
+emulation. Windows 7 and 8.1 are not supported.
+
+### Why .NET 10
+
+RigCheck moved from .NET 8 to .NET 10 in September
+2026. This changed nothing about which versions of
+Windows it runs on — .NET 8 and .NET 10 support the
+identical list of Windows client versions, and
+neither supports Windows 7 or 8.1.
+
+The reason is support lifetime. .NET 8 reaches end
+of support on November 10, 2026, after which the
+runtime bundled inside every RigCheck build would
+stop receiving security fixes. .NET 10 is the
+current long-term-support release, supported through
+November 14, 2028.
+
+### Build channels and expiry
+
+RigCheck is published in three channels:
+
+- **Alpha** — built from every change on the
+ `develop` branch. **Alpha builds stop running
+ 30 days after they were built.** This keeps
+ testers on current code and steers everyday
+ users toward stable releases. The expiry date
+ is shown in the window title and in Help >
+ About. When an alpha expires, starting it shows
+ a notice with a link to the latest build.
+- **Beta** — tagged pre-releases. Beta builds warn
+ in the status bar 90 days after they were built
+ but keep running.
+- **Stable** — tagged releases. Never expire.
+
+The window title shows the version, channel, and
+expiry date, for example
+`RigCheck by ShackDesk 0.6.2-alpha — expires 2026-10-21`.
+
+### Where RigCheck keeps its files
+
+RigCheck is a single portable exe and writes only
+to your local application data folder. Nothing goes
+in the registry.
+
+```
+%LOCALAPPDATA%\ShackDesk\RigCheck\
+ rigcheck-settings.json all settings, plain JSON
+ Logs\ daily log files, 7 kept
+ Telemetry\ local copies of diagnostic reports
+```
+
+Delete `rigcheck-settings.json` to reset every
+setting to its default. Settings › Logging shows
+the log folder and can open or empty it.
+
+### Anonymous diagnostics
+
+On first launch RigCheck asks whether it may send
+anonymous diagnostic reports. Nothing is sent
+unless you say yes, and you can change the choice
+in Settings at any time. Every report is also
+stored locally and can be inspected under
+Help > View collected data.
+
+What is sent: the RigCheck and Windows versions,
+whether Hamlib was found, and after each test run
+the radio model, serial settings, USB cable
+identifiers, and which tests passed or failed.
+This is what improves the radio and cable
+database for everyone.
+
+Never sent: callsign, computer name, file paths,
+serial numbers, or IP address. The only identifier
+is a random ID created on first run, shown in
+Help > About as a Support ID. Reports go to the
+shared ShackDesk endpoint; see
+shackdesk.com/privacy for the full policy.
+
+### Hamlib
+
+RigCheck does not include Hamlib. It finds the
+rigctl.exe already on the computer from any of:
+
+- WSJT-X (includes Hamlib) — wsjt.sourceforge.io
+- Fldigi (includes Hamlib) — w1hkj.com
+- Standalone Hamlib for Windows —
+ github.com/Hamlib/Hamlib/releases
+
+Most operators already have WSJT-X installed.
+
## RigCheck Purpose
Hamlib is the open source radio control library used
@@ -75,7 +173,7 @@ Test 4: Get PTT state
Fail: with diagnostic suggestion
Test 5: Get signal meter (S-meter)
- Pass: "Signal strength: S7 (-73 dBm)"
+ Pass: "Signal strength: S7 (-85 dBm)"
Fail: with diagnostic suggestion
Test 6: Get VFO
diff --git a/Resources/DarkTheme.xaml b/Resources/DarkTheme.xaml
new file mode 100644
index 0000000..ecf50a4
--- /dev/null
+++ b/Resources/DarkTheme.xaml
@@ -0,0 +1,43 @@
+
+
+
+
+
+ #FF1B1E23
+ #FF23272E
+ #FF2B3038
+ #FF111316
+ #FF3A4049
+
+
+ #FFE6E8EB
+ #FF8B929C
+
+
+ #FF4FB3BF
+ #FF6AC5D0
+ #FF5FCF80
+ #FFE85D5D
+ #FFE9B949
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Resources/Strings.resx b/Resources/Strings.resx
new file mode 100644
index 0000000..f3e7f1a
--- /dev/null
+++ b/Resources/Strings.resx
@@ -0,0 +1,338 @@
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+
+
+ _Settings
+ Top-level menu item. Opens the settings window.
+
+
+ _Help
+ Top-level menu item.
+
+
+ _Contents
+ Help menu item. Opens the online help page in a web browser.
+
+
+ _About {0}
+ Help menu item. {0} is the application name.
+
+
+
+
+ Close
+ Button that closes a dialog window.
+
+
+
+
+ Settings
+ Window title.
+
+
+ Settings will appear here in a future release.
+
+
+
+
+ About {0}
+ Window title. {0} is the application name.
+
+
+ Version {0}
+ {0} is a version number such as 0.6.2.
+
+
+ Developed by {0}
+ {0} is the developer's name and callsign.
+
+
+ Licensed under {0}
+ {0} is the license name, e.g. GPL v3.
+
+
+ Website
+ Link that opens the product web page in a browser.
+
+
+ Source code
+ Link that opens the GitHub repository in a browser.
+
+
+
+ {0} build, compiled {1}
+ {0} is the channel (alpha, beta, stable); {1} is a date like 2026-09-21.
+
+
+ This {0} build expires on {1}.
+ {0} is the channel; {1} is a date like 2026-10-21.
+
+
+
+
+ expires {0}
+ Appended to the window title on alpha and beta builds. {0} is a date.
+
+
+ This {0} build expires in {1} day(s), on {2}. Download the current build from the Help menu.
+ Status strip message in the final days before expiry. {0} channel, {1} number of days, {2} date.
+
+
+ This {0} build expires today. Download the current build from the Help menu.
+ Status strip message on the last day. {0} is the channel.
+
+
+ This beta build expired on {0}. It still runs, but a newer build is available.
+ Status strip message on an expired beta. {0} is a date.
+
+
+ {0} build expired
+ Window title of the dialog shown when an expired alpha is started. {0} is the app name.
+
+
+ This alpha build of {0} expired on {1}.
+
+Alpha builds stop running after {2} days so that testers always use current code. Please download the latest build.
+ {0} app name, {1} date, {2} number of days.
+
+
+ Download latest build
+ Button. Opens the releases page in a web browser.
+
+
+ _Download latest build
+ Help menu item. Opens the releases page in a web browser.
+
+
+
+
+ Welcome to {0}
+ Window title of the first-run dialog. {0} is the app name.
+
+
+ {0} can send anonymous diagnostic reports that help improve the radio and cable database for everyone.
+ {0} is the app name.
+
+
+ Send anonymous diagnostic reports
+ Checkbox label in the first-run dialog and in Settings.
+
+
+ What is sent: the {0} version and Windows version, whether Hamlib was found, and after each test run the radio model, serial settings, USB cable identifiers, and which tests passed or failed.
+ {0} is the app name.
+
+
+ Never sent: your callsign, computer name, file paths, serial numbers, or IP address. The only identifier is a random ID created when you first ran the program.
+
+
+ You can change this at any time in Settings, and see every report under Help.
+
+
+ Privacy policy
+ Link that opens the privacy policy in a web browser.
+
+
+ Continue
+ Button that closes the first-run dialog.
+
+
+ Anonymous diagnostics
+ Section heading in Settings.
+
+
+ View collected data…
+ Button in Settings that opens the telemetry data viewer.
+
+
+ Logging
+ Section heading in Settings.
+
+
+ Write to log files:
+ Label before the log level dropdown.
+
+
+ Off
+
+
+ Errors only
+
+
+ Normal
+
+
+ Detailed (recommended for test builds)
+
+
+ Log files are stored in:
+
+
+ Open log folder
+ Button. Opens the log folder in Windows Explorer.
+
+
+ Delete log files
+ Button.
+
+
+ Delete all {0} log files? This cannot be undone.
+ Confirmation question. {0} is the app name.
+
+
+ {0} log file(s) deleted.
+ {0} is a number.
+
+
+ Support ID
+ Section heading in Settings.
+
+
+ A random identifier created when you first ran the program. It is the only identifier ever sent in diagnostic reports, and lets a support request be matched to them. Resetting it breaks that link for all past reports and deletes their local copies.
+
+
+ Reset Support ID…
+ Button.
+
+
+ Create a new Support ID? Past diagnostic reports can no longer be matched to this installation, and their local copies will be deleted.
+ Confirmation question.
+
+
+ OK
+ Button. Saves changes and closes the dialog.
+
+
+ Cancel
+ Button. Discards changes and closes the dialog.
+
+
+ _View collected data
+ Help menu item. Opens the telemetry data viewer.
+
+
+ Collected data
+ Window title.
+
+
+ Every diagnostic report this copy of {0} has sent or queued, newest first. Reports marked "pending" could not be sent yet and will be retried at the next start.
+ {0} is the app name.
+
+
+ No reports have been created yet.
+
+
+ Delete local copies
+ Button. Deletes the locally stored report copies (does not affect what was already sent).
+
+
+ Support ID: {0}
+ {0} is a random identifier. Users can quote it in support requests.
+
+
+ Quote this ID in a support request to match it with your diagnostic reports, if you have enabled them.
+
+
+ Copy
+ Button that copies text to the clipboard.
+
+
+ {0} needs to close
+ {0} is the app name.
+
+
+ {0} ran into an unexpected error and needs to close.
+
+Details have been written to the log. If this keeps happening, please report it with the exported log attached.
+ {0} is the app name.
+
+
+
+
+ Choose your radio from the Quick preset list.
+ Shown in the status strip until a radio is selected.
+
+
+ Select the COM port your radio is connected to.
+ Shown in the status strip until a port is selected.
+
+
+ Choose your radio…
+ Grey placeholder text inside the empty preset dropdown.
+
+
+ Select a port…
+ Grey placeholder text inside the empty COM port dropdown.
+
+
+
+
+
+ Radio default
+ First choice in every serial setting dropdown. Means: let Hamlib decide.
+
+
+ None
+
+
+ Even
+ Serial parity.
+
+
+ Odd
+ Serial parity.
+
+
+ Mark
+ Serial parity.
+
+
+ Space
+ Serial parity.
+
+
+ Hardware
+ Serial flow control (RTS/CTS).
+
+
+ Software
+ Serial flow control (XON/XOFF).
+
+
+ CAT
+ PTT method. Amateur radio term, usually left untranslated.
+
+
+ RTS
+ PTT method via serial RTS line. Usually left untranslated.
+
+
+ DTR
+ PTT method via serial DTR line. Usually left untranslated.
+
+
+ VOX
+ PTT method. Amateur radio term, usually left untranslated.
+
+
diff --git a/Resources/Styles.xaml b/Resources/Styles.xaml
new file mode 100644
index 0000000..28ebb13
--- /dev/null
+++ b/Resources/Styles.xaml
@@ -0,0 +1,497 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RigCheck.csproj b/RigCheck.csproj
index 5dc465c..090c153 100644
--- a/RigCheck.csproj
+++ b/RigCheck.csproj
@@ -27,7 +27,7 @@
WinExe
- net8.0-windows
+ net10.0-windows
enable
enable
true
@@ -35,11 +35,11 @@
RigCheck
Assets\rigcheck.ico
app.manifest
- 0.6.0
- 0.6.0.0
- 0.6.0.0
+ 0.6.4
+ 0.6.4.0
+ 0.6.4.0
My Computer Guru LLC
- Copyright © 2025 My Computer Guru LLC
+ Copyright © 2025-2026 My Computer Guru LLC
RigCheck by ShackDesk
Ham radio rig control diagnostics — Know your rig is ready
Mark McDow N4TEK
@@ -61,19 +61,41 @@
CS8600;CS8601;CS8602;CS8603;CS8604
+
+
+ dev
+ $([System.DateTime]::UtcNow.ToString('yyyy-MM-dd'))
+
+
+
+
+ <_Parameter1>Channel
+ <_Parameter2>$(Channel)
+
+
+ <_Parameter1>BuildDateUtc
+ <_Parameter2>$(BuildDateUtc)
+
+
+
-
+
-
-
+
+
-
-
+
+
+
@@ -83,17 +105,15 @@
-
-
-
-
-
-
- PreserveNewest
-
-
- PreserveNewest
-
+
+
+
+
+
diff --git a/Services/ComPortService.cs b/Services/ComPortService.cs
index d337f3a..d0cceaa 100644
--- a/Services/ComPortService.cs
+++ b/Services/ComPortService.cs
@@ -109,34 +109,30 @@ private static List GetPortsFallback()
// ── USB database ──────────────────────────────────────────────────────
+ // External copy first, embedded copy last — see DataFileLocator.
+ // A CableHint may be a Strings.resx key (localized) or plain text (passed through).
private static List LoadUsbDatabase()
{
try
{
- var candidates = new[]
- {
- Path.Combine(AppContext.BaseDirectory, "usb_devices.json"),
- Path.Combine(AppContext.BaseDirectory, "Assets", "usb_devices.json"),
- };
-
- foreach (var path in candidates)
- {
- if (!File.Exists(path)) continue;
- var json = File.ReadAllText(path);
- var db = JsonConvert.DeserializeObject>(json);
- if (db is not null)
- {
- Log.Debug("USB device database: {Count} entries from {Path}", db.Count, path);
- return db;
- }
- }
+ var json = DataFileLocator.ReadAllText("usb_devices.json");
+ if (json is null) return [];
+
+ var db = JsonConvert.DeserializeObject>(json)?
+ .Where(e => !string.IsNullOrEmpty(e.Vid))
+ .ToList() ?? [];
+
+ foreach (var e in db)
+ e.CableHint = Localization.Strings.TryGet(e.CableHint, out var text) ? text : e.CableHint;
+
+ Log.Debug("USB device database: {Count} entries", db.Count);
+ return db;
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to load USB device database — cable hints unavailable");
+ return [];
}
-
- return [];
}
private UsbDeviceEntry? LookupUsb(string vid, string? pid)
diff --git a/Services/DataFileLocator.cs b/Services/DataFileLocator.cs
new file mode 100644
index 0000000..7323428
--- /dev/null
+++ b/Services/DataFileLocator.cs
@@ -0,0 +1,66 @@
+using Serilog;
+using System.IO;
+
+namespace RigCheck.Services;
+
+///
+/// Finds a data file such as usb_devices.json, external copy first,
+/// embedded copy last. The embedded copy keeps the single portable exe
+/// self-sufficient; an external copy beside the exe or in the shared
+/// ShackDesk folder overrides it, so the data can be updated separately
+/// from the program or shared with other ShackDesk apps later.
+///
+/// Search order:
+/// 1. next to the exe — a tester drops a newer file in
+/// 2. Assets\ next to the exe — a source-tree layout
+/// 3. %LOCALAPPDATA%\ShackDesk\Data\ — shared across suite apps
+/// 4. embedded resource — always present
+///
+public static class DataFileLocator
+{
+ private static readonly string SharedDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ BrandingInfo.SuiteName, "Data");
+
+ /// Open the first copy found. The stream's origin is logged. Returns null only if the embedded copy is missing.
+ public static Stream? Open(string fileName)
+ {
+ foreach (var path in ExternalCandidates(fileName))
+ {
+ if (!File.Exists(path)) continue;
+ try
+ {
+ Log.Information("Data file {Name} loaded from {Path}", fileName, path);
+ return File.OpenRead(path);
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "Data file {Path} exists but could not be opened; trying next location", path);
+ }
+ }
+
+ var resource = $"{BrandingInfo.AppName}.Assets.{fileName}";
+ var stream = typeof(DataFileLocator).Assembly.GetManifestResourceStream(resource);
+ if (stream is null)
+ Log.Warning("Data file {Name} not found anywhere, including embedded resource {Resource}", fileName, resource);
+ else
+ Log.Debug("Data file {Name} loaded from embedded resource", fileName);
+ return stream;
+ }
+
+ /// Read the first copy found as text, or null.
+ public static string? ReadAllText(string fileName)
+ {
+ using var stream = Open(fileName);
+ if (stream is null) return null;
+ using var reader = new StreamReader(stream);
+ return reader.ReadToEnd();
+ }
+
+ private static IEnumerable ExternalCandidates(string fileName)
+ {
+ yield return Path.Combine(AppContext.BaseDirectory, fileName);
+ yield return Path.Combine(AppContext.BaseDirectory, "Assets", fileName);
+ yield return Path.Combine(SharedDir, fileName);
+ }
+}
diff --git a/Services/DiagnosisEngine.cs b/Services/DiagnosisEngine.cs
index 14b32a5..9f1a23b 100644
--- a/Services/DiagnosisEngine.cs
+++ b/Services/DiagnosisEngine.cs
@@ -73,7 +73,7 @@ public DiagnosticResult Diagnose(RigctlResult result, ConnectionConfig cfg)
Checks:
[
"Is the radio powered on?",
- $"Is the baud rate correct? Your radio manual lists the CAT baud rate. Currently set to {cfg.BaudRate}.",
+ $"Is the baud rate correct? Your radio manual lists the CAT baud rate. Currently set to {(cfg.BaudRate > 0 ? cfg.BaudRate.ToString() : "the radio's default")}.",
"Is the CAT / CI-V / RS-232 cable connected to the correct port on the radio?",
"Does your radio need CAT control enabled in its menu? Check the manual for \"CAT\", \"CI-V\", or \"RS-232\" settings.",
"Some radios use a different data bits / parity / stop bits setting. Check the radio manual.",
@@ -137,28 +137,3 @@ public DiagnosticResult Diagnose(RigctlResult result, ConnectionConfig cfg)
LearnMoreUrl: BrandingInfo.IssueUrl,
RawError: rawMessage);
}
-
-// ── Result type ───────────────────────────────────────────────────────────
-
-public record DiagnosticResult(
- string Summary,
- string[] Checks,
- string? FixCommand,
- string? LearnMoreUrl,
- string? RawError = null,
- HelpTopic[] HelpTopics = null!)
-{
- // Ensure HelpTopics is never null
- public HelpTopic[] HelpTopics { get; init; } = HelpTopics ?? [];
-
- public static readonly DiagnosticResult Ok = new(
- Summary: string.Empty,
- Checks: [],
- FixCommand: null,
- LearnMoreUrl: null);
-
- public bool HasChecks => Checks.Length > 0;
- public bool HasFixCommand => FixCommand is not null;
- public bool HasHelpTopics => HelpTopics.Length > 0;
- public bool IsOk => string.IsNullOrEmpty(Summary);
-}
diff --git a/Services/HamlibRunnerService.cs b/Services/HamlibRunnerService.cs
index 39b2d05..d2c4074 100644
--- a/Services/HamlibRunnerService.cs
+++ b/Services/HamlibRunnerService.cs
@@ -78,11 +78,13 @@ public async Task RunAsync(RigctlCommand command,
process.BeginOutputReadLine();
process.BeginErrorReadLine();
- var completed = await process
- .WaitForExitAsync(ct)
- .WaitAsync(TimeSpan.FromMilliseconds(TimeoutMs), ct);
-
- if (!completed)
+ try
+ {
+ await process
+ .WaitForExitAsync(ct)
+ .WaitAsync(TimeSpan.FromMilliseconds(TimeoutMs), ct);
+ }
+ catch (TimeoutException)
{
try { process.Kill(); } catch { /* best effort */ }
return RigctlResult.Failure(command, RigctlError.Timeout,
diff --git a/Services/HelpContent.cs b/Services/HelpContent.cs
new file mode 100644
index 0000000..bddc611
--- /dev/null
+++ b/Services/HelpContent.cs
@@ -0,0 +1,38 @@
+using RigCheck.Models;
+
+namespace RigCheck.Services;
+
+///
+/// Help articles shown alongside specific failure types, aimed at operators
+/// who have never configured CAT control before.
+///
+public static class HelpContent
+{
+ public static HelpTopic[] TopicsForError(RigctlError error) => error switch
+ {
+ RigctlError.Timeout => [EnableCat, BaudRateMatch],
+ RigctlError.NoResponse => [BaudRateMatch, CivAddress],
+ _ => [],
+ };
+
+ private static readonly HelpTopic EnableCat = new(
+ "Enabling CAT control on your radio",
+ "Most radios ship with CAT control disabled or set to a default speed. " +
+ "Look in the radio's menu for a setting named CAT, CI-V, RS-232, or PC control " +
+ "and make sure it is turned on. Icom radios call this CI-V; Yaesu and Kenwood " +
+ "call it CAT. The exact menu number is in your radio's manual under \"remote control\".");
+
+ private static readonly HelpTopic BaudRateMatch = new(
+ "Matching the baud rate",
+ "The baud rate set in RigCheck must match the rate set inside the radio. " +
+ "Common defaults: Icom IC-7300 and IC-705 use 19200 or 115200; Yaesu FT-991A and " +
+ "FT-DX10 use 38400; Kenwood TS-590SG uses 115200; Elecraft K3 uses 38400. " +
+ "If unsure, try 9600 first — nearly every radio supports it.");
+
+ private static readonly HelpTopic CivAddress = new(
+ "Icom CI-V address",
+ "Icom radios each answer to a CI-V address, shown in the radio's menu as a hex " +
+ "number such as 94h. Hamlib uses the default address for the selected model. " +
+ "If the address has been changed in the radio, either reset it to the default " +
+ "or add the matching address in the advanced serial settings.");
+}
diff --git a/Services/LogExportService.cs b/Services/LogExportService.cs
index 93fe8a5..a040c43 100644
--- a/Services/LogExportService.cs
+++ b/Services/LogExportService.cs
@@ -69,8 +69,8 @@ public string BuildReport(TestSuiteResult suite)
{
sb.AppendLine($" Mode: Direct serial");
sb.AppendLine($" COM port: {cfg.ComPort}");
- sb.AppendLine($" Baud rate: {cfg.BaudRate}");
- sb.AppendLine($" Data bits: {cfg.DataBits}");
+ sb.AppendLine($" Baud rate: {(cfg.BaudRate > 0 ? cfg.BaudRate.ToString() : "radio default")}");
+ sb.AppendLine($" Data bits: {(cfg.DataBits > 0 ? cfg.DataBits.ToString() : "radio default")}");
sb.AppendLine($" Parity: {cfg.Parity}");
sb.AppendLine($" Stop bits: {cfg.StopBits}");
sb.AppendLine($" Flow ctrl: {cfg.FlowControl}");
diff --git a/Services/RadioPresetsService.cs b/Services/RadioPresetsService.cs
index ff848b0..2ccc2ae 100644
--- a/Services/RadioPresetsService.cs
+++ b/Services/RadioPresetsService.cs
@@ -1,170 +1,57 @@
using RigCheck.Models;
using Serilog;
-using System.IO;
using Newtonsoft.Json;
namespace RigCheck.Services;
///
-/// Provides quick-start presets for popular radios.
-/// Built-in presets are compiled in; optional user-defined presets
-/// can extend them from radio_presets.json in the assets folder.
+/// Quick-start presets for popular radios, loaded from radio_presets.json
+/// through DataFileLocator (an external copy overrides the embedded one).
+/// Presets are ordered by Popularity so the most common rigs come first.
///
-/// Selecting a preset fills all connection configuration fields.
-/// The user can override any field after applying a preset.
+/// Selecting a preset fills all connection configuration fields; the
+/// operator can override any field afterwards.
///
public class RadioPresetsService
{
+ private const string FileName = "radio_presets.json";
+
private readonly List _presets;
public IReadOnlyList Presets => _presets;
public RadioPresetsService()
{
- _presets = [..BuiltInPresets()];
- TryLoadUserPresets();
+ _presets = Load();
}
public RadioPreset? FindByName(string name) =>
_presets.FirstOrDefault(p =>
p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
- // ── Built-in presets ──────────────────────────────────────────────────
- // Hamlib model IDs: https://github.com/Hamlib/Hamlib/wiki/Supported-Radios
- // These are known-good settings from the community.
-
- private static IEnumerable BuiltInPresets() =>
- [
- new RadioPreset
- {
- Name = "Icom IC-7300",
- Manufacturer = "Icom",
- HamlibModelId = 3073,
- BaudRate = 19200,
- PttMethod = "CAT",
- Notes = "CI-V address default 0x94. USB cable to radio USB port.",
- },
- new RadioPreset
- {
- Name = "Icom IC-705",
- Manufacturer = "Icom",
- HamlibModelId = 3085,
- BaudRate = 115200,
- PttMethod = "CAT",
- Notes = "CI-V address default 0xA4. USB cable to radio USB port.",
- },
- new RadioPreset
- {
- Name = "Icom IC-7610",
- Manufacturer = "Icom",
- HamlibModelId = 3078,
- BaudRate = 115200,
- PttMethod = "CAT",
- Notes = "CI-V address default 0x98.",
- },
- new RadioPreset
- {
- Name = "Icom IC-9700",
- Manufacturer = "Icom",
- HamlibModelId = 3081,
- BaudRate = 115200,
- PttMethod = "CAT",
- Notes = "CI-V address default 0xA2.",
- },
- new RadioPreset
- {
- Name = "Yaesu FT-991A",
- Manufacturer = "Yaesu",
- HamlibModelId = 1035,
- BaudRate = 38400,
- PttMethod = "CAT",
- Notes = "Use USB cable to radio USB port. Enable CAT in radio menu.",
- },
- new RadioPreset
- {
- Name = "Yaesu FT-DX10",
- Manufacturer = "Yaesu",
- HamlibModelId = 1053,
- BaudRate = 38400,
- PttMethod = "CAT",
- Notes = "USB cable to radio USB port.",
- },
- new RadioPreset
- {
- Name = "Yaesu FT-DX101D",
- Manufacturer = "Yaesu",
- HamlibModelId = 1049,
- BaudRate = 38400,
- PttMethod = "CAT",
- Notes = "USB cable to radio USB port.",
- },
- new RadioPreset
- {
- Name = "Kenwood TS-590SG",
- Manufacturer = "Kenwood",
- HamlibModelId = 2031,
- BaudRate = 115200,
- FlowControl = "Hardware",
- PttMethod = "CAT",
- Notes = "Enable hardware flow control (RTS/CTS) on radio.",
- },
- new RadioPreset
- {
- Name = "Kenwood TS-890S",
- Manufacturer = "Kenwood",
- HamlibModelId = 2047,
- BaudRate = 115200,
- FlowControl = "Hardware",
- PttMethod = "CAT",
- },
- new RadioPreset
- {
- Name = "Elecraft K3",
- Manufacturer = "Elecraft",
- HamlibModelId = 2029,
- BaudRate = 38400,
- PttMethod = "CAT",
- },
- new RadioPreset
- {
- Name = "Elecraft KX3",
- Manufacturer = "Elecraft",
- HamlibModelId = 2030,
- BaudRate = 38400,
- PttMethod = "CAT",
- },
- new RadioPreset
- {
- Name = "Flex 6300 (SmartSDR)",
- Manufacturer = "FlexRadio",
- HamlibModelId = 1, // Use rigctld / network mode for Flex
- BaudRate = 4800,
- PttMethod = "CAT",
- Notes = "Flex radios typically use rigctld network mode. See SmartSDR documentation.",
- },
- ];
-
- // ── Optional user-defined presets ─────────────────────────────────────
+ // ── Loading ───────────────────────────────────────────────────────────
- private void TryLoadUserPresets()
+ private static List Load()
{
try
{
- var path = Path.Combine(
- AppContext.BaseDirectory, "Assets", "radio_presets.json");
-
- if (!File.Exists(path)) return;
+ var json = DataFileLocator.ReadAllText(FileName);
+ if (json is null) return [];
- var json = File.ReadAllText(path);
- var userPresets = JsonConvert.DeserializeObject>(json);
- if (userPresets is null) return;
+ // Entries with a _comment key are documentation for contributors, not presets.
+ var presets = JsonConvert.DeserializeObject>(json)?
+ .Where(p => !string.IsNullOrEmpty(p.Name) && p.HamlibModelId > 1)
+ .OrderByDescending(p => p.Popularity)
+ .ThenBy(p => p.Name)
+ .ToList() ?? [];
- _presets.AddRange(userPresets);
- Log.Information("Loaded {Count} user-defined radio presets", userPresets.Count);
+ Log.Information("Loaded {Count} radio presets", presets.Count);
+ return presets;
}
catch (Exception ex)
{
- Log.Warning(ex, "Failed to load user radio presets — using built-ins only");
+ Log.Warning(ex, "Failed to load {File} — no presets available", FileName);
+ return [];
}
}
}
diff --git a/Services/RigctlCommandBuilder.cs b/Services/RigctlCommandBuilder.cs
index b54bbaa..633f4ad 100644
--- a/Services/RigctlCommandBuilder.cs
+++ b/Services/RigctlCommandBuilder.cs
@@ -21,14 +21,22 @@ public class RigctlCommandBuilder
///
/// Build the connection arguments common to every rigctl command
- /// for a direct serial connection.
+ /// for a direct serial connection. A baud rate of 0 omits -s so
+ /// rigctl falls back to the model's default serial speed.
///
- public static RigctlCommand SerialArgs(ConnectionConfig cfg) =>
- new(
- Args: ["-m", cfg.ModelId.ToString(),
- "-r", cfg.ComPort,
- "-s", cfg.BaudRate.ToString()],
- ConnectionLabel: $"-m {cfg.ModelId} -r {cfg.ComPort} -s {cfg.BaudRate}");
+ public static RigctlCommand SerialArgs(ConnectionConfig cfg)
+ {
+ string[] args = ["-m", cfg.ModelId.ToString(), "-r", cfg.ComPort];
+ var label = $"-m {cfg.ModelId} -r {cfg.ComPort}";
+
+ if (cfg.BaudRate > 0)
+ {
+ args = [..args, "-s", cfg.BaudRate.ToString()];
+ label = $"{label} -s {cfg.BaudRate}";
+ }
+
+ return new(Args: args, ConnectionLabel: label);
+ }
///
/// Build connection args for a rigctld network connection.
diff --git a/Services/SettingsService.cs b/Services/SettingsService.cs
index f19bbae..6dc3ef8 100644
--- a/Services/SettingsService.cs
+++ b/Services/SettingsService.cs
@@ -20,7 +20,7 @@ public class RigCheckSettings
public string StopBits { get; set; } = BrandingInfo.DefaultStopBits;
public string FlowControl { get; set; } = BrandingInfo.DefaultFlowCtrl;
public string PttMethod { get; set; } = BrandingInfo.DefaultPttMethod;
- public int RadioModelId { get; set; } = 1; // Hamlib rig ID
+ public int RadioModelId { get; set; } = 0; // Hamlib rig ID; 0 = none chosen
public string RadioModelName { get; set; } = string.Empty;
// Network / rigctld
@@ -46,6 +46,15 @@ public class RigCheckSettings
// Command history for raw console (most recent first)
public List CommandHistory { get; set; } = [];
+
+ // Telemetry. InstallId is a random GUID created once per install; it is the
+ // only identifier ever sent and lets a support request be matched to reports.
+ public string InstallId { get; set; } = Guid.NewGuid().ToString("D");
+ public bool TelemetryEnabled { get; set; } = false;
+ public bool TelemetryPrompted { get; set; } = false;
+
+ // Logging level; empty means the channel default (see AppLogger).
+ public string LogLevel { get; set; } = string.Empty;
}
// ── Settings service ──────────────────────────────────────────────────────
diff --git a/Services/TelemetryService.cs b/Services/TelemetryService.cs
new file mode 100644
index 0000000..7b2461a
--- /dev/null
+++ b/Services/TelemetryService.cs
@@ -0,0 +1,196 @@
+using RigCheck.Models;
+using Serilog;
+using System.IO;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+
+namespace RigCheck.Services;
+
+///
+/// Sends anonymous diagnostic reports to the ShackDesk telemetry endpoint.
+/// Same payload shape and endpoint as PortPane, so both apps land in the
+/// same database and dashboard.
+///
+/// Rules, in order of importance:
+/// - Nothing is sent unless the operator enabled it (first-run prompt or Settings).
+/// - Nothing here ever blocks the UI or a test run. Sends are fire-and-forget
+/// with a short timeout; failures are queued to disk and retried at next startup.
+/// - Every payload sent or queued is also written locally so the operator can
+/// see exactly what left the machine (Help > View collected data).
+/// - No callsign, machine name, file path, serial number, or IP address is ever
+/// included. The only identifier is a random install ID, so support requests
+/// can be matched to reports if the operator chooses to quote it.
+///
+public sealed class TelemetryService
+{
+ private readonly HttpClient _http;
+ private readonly SettingsService _settings;
+
+ private static readonly string DataDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ BrandingInfo.SuiteName, BrandingInfo.AppName, "Telemetry");
+
+ private const int MaxPending = 10;
+ private const int MaxSentKept = 50;
+ private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true };
+
+ public TelemetryService(HttpClient http, SettingsService settings)
+ {
+ _http = http;
+ _settings = settings;
+ Directory.CreateDirectory(DataDir);
+ }
+
+ public bool IsEnabled => _settings.Current.TelemetryEnabled;
+
+ // ── Events ────────────────────────────────────────────────────────────
+
+ /// Sent once per launch: which channel, and whether Hamlib was found.
+ public Task ReportStartupAsync(HamlibLocatorService hamlib) =>
+ ReportAsync("startup", new Dictionary
+ {
+ ["channel"] = BuildInfo.Channel,
+ ["hamlib_found"] = hamlib.IsAvailable,
+ ["hamlib_via"] = hamlib.FoundVia,
+ });
+
+ ///
+ /// Sent after a test run. This is the report that improves the radio
+ /// presets database: which cable, which model, which settings, and
+ /// which tests passed or failed.
+ ///
+ public Task ReportTestRunAsync(TestSuiteResult suite, ComPortInfo? port) =>
+ ReportAsync("test_run", new Dictionary
+ {
+ ["model_id"] = suite.Config.ModelId,
+ ["model_name"] = suite.Config.RadioModelName,
+ ["baud"] = suite.Config.BaudRate,
+ ["use_rigctld"] = suite.Config.UseRigctld,
+ ["port_vid"] = port?.Vid,
+ ["port_pid"] = port?.Pid,
+ ["cable_family"] = port?.RadioFamily,
+ ["passed"] = suite.PassCount,
+ ["failed"] = suite.FailCount,
+ ["results"] = suite.Results.Select(r => new
+ {
+ test = r.Id.ToString(),
+ status = r.Status.ToString(),
+ error = r.Error?.ToString(),
+ }),
+ });
+
+ /// Exception type and message only — never the stack trace, which can contain file paths.
+ public Task ReportCrashAsync(Exception ex) =>
+ ReportAsync("crash", new Dictionary
+ {
+ ["exception"] = ex.GetType().Name,
+ ["message"] = ex.Message,
+ });
+
+ // ── Pending queue ─────────────────────────────────────────────────────
+
+ /// Retry reports that could not be sent earlier. Called at startup, never awaited by the UI.
+ public async Task FlushPendingAsync()
+ {
+ if (!IsEnabled) return;
+
+ foreach (var file in Directory.GetFiles(DataDir, "pending-*.json").OrderBy(f => f))
+ {
+ try
+ {
+ var json = await File.ReadAllTextAsync(file);
+ if (await PostAsync(json))
+ {
+ File.Move(file, file.Replace("pending-", "sent-"), overwrite: true);
+ Log.Debug("Telemetry: delivered queued report {File}", Path.GetFileName(file));
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Debug(ex, "Telemetry: could not flush {File}", Path.GetFileName(file));
+ }
+ }
+ }
+
+ /// Every report written locally, newest first, for the data viewer.
+ public IReadOnlyList<(string Name, string Json)> GetLocalReports() =>
+ Directory.GetFiles(DataDir, "*.json")
+ .Select(f => new FileInfo(f))
+ .OrderByDescending(f => f.LastWriteTimeUtc)
+ .Select(f => (f.Name, SafeRead(f.FullName)))
+ .ToList();
+
+ public void ClearLocalReports()
+ {
+ foreach (var f in Directory.GetFiles(DataDir, "*.json"))
+ try { File.Delete(f); } catch { /* best effort */ }
+ }
+
+ // ── Internals ─────────────────────────────────────────────────────────
+
+ private async Task ReportAsync(string eventName, Dictionary props)
+ {
+ if (!IsEnabled) return;
+
+ props["install_id"] = _settings.Current.InstallId;
+
+ var id = Guid.NewGuid().ToString();
+ var json = JsonSerializer.Serialize(new
+ {
+ report_id = id,
+ app = BrandingInfo.AppName,
+ version = BuildInfo.VersionLabel,
+ @event = eventName,
+ os = Environment.OSVersion.VersionString,
+ timestamp = DateTimeOffset.UtcNow,
+ props,
+ }, JsonOpts);
+
+ var delivered = await PostAsync(json);
+ WriteLocal(delivered ? $"sent-{id}.json" : $"pending-{id}.json", json);
+ if (delivered) PruneSent(); else PrunePending();
+ }
+
+ private async Task PostAsync(string json)
+ {
+ try
+ {
+ using var body = new StringContent(json, Encoding.UTF8, "application/json");
+ using var resp = await _http.PostAsync(BrandingInfo.TelemetryEndpoint, body);
+ Log.Debug("Telemetry: {Status}", resp.StatusCode);
+ return resp.IsSuccessStatusCode;
+ }
+ catch (Exception ex)
+ {
+ // Offline is the normal case in the field — quiet at Debug level.
+ Log.Debug(ex, "Telemetry: send failed, queuing");
+ return false;
+ }
+ }
+
+ private static void WriteLocal(string name, string json)
+ {
+ try { File.WriteAllText(Path.Combine(DataDir, name), json); }
+ catch (Exception ex) { Log.Debug(ex, "Telemetry: could not write local copy"); }
+ }
+
+ private static void PrunePending() => Prune("pending-*.json", MaxPending);
+ private static void PruneSent() => Prune("sent-*.json", MaxSentKept);
+
+ private static void Prune(string pattern, int keep)
+ {
+ var files = Directory.GetFiles(DataDir, pattern)
+ .Select(f => new FileInfo(f))
+ .OrderByDescending(f => f.LastWriteTimeUtc)
+ .Skip(keep);
+ foreach (var f in files)
+ try { f.Delete(); } catch { /* best effort */ }
+ }
+
+ private static string SafeRead(string path)
+ {
+ try { return File.ReadAllText(path); }
+ catch { return string.Empty; }
+ }
+}
diff --git a/Services/TestRunnerService.cs b/Services/TestRunnerService.cs
index 31427b0..2743cad 100644
--- a/Services/TestRunnerService.cs
+++ b/Services/TestRunnerService.cs
@@ -99,7 +99,7 @@ private async Task TestOpenConnectionAsync(ConnectionConfig cfg, Can
TestId.OpenConnection,
$"Could not connect to radio",
cmd.DisplayCommand,
- diag);
+ diag, result.Error);
}
private async Task TestGetFrequencyAsync(ConnectionConfig cfg, CancellationToken ct)
@@ -124,7 +124,7 @@ private async Task TestGetFrequencyAsync(ConnectionConfig cfg, Cance
TestId.GetFrequency,
"Radio did not respond to frequency query",
cmd.DisplayCommand,
- diag);
+ diag, result.Error);
}
private async Task TestGetModeAsync(ConnectionConfig cfg, CancellationToken ct)
@@ -148,7 +148,7 @@ private async Task TestGetModeAsync(ConnectionConfig cfg, Cancellati
TestId.GetMode,
"Radio did not respond to mode query",
cmd.DisplayCommand,
- diag);
+ diag, result.Error);
}
private async Task TestGetPttAsync(ConnectionConfig cfg, CancellationToken ct)
@@ -170,7 +170,7 @@ private async Task TestGetPttAsync(ConnectionConfig cfg, Cancellatio
TestId.GetPtt,
"Radio did not respond to PTT query",
cmd.DisplayCommand,
- diag);
+ diag, result.Error);
}
private async Task TestGetSmeterAsync(ConnectionConfig cfg, CancellationToken ct)
@@ -195,7 +195,7 @@ private async Task TestGetSmeterAsync(ConnectionConfig cfg, Cancella
TestId.GetSmeter,
"Radio did not respond to S-meter query (some radios don't support this)",
cmd.DisplayCommand,
- diag);
+ diag, result.Error);
}
private async Task TestGetVfoAsync(ConnectionConfig cfg, CancellationToken ct)
@@ -214,7 +214,7 @@ private async Task TestGetVfoAsync(ConnectionConfig cfg, Cancellatio
TestId.GetVfo,
"Radio did not respond to VFO query",
cmd.DisplayCommand,
- diag);
+ diag, result.Error);
}
private async Task TestSetFrequencyAsync(ConnectionConfig cfg, CancellationToken ct)
@@ -228,7 +228,7 @@ private async Task TestSetFrequencyAsync(ConnectionConfig cfg, Cance
TestId.SetFrequency,
"Could not read current frequency before set test",
getCmd.DisplayCommand,
- _diagnosis.Diagnose(getResult, cfg));
+ _diagnosis.Diagnose(getResult, cfg), getResult.Error);
}
// Offset by +1 kHz for the test
@@ -242,7 +242,7 @@ private async Task TestSetFrequencyAsync(ConnectionConfig cfg, Cance
TestId.SetFrequency,
"Failed to set frequency",
setCmd.DisplayCommand,
- _diagnosis.Diagnose(setResult, cfg));
+ _diagnosis.Diagnose(setResult, cfg), setResult.Error);
}
// Read back and verify
@@ -286,13 +286,14 @@ private static bool TryParseFrequency(string raw, out double mhz)
private static bool TryParseSmeter(string raw, out string sLabel, out string dbm)
{
sLabel = "?"; dbm = "?";
- if (!double.TryParse(raw.Trim(), out var val)) return false;
-
- // Hamlib returns S-meter in dBm-ish units (actual values depend on radio)
- // Standard S-unit: S9 = -73 dBm, each S unit = 6 dB below
- dbm = $"{val:F0}";
- var sUnits = Math.Clamp((int)Math.Round((val + 127.0) / 6.0), 0, 9);
- sLabel = sUnits >= 9 ? $"S9+{(int)(val + 73)}" : $"S{sUnits}";
+ if (!double.TryParse(raw.Trim(), out var db)) return false;
+
+ // Hamlib STRENGTH is dB relative to S9: -54 = S0, 0 = S9, +20 = S9+20.
+ // One S-unit is 6 dB. S9 is -73 dBm by the HF convention.
+ dbm = $"{-73 + db:F0}";
+ sLabel = db >= 0
+ ? (db < 1 ? "S9" : $"S9+{db:F0}")
+ : $"S{Math.Clamp((int)Math.Round(9 + db / 6.0), 0, 9)}";
return true;
}
diff --git a/TRANSLATING.md b/TRANSLATING.md
new file mode 100644
index 0000000..b382b85
--- /dev/null
+++ b/TRANSLATING.md
@@ -0,0 +1,79 @@
+# Translating RigCheck
+
+Translations are community-contributed and always welcome. **Any language is
+accepted** — there is no predefined list. You do not need to be a programmer:
+the work is copying one file and translating the text in it.
+
+**Status:** the translation mechanism is in place and working. RigCheck's text
+is being moved into the string file in stages; the file grows with each build.
+A translation made today stays valid — new keys simply fall back to English
+until you add them.
+
+---
+
+## How it works
+
+All user-visible text lives in `Resources/Strings.resx` (English). A
+translation is a copy of that file named for its language:
+
+| Language | File |
+| --- | --- |
+| English (source) | `Resources/Strings.resx` |
+| German | `Resources/Strings.de.resx` |
+| Japanese | `Resources/Strings.ja.resx` |
+| Brazilian Portuguese | `Resources/Strings.pt-BR.resx` |
+
+RigCheck picks the file matching the Windows display language. Any key missing
+from a translation falls back to English, so a partial translation is still
+useful.
+
+## Steps
+
+1. **Check for existing work.** Look at the
+ [open pull requests](https://github.com/Computer-Tsu/ShackDesk-RigCheck/pulls)
+ and [translation issues](https://github.com/Computer-Tsu/ShackDesk-RigCheck/issues?q=label%3Atranslation)
+ for your language before starting, so two people don't translate the same thing.
+2. **Claim it.** Open an issue titled "Translation: " so others know.
+3. **Copy** `Resources/Strings.resx` to `Resources/Strings..resx`, using the
+ IETF language tag (`de`, `fr`, `ja`, `es`, `pt-BR`, `zh-Hans`, …).
+4. **Add a header comment** at the top of your file with the language, your
+ name and callsign, and the date. This is how you are credited.
+5. **Translate only the text inside `` tags.** Do not change the
+ `name="…"` keys — the app uses them to find each string. The ``
+ on each entry explains where the text appears and what any placeholders mean.
+6. **Keep placeholders exactly:** `{0}`, `{1}` are replaced at runtime with values
+ such as the app name or a date.
+7. **Keep keyboard shortcuts sensible:** an underscore before a letter
+ (`_Help`) makes that letter the Alt-key shortcut. Choose a letter that exists
+ in your translation and is not already used by another item in the same menu.
+8. **Expect longer text.** German and French often run 30% longer than English.
+ The layout allows for it, but very long strings may wrap.
+9. **Submit** a pull request, or attach the file to your issue if you prefer not
+ to use git. By submitting you agree to the [CLA](CLA.md).
+
+Machine translation is a fine starting point. What makes a translation good is
+a native-speaking ham reading it through and fixing the radio terms.
+
+## Glossary
+
+**Never translate** — these are names:
+
+ShackDesk · RigCheck · PortPane · Hamlib · rigctl · rigctld · WSJT-X · Fldigi ·
+Flrig · JS8Call · Winlink · OmniRig · radio model names (IC-7300, FT-991A, …) ·
+callsigns
+
+**Usually left in English by hams** — use whatever your local club says:
+
+CAT · CI-V · PTT · VFO · COM port · baud · RTS · DTR · VOX · S-meter
+
+The tagline "Know your rig is ready" is part of the brand and stays in English.
+
+## Credit
+
+Translators are listed by name and callsign in **Help › About** inside RigCheck
+and in `TRANSLATORS.md` in this repository. Your GitHub contribution also
+appears on the repository's contributors page.
+
+## Questions
+
+Ask in [Discussions](https://github.com/Computer-Tsu/ShackDesk-RigCheck/discussions).
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 19e1bad..c5a7f9c 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -1,5 +1,6 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using RigCheck.Localization;
using RigCheck.Models;
using RigCheck.Services;
using Serilog;
@@ -19,6 +20,7 @@ public partial class MainViewModel : ObservableObject
private readonly LogExportService _logExport;
private readonly HamlibLocatorService _hamlib;
private readonly SettingsService _settings;
+ private readonly TelemetryService _telemetry;
public ConnectionViewModel Connection { get; }
public TestResultsViewModel Results { get; }
@@ -28,14 +30,21 @@ public partial class MainViewModel : ObservableObject
[ObservableProperty] private bool _isRunning;
[ObservableProperty] private bool _rawConsoleVisible;
+ [ObservableProperty] private bool _chromeVisible;
[ObservableProperty] private bool _alwaysOnTop;
[ObservableProperty] private double _scaleFactor = 1.0;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private string _hamlibStatus = string.Empty;
- // Title shown in window chrome
+ public bool IsHamlibAvailable => _hamlib.IsAvailable;
+ public bool IsHamlibMissing => !_hamlib.IsAvailable;
+
+ // Title shown in window chrome. Alpha and beta builds always show the
+ // expiry date here so it is visible without opening any dialog.
public string WindowTitle =>
- $"{BrandingInfo.FullName} {BrandingInfo.Version}";
+ BuildInfo.ExpiryDate is { } exp
+ ? $"{BrandingInfo.FullName} {BuildInfo.VersionLabel} — {Strings.Format("Expiry_TitleBar", exp.ToString("yyyy-MM-dd"))}"
+ : $"{BrandingInfo.FullName} {BuildInfo.VersionLabel}";
// ── Constructor ───────────────────────────────────────────────────────
@@ -46,7 +55,8 @@ public MainViewModel(
TestRunnerService testRunner,
LogExportService logExport,
HamlibLocatorService hamlib,
- SettingsService settings)
+ SettingsService settings,
+ TelemetryService telemetry)
{
Connection = connection;
Results = results;
@@ -55,9 +65,23 @@ public MainViewModel(
_logExport = logExport;
_hamlib = hamlib;
_settings = settings;
+ _telemetry = telemetry;
LoadSettings();
CheckHamlib();
+
+ // Run Tests stays disabled until a radio and port are chosen; the
+ // status strip explains what is still missing.
+ Connection.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName is nameof(ConnectionViewModel.IsReady)
+ or nameof(ConnectionViewModel.ReadinessHint))
+ {
+ RunTestsCommand.NotifyCanExecuteChanged();
+ UpdateReadinessStatus();
+ }
+ };
+ UpdateReadinessStatus();
}
// ── Commands ──────────────────────────────────────────────────────────
@@ -98,6 +122,9 @@ private async Task RunTestsAsync()
Log.Information("Test suite complete: {Pass} pass, {Fail} fail, {Warn} warn",
suite.PassCount, suite.FailCount, suite.WarningCount);
+
+ // Fire-and-forget; the report must never delay showing results.
+ _ = _telemetry.ReportTestRunAsync(suite, Connection.SelectedPort);
}
catch (Exception ex)
{
@@ -110,7 +137,7 @@ private async Task RunTestsAsync()
}
}
- private bool CanRunTests() => !IsRunning && _hamlib.IsAvailable;
+ private bool CanRunTests() => !IsRunning && _hamlib.IsAvailable && Connection.IsReady;
[RelayCommand]
private async Task ExportLogAsync()
@@ -140,10 +167,34 @@ private async Task ExportLogAsync()
StatusMessage = "Log export failed — check the application log for details.";
}
+ [RelayCommand]
+ private Task SendRawCommandAsync() =>
+ RawConsole.SendCommandCommand.ExecuteAsync(Connection.BuildConfig());
+
+ [RelayCommand]
+ private void OpenHamlibDownload() => OpenUrl(BrandingInfo.HamlibDownloadUrl);
+
+ [RelayCommand]
+ private void OpenHelp() => OpenUrl(BrandingInfo.HelpUrl);
+
+ [RelayCommand]
+ private void OpenReleases() => OpenUrl(BrandingInfo.ReleasesUrl);
+
+ private static void OpenUrl(string url) =>
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = url,
+ UseShellExecute = true,
+ });
+
[RelayCommand]
private void ToggleRawConsole() =>
RawConsoleVisible = !RawConsoleVisible;
+ [RelayCommand]
+ private void ToggleChrome() =>
+ ChromeVisible = !ChromeVisible;
+
[RelayCommand]
private void ScaleUp() => ScaleFactor = Math.Min(ScaleFactor + 0.1, 2.0);
@@ -155,13 +206,16 @@ private void ToggleRawConsole() =>
// ── Window lifecycle ──────────────────────────────────────────────────
- public void OnWindowClosing()
+ public void OnWindowClosing(double windowLeft, double windowTop)
{
_settings.Update(s =>
{
s.RawConsoleOpen = RawConsoleVisible;
s.ScaleFactor = ScaleFactor;
s.AlwaysOnTop = AlwaysOnTop;
+ s.WindowLeft = windowLeft;
+ s.WindowTop = windowTop;
+ s.CommandHistory = RawConsole.GetHistory();
Connection.SaveTo(s);
});
}
@@ -174,6 +228,7 @@ private void LoadSettings()
RawConsoleVisible = s.RawConsoleOpen;
ScaleFactor = s.ScaleFactor;
AlwaysOnTop = s.AlwaysOnTop;
+ RawConsole.LoadHistory(s.CommandHistory);
Connection.LoadFrom(s);
}
@@ -187,10 +242,38 @@ private void CheckHamlib()
else
{
HamlibStatus = "Hamlib not found — install WSJT-X or download Hamlib";
- StatusMessage = "Hamlib not found. Run Tests will be unavailable until Hamlib is installed.";
Log.Warning("Hamlib not available");
}
RunTestsCommand.NotifyCanExecuteChanged();
}
+
+ // Status strip shows the single most important message. An imminent
+ // expiry outranks everything because nothing else matters once the
+ // build stops running.
+ private void UpdateReadinessStatus()
+ {
+ if (IsRunning) return;
+
+ StatusMessage = ExpiryMessage()
+ ?? (!_hamlib.IsAvailable
+ ? "Hamlib not found. Run Tests will be unavailable until Hamlib is installed."
+ : Connection.ReadinessHint);
+ }
+
+ private static string? ExpiryMessage()
+ {
+ if (BuildInfo.ExpiryDate is not { } exp) return null;
+ var date = exp.ToString("yyyy-MM-dd");
+
+ if (BuildInfo.IsExpired)
+ return BuildInfo.IsBeta ? Strings.Format("Expiry_BetaExpired", date) : null;
+
+ if (!BuildInfo.IsExpiringSoon) return null;
+
+ var days = BuildInfo.DaysUntilExpiry ?? 0;
+ return days == 0
+ ? Strings.Format("Expiry_WarningToday", BuildInfo.Channel)
+ : Strings.Format("Expiry_Warning", BuildInfo.Channel, days, date);
+ }
}
diff --git a/ViewModels/ViewModels.cs b/ViewModels/ViewModels.cs
index e4c1484..9c0a033 100644
--- a/ViewModels/ViewModels.cs
+++ b/ViewModels/ViewModels.cs
@@ -1,5 +1,6 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using RigCheck.Localization;
using RigCheck.Models;
using RigCheck.Services;
using System.Collections.ObjectModel;
@@ -25,32 +26,72 @@ public partial class ConnectionViewModel : ObservableObject
public ObservableCollection AvailablePorts { get; } = [];
public ObservableCollection AvailablePresets { get; } = [];
- public IReadOnlyList BaudRates { get; } =
- [1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200];
+ // Every optional serial setting offers "Radio default" first: the operator
+ // usually doesn't know these, and Hamlib's model database does. Choice
+ // values are what gets stored and passed to rigctl; labels are localized.
+ public IReadOnlyList BaudRates { get; } =
+ [
+ new(0, Strings.Get("Option_RadioDefault")),
+ new(1200, "1200"), new(2400, "2400"), new(4800, "4800"), new(9600, "9600"),
+ new(19200, "19200"), new(38400, "38400"), new(57600, "57600"), new(115200, "115200"),
+ ];
- public IReadOnlyList DataBitsOptions { get; } = ["7", "8"];
- public IReadOnlyList ParityOptions { get; } = ["None", "Even", "Odd", "Mark", "Space"];
- public IReadOnlyList StopBitsOptions { get; } = ["1", "1.5", "2"];
- public IReadOnlyList FlowCtrlOptions { get; } = ["None", "Hardware", "Software"];
- public IReadOnlyList PttMethods { get; } = ["CAT", "RTS", "DTR", "VOX", "None"];
+ public IReadOnlyList DataBitsOptions { get; } =
+ [Choice.RadioDefault, Choice.Literal("7"), Choice.Literal("8")];
+
+ public IReadOnlyList ParityOptions { get; } =
+ [Choice.RadioDefault, Choice.Localized("None"), Choice.Localized("Even"),
+ Choice.Localized("Odd"), Choice.Localized("Mark"), Choice.Localized("Space")];
+
+ public IReadOnlyList StopBitsOptions { get; } =
+ [Choice.RadioDefault, Choice.Literal("1"), Choice.Literal("1.5"), Choice.Literal("2")];
+
+ public IReadOnlyList FlowCtrlOptions { get; } =
+ [Choice.RadioDefault, Choice.Localized("None"), Choice.Localized("Hardware"), Choice.Localized("Software")];
+
+ public IReadOnlyList PttMethods { get; } =
+ [Choice.RadioDefault, Choice.Localized("CAT"), Choice.Localized("RTS"),
+ Choice.Localized("DTR"), Choice.Localized("VOX"), Choice.Localized("None")];
// ── Connection fields ─────────────────────────────────────────────────
- [ObservableProperty] private ComPortInfo? _selectedPort;
- [ObservableProperty] private int _baudRate = 9600;
- [ObservableProperty] private string _dataBits = "8";
- [ObservableProperty] private string _parity = "None";
- [ObservableProperty] private string _stopBits = "1";
- [ObservableProperty] private string _flowControl = "None";
- [ObservableProperty] private string _pttMethod = "CAT";
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsReady), nameof(ReadinessHint))]
+ private ComPortInfo? _selectedPort;
+
+ [ObservableProperty] private int _baudRate = BrandingInfo.DefaultBaudRate;
+ [ObservableProperty] private string _dataBits = BrandingInfo.DefaultDataBits;
+ [ObservableProperty] private string _parity = BrandingInfo.DefaultParity;
+ [ObservableProperty] private string _stopBits = BrandingInfo.DefaultStopBits;
+ [ObservableProperty] private string _flowControl = BrandingInfo.DefaultFlowCtrl;
+ [ObservableProperty] private string _pttMethod = BrandingInfo.DefaultPttMethod;
+
+ // Hamlib model fields. 0 = no radio chosen. Model 1 is Hamlib's dummy
+ // rig, which would make every test pass against a simulation — so it is
+ // never treated as a valid selection.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsReady), nameof(ReadinessHint))]
+ private int _modelId = 0;
- // Hamlib model fields
- [ObservableProperty] private int _modelId = 1;
[ObservableProperty] private string _modelName = string.Empty;
[ObservableProperty] private string _modelSearch = string.Empty;
+ // ── Readiness ─────────────────────────────────────────────────────────
+
+ /// True when enough is configured to run the test suite.
+ public bool IsReady => UseRigctld || (ModelId > 1 && SelectedPort is not null);
+
+ /// What the operator still needs to choose, or empty when ready.
+ public string ReadinessHint =>
+ UseRigctld ? string.Empty
+ : ModelId <= 1 ? Strings.Get("Ready_ChooseRadio")
+ : SelectedPort is null ? Strings.Get("Ready_SelectPort")
+ : string.Empty;
+
// Network / rigctld
- [ObservableProperty] private bool _useRigctld = false;
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsReady), nameof(ReadinessHint))]
+ private bool _useRigctld = false;
[ObservableProperty] private string _rigctldHost = "localhost";
[ObservableProperty] private int _rigctldPort = 4532;
@@ -88,15 +129,17 @@ public ConnectionViewModel(ComPortService ports, RadioPresetsService presets)
LoadPresets();
}
+ // Ports are never auto-selected: many shacks have several COM ports and
+ // guessing wrong sends the operator down the wrong diagnosis path.
[RelayCommand]
public void RefreshPorts()
{
+ var previous = SelectedPort?.PortName;
AvailablePorts.Clear();
foreach (var p in _ports.GetAvailablePorts())
AvailablePorts.Add(p);
- if (SelectedPort is null && AvailablePorts.Count > 0)
- SelectedPort = AvailablePorts[0];
+ SelectedPort = AvailablePorts.FirstOrDefault(p => p.PortName == previous);
}
private void LoadPresets()
@@ -124,7 +167,7 @@ private void ApplyPreset(RadioPreset preset)
RadioModelName = ModelName,
ComPort = SelectedPort?.PortName ?? string.Empty,
BaudRate = BaudRate,
- DataBits = int.Parse(DataBits),
+ DataBits = int.TryParse(DataBits, out var bits) ? bits : 0,
Parity = Parity,
StopBits = StopBits,
FlowControl = FlowControl,
@@ -169,6 +212,26 @@ public void SaveTo(RigCheckSettings s)
}
}
+/// A baud rate choice; Value 0 means omit the flag and let Hamlib decide.
+public record BaudOption(int Value, string Label);
+
+///
+/// A dropdown choice for a serial setting. Value is the stable, language-
+/// independent string that is saved to settings and passed to rigctl;
+/// Label is what the operator sees.
+///
+public record Choice(string Value, string Label)
+{
+ public static readonly Choice RadioDefault =
+ new(BrandingInfo.RadioDefault, Strings.Get("Option_RadioDefault"));
+
+ /// A choice whose label needs no translation (numbers).
+ public static Choice Literal(string value) => new(value, value);
+
+ /// A choice whose label comes from Strings.resx as Option_{value}.
+ public static Choice Localized(string value) => new(value, Strings.Get($"Option_{value}"));
+}
+
// ═══════════════════════════════════════════════════════════════════════════
// TestResultsViewModel
// ═══════════════════════════════════════════════════════════════════════════
diff --git a/Views/AboutDialog.xaml b/Views/AboutDialog.xaml
new file mode 100644
index 0000000..4f81dd2
--- /dev/null
+++ b/Views/AboutDialog.xaml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/AboutDialog.xaml.cs b/Views/AboutDialog.xaml.cs
new file mode 100644
index 0000000..50c625d
--- /dev/null
+++ b/Views/AboutDialog.xaml.cs
@@ -0,0 +1,46 @@
+using RigCheck.Localization;
+using System.Diagnostics;
+using System.Windows;
+using System.Windows.Controls;
+
+namespace RigCheck.Views;
+
+///
+/// About box. Text with placeholders is composed here rather than bound,
+/// because the values come from constants and never change at runtime.
+///
+public partial class AboutDialog : Window
+{
+ private readonly string _installId;
+
+ public AboutDialog(string installId)
+ {
+ _installId = installId;
+ InitializeComponent();
+
+ SupportIdText.Text = Strings.Format("About_SupportId", installId);
+
+ Title = Strings.Format("About_Title", BrandingInfo.AppName);
+ VersionText.Text = Strings.Format("About_Version", BuildInfo.VersionLabel);
+ ChannelText.Text = Strings.Format("About_Channel", BuildInfo.Channel,
+ BuildInfo.BuildDate?.ToString("yyyy-MM-dd") ?? "?");
+ DeveloperText.Text = Strings.Format("About_DevelopedBy", BrandingInfo.Developer);
+ LicenseText.Text = Strings.Format("About_License", BrandingInfo.License);
+
+ if (BuildInfo.ExpiryDate is { } exp)
+ {
+ ExpiryText.Text = Strings.Format("About_Expires", BuildInfo.Channel, exp.ToString("yyyy-MM-dd"));
+ ExpiryText.Visibility = Visibility.Visible;
+ }
+ }
+
+ private void CopyId_Click(object sender, RoutedEventArgs e) =>
+ Clipboard.SetText(_installId);
+
+ // Each link button carries its URL in Tag so one handler serves them all.
+ private void Link_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button { Tag: string url } && !string.IsNullOrEmpty(url))
+ Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
+ }
+}
diff --git a/Views/Converters.cs b/Views/Converters.cs
new file mode 100644
index 0000000..82567cb
--- /dev/null
+++ b/Views/Converters.cs
@@ -0,0 +1,92 @@
+using RigCheck.Models;
+using RigCheck.ViewModels;
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+using System.Windows.Media;
+
+namespace RigCheck.Views;
+
+// ── Value converters for XAML bindings ────────────────────────────────────
+// Instantiated once in Resources/Styles.xaml and referenced by key from
+// MainWindow.xaml. Color converters look brushes up from the application
+// resources at runtime so the theme can be swapped without touching code.
+
+/// Negates a boolean. Used for "enabled when not running" bindings.
+public sealed class InverseBoolConverter : IValueConverter
+{
+ public object Convert(object value, Type t, object p, CultureInfo c) => value is false;
+ public object ConvertBack(object value, Type t, object p, CultureInfo c) => value is false;
+}
+
+/// Collapses an element when the bound boolean is true.
+public sealed class InverseBoolToVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type t, object p, CultureInfo c) =>
+ value is true ? Visibility.Collapsed : Visibility.Visible;
+ public object ConvertBack(object value, Type t, object p, CultureInfo c) =>
+ value is Visibility.Collapsed;
+}
+
+/// Collapses an element when the bound string is null or empty.
+public sealed class NullOrEmptyToCollapsedConverter : IValueConverter
+{
+ public object Convert(object value, Type t, object p, CultureInfo c) =>
+ string.IsNullOrEmpty(value as string) ? Visibility.Collapsed : Visibility.Visible;
+ public object ConvertBack(object value, Type t, object p, CultureInfo c) =>
+ throw new NotSupportedException();
+}
+
+/// Expand/collapse glyph for the per-result diagnosis toggle.
+public sealed class ExpandIconConverter : IValueConverter
+{
+ public object Convert(object value, Type t, object p, CultureInfo c) =>
+ value is true ? "▾" : "▸";
+ public object ConvertBack(object value, Type t, object p, CultureInfo c) =>
+ throw new NotSupportedException();
+}
+
+/// Pass/fail brush for the Hamlib status indicator dot.
+public sealed class BoolToStatusColorConverter : IValueConverter
+{
+ public object Convert(object value, Type t, object p, CultureInfo c) =>
+ Application.Current.Resources[value is true ? "BrushPass" : "BrushFail"];
+ public object ConvertBack(object value, Type t, object p, CultureInfo c) =>
+ throw new NotSupportedException();
+}
+
+/// Maps a test status to the brush used for its icon.
+public sealed class StatusToColorConverter : IValueConverter
+{
+ public object Convert(object value, Type t, object p, CultureInfo c)
+ {
+ var key = value switch
+ {
+ TestStatus.Pass => "BrushPass",
+ TestStatus.Fail => "BrushFail",
+ TestStatus.Warning => "BrushWarn",
+ TestStatus.Running => "BrushAccent",
+ _ => "BrushMuted",
+ };
+ return Application.Current.Resources[key];
+ }
+ public object ConvertBack(object value, Type t, object p, CultureInfo c) =>
+ throw new NotSupportedException();
+}
+
+/// Colors raw console lines: commands in accent, errors in red, output in normal text.
+public sealed class ConsoleKindToColorConverter : IValueConverter
+{
+ public object Convert(object value, Type t, object p, CultureInfo c)
+ {
+ var key = value switch
+ {
+ ConsoleEntryKind.Command => "BrushAccent",
+ ConsoleEntryKind.Error => "BrushFail",
+ _ => "BrushForeground",
+ };
+ return Application.Current.Resources[key];
+ }
+ public object ConvertBack(object value, Type t, object p, CultureInfo c) =>
+ throw new NotSupportedException();
+}
diff --git a/Views/ExpiredDialog.xaml b/Views/ExpiredDialog.xaml
new file mode 100644
index 0000000..caea58a
--- /dev/null
+++ b/Views/ExpiredDialog.xaml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/ExpiredDialog.xaml.cs b/Views/ExpiredDialog.xaml.cs
new file mode 100644
index 0000000..99e1786
--- /dev/null
+++ b/Views/ExpiredDialog.xaml.cs
@@ -0,0 +1,26 @@
+using RigCheck.Localization;
+using System.Diagnostics;
+using System.Windows;
+
+namespace RigCheck.Views;
+
+/// Blocking notice for an expired alpha build.
+public partial class ExpiredDialog : Window
+{
+ public ExpiredDialog()
+ {
+ InitializeComponent();
+
+ Title = Strings.Format("Expired_Title", BrandingInfo.AppName);
+ MessageText.Text = Strings.Format("Expired_Message",
+ BrandingInfo.AppName,
+ BuildInfo.ExpiryDate?.ToString("yyyy-MM-dd") ?? "?",
+ BrandingInfo.AlphaExpiryDays);
+ }
+
+ private void Download_Click(object sender, RoutedEventArgs e)
+ {
+ Process.Start(new ProcessStartInfo { FileName = BrandingInfo.ReleasesUrl, UseShellExecute = true });
+ Close();
+ }
+}
diff --git a/Views/FirstRunDialog.xaml b/Views/FirstRunDialog.xaml
new file mode 100644
index 0000000..14b869a
--- /dev/null
+++ b/Views/FirstRunDialog.xaml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/FirstRunDialog.xaml.cs b/Views/FirstRunDialog.xaml.cs
new file mode 100644
index 0000000..19f5a84
--- /dev/null
+++ b/Views/FirstRunDialog.xaml.cs
@@ -0,0 +1,38 @@
+using RigCheck.Localization;
+using RigCheck.Services;
+using System.Diagnostics;
+using System.Windows;
+
+namespace RigCheck.Views;
+
+/// First-launch choice about anonymous diagnostics. Records that the question was asked.
+public partial class FirstRunDialog : Window
+{
+ private readonly SettingsService _settings;
+
+ public FirstRunDialog(SettingsService settings)
+ {
+ _settings = settings;
+ InitializeComponent();
+
+ Title = Strings.Format("FirstRun_Title", BrandingInfo.AppName);
+ IntroText.Text = Strings.Format("FirstRun_Intro", BrandingInfo.AppName);
+ WhatIsSentText.Text = Strings.Format("Telemetry_WhatIsSent", BrandingInfo.AppName);
+
+ // Test-channel builds are where the data matters most; stable defaults to off.
+ EnableCheckBox.IsChecked = BuildInfo.IsAlpha || BuildInfo.IsBeta;
+ }
+
+ private void Continue_Click(object sender, RoutedEventArgs e)
+ {
+ _settings.Update(s =>
+ {
+ s.TelemetryEnabled = EnableCheckBox.IsChecked == true;
+ s.TelemetryPrompted = true;
+ });
+ Close();
+ }
+
+ private void Privacy_Click(object sender, RoutedEventArgs e) =>
+ Process.Start(new ProcessStartInfo { FileName = BrandingInfo.PrivacyUrl, UseShellExecute = true });
+}
diff --git a/Views/MainWindow.xaml b/Views/MainWindow.xaml
index 8ec267a..8a2a8ee 100644
--- a/Views/MainWindow.xaml
+++ b/Views/MainWindow.xaml
@@ -3,6 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:RigCheck.ViewModels"
xmlns:models="clr-namespace:RigCheck.Models"
+ xmlns:loc="clr-namespace:RigCheck.Localization"
Title="{Binding WindowTitle}"
MinWidth="560" MinHeight="480"
Width="{Binding Source={StaticResource Settings}, Path=Current.WindowWidth, Mode=TwoWay}"
@@ -13,9 +14,49 @@
Closing="Window_Closing"
Loaded="Window_Loaded">
-
-
-
+
+
+
+
+
+
+
+
+
+
+
@@ -38,11 +79,11 @@
Style="{StaticResource ChromeTitleStyle}" />
+ Command="{Binding ScaleDownCommand}" ToolTip="Scale down" />
+ Command="{Binding ScaleUpCommand}" ToolTip="Scale up" />
+ Command="{Binding ScaleResetCommand}" ToolTip="Reset scale" />
@@ -56,10 +97,15 @@
Fill="{Binding IsHamlibAvailable, Converter={StaticResource BoolToStatusColor}}" />
-
+ Command="{Binding OpenHamlibDownloadCommand}">
+
+
+
+
+
@@ -79,6 +125,7 @@
@@ -110,6 +157,7 @@
ItemsSource="{Binding Connection.AvailablePorts}"
SelectedItem="{Binding Connection.SelectedPort}"
DisplayMemberPath="DisplayName"
+ Tag="{loc:Str Placeholder_SelectPort}"
Style="{StaticResource ComboBoxStyle}" MinWidth="160" />
+ SelectedValuePath="Value" DisplayMemberPath="Label"
+ SelectedValue="{Binding Connection.BaudRate}"
+ Style="{StaticResource ComboBoxStyle}" MinWidth="120" />
@@ -146,35 +195,41 @@
Visibility="{Binding Connection.UseRigctld, Converter={StaticResource InverseBoolToVisibility}}"
Margin="0,4,0,0">
+
+ SelectedValuePath="Value" DisplayMemberPath="Label"
+ SelectedValue="{Binding Connection.PttMethod}"
+ Style="{StaticResource ComboBoxStyle}" MinWidth="120" />
+ SelectedValuePath="Value" DisplayMemberPath="Label"
+ SelectedValue="{Binding Connection.DataBits}"
+ Style="{StaticResource ComboBoxStyle}" MinWidth="120" />
+ SelectedValuePath="Value" DisplayMemberPath="Label"
+ SelectedValue="{Binding Connection.Parity}"
+ Style="{StaticResource ComboBoxStyle}" MinWidth="120" />
+ SelectedValuePath="Value" DisplayMemberPath="Label"
+ SelectedValue="{Binding Connection.StopBits}"
+ Style="{StaticResource ComboBoxStyle}" MinWidth="120" />
+ SelectedValuePath="Value" DisplayMemberPath="Label"
+ SelectedValue="{Binding Connection.FlowControl}"
+ Style="{StaticResource ComboBoxStyle}" MinWidth="120" />
@@ -297,7 +352,7 @@
Command="{Binding RawConsole.ClearConsoleCommand}"
Style="{StaticResource SecondaryButtonStyle}" />
+
diff --git a/Views/MainWindow.xaml.cs b/Views/MainWindow.xaml.cs
new file mode 100644
index 0000000..cf8048a
--- /dev/null
+++ b/Views/MainWindow.xaml.cs
@@ -0,0 +1,87 @@
+using RigCheck.Localization;
+using RigCheck.Services;
+using RigCheck.ViewModels;
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Input;
+
+namespace RigCheck.Views;
+
+///
+/// Code-behind for the main window. Kept to view-only concerns:
+/// window placement persistence and keyboard routing for the raw console.
+/// All application logic lives in MainViewModel.
+///
+public partial class MainWindow : Window
+{
+ private readonly MainViewModel _vm;
+ private readonly SettingsService _settings;
+ private readonly TelemetryService _telemetry;
+
+ public MainWindow(MainViewModel vm, SettingsService settings, TelemetryService telemetry)
+ {
+ _vm = vm;
+ _settings = settings;
+ _telemetry = telemetry;
+ // Set DataContext before InitializeComponent so bindings resolve on first layout
+ DataContext = vm;
+ InitializeComponent();
+
+ // Brand name is injected here so it can never end up in a translation file.
+ AboutMenuItem.Header = Strings.Format("Menu_HelpAbout", BrandingInfo.AppName);
+ }
+
+ // ── Menu ──────────────────────────────────────────────────────────────
+ // Dialogs are opened from code-behind because owning and showing a
+ // window is a view concern; the ViewModel never references a Window.
+
+ private void Settings_Click(object sender, RoutedEventArgs e) =>
+ new SettingsDialog(_settings, _telemetry) { Owner = this }.ShowDialog();
+
+ private void ViewData_Click(object sender, RoutedEventArgs e) =>
+ new TelemetryDataViewer(_telemetry) { Owner = this }.ShowDialog();
+
+ private void About_Click(object sender, RoutedEventArgs e) =>
+ new AboutDialog(_settings.Current.InstallId) { Owner = this }.ShowDialog();
+
+ // ── Window placement ──────────────────────────────────────────────────
+ // Width and height are bound directly to settings in XAML. Left/Top are
+ // restored here because a window position is view state, not ViewModel state.
+
+ private void Window_Loaded(object sender, RoutedEventArgs e)
+ {
+ var s = _settings.Current;
+ if (!double.IsNaN(s.WindowLeft) && !double.IsNaN(s.WindowTop))
+ {
+ Left = s.WindowLeft;
+ Top = s.WindowTop;
+ }
+ }
+
+ private void Window_Closing(object sender, CancelEventArgs e) =>
+ _vm.OnWindowClosing(Left, Top);
+
+ // ── Raw console keyboard handling ─────────────────────────────────────
+ // Enter sends the command; Up/Down walk the history like a shell prompt.
+
+ private void ConsoleInput_KeyDown(object sender, KeyEventArgs e)
+ {
+ switch (e.Key)
+ {
+ case Key.Enter:
+ _vm.SendRawCommandCommand.Execute(null);
+ e.Handled = true;
+ break;
+ case Key.Up:
+ _vm.RawConsole.HistoryUp();
+ ConsoleInput.CaretIndex = ConsoleInput.Text.Length;
+ e.Handled = true;
+ break;
+ case Key.Down:
+ _vm.RawConsole.HistoryDown();
+ ConsoleInput.CaretIndex = ConsoleInput.Text.Length;
+ e.Handled = true;
+ break;
+ }
+ }
+}
diff --git a/Views/SettingsDialog.xaml b/Views/SettingsDialog.xaml
new file mode 100644
index 0000000..e25dc2d
--- /dev/null
+++ b/Views/SettingsDialog.xaml
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/SettingsDialog.xaml.cs b/Views/SettingsDialog.xaml.cs
new file mode 100644
index 0000000..3e27963
--- /dev/null
+++ b/Views/SettingsDialog.xaml.cs
@@ -0,0 +1,102 @@
+using RigCheck.Localization;
+using RigCheck.Logging;
+using RigCheck.Services;
+using RigCheck.ViewModels;
+using System.Diagnostics;
+using System.Windows;
+
+namespace RigCheck.Views;
+
+///
+/// Settings window. Diagnostics and logging choices are pending until OK;
+/// the file and ID actions happen immediately after confirmation.
+///
+public partial class SettingsDialog : Window
+{
+ private readonly SettingsService _settings;
+ private readonly TelemetryService _telemetry;
+
+ public SettingsDialog(SettingsService settings, TelemetryService telemetry)
+ {
+ _settings = settings;
+ _telemetry = telemetry;
+ InitializeComponent();
+
+ WhatIsSentText.Text = Strings.Format("Telemetry_WhatIsSent", BrandingInfo.AppName);
+ LogPathText.Text = AppLogger.LogDirectory;
+ SupportIdText.Text = _settings.Current.InstallId;
+
+ LogLevelCombo.ItemsSource = new[]
+ {
+ new Choice(AppLogger.LevelOff, Strings.Get("LogLevel_Off")),
+ new Choice(AppLogger.LevelErrors, Strings.Get("LogLevel_Error")),
+ new Choice(AppLogger.LevelNormal, Strings.Get("LogLevel_Information")),
+ new Choice(AppLogger.LevelDetailed, Strings.Get("LogLevel_Debug")),
+ };
+
+ // Load current values into the pending controls
+ TelemetryCheckBox.IsChecked = _settings.Current.TelemetryEnabled;
+ LogLevelCombo.SelectedValue = AppLogger.ResolveLevel(_settings.Current.LogLevel);
+ }
+
+ // ── OK / Cancel ───────────────────────────────────────────────────────
+
+ private void Ok_Click(object sender, RoutedEventArgs e)
+ {
+ var level = LogLevelCombo.SelectedValue as string ?? AppLogger.ChannelDefaultLevel;
+
+ _settings.Update(s =>
+ {
+ s.TelemetryEnabled = TelemetryCheckBox.IsChecked == true;
+ s.LogLevel = level;
+ });
+
+ if (level != AppLogger.CurrentLevel)
+ AppLogger.Configure(level);
+
+ DialogResult = true;
+ }
+
+ // ── Immediate actions ─────────────────────────────────────────────────
+
+ private void ViewData_Click(object sender, RoutedEventArgs e) =>
+ new TelemetryDataViewer(_telemetry) { Owner = this }.ShowDialog();
+
+ private void Privacy_Click(object sender, RoutedEventArgs e) =>
+ Process.Start(new ProcessStartInfo { FileName = BrandingInfo.PrivacyUrl, UseShellExecute = true });
+
+ private void OpenLogFolder_Click(object sender, RoutedEventArgs e)
+ {
+ System.IO.Directory.CreateDirectory(AppLogger.LogDirectory);
+ Process.Start(new ProcessStartInfo { FileName = AppLogger.LogDirectory, UseShellExecute = true });
+ }
+
+ private void DeleteLogs_Click(object sender, RoutedEventArgs e)
+ {
+ var confirm = MessageBox.Show(this,
+ Strings.Format("Settings_DeleteLogsConfirm", BrandingInfo.AppName),
+ Strings.Get("Settings_DeleteLogs"),
+ MessageBoxButton.YesNo, MessageBoxImage.Question);
+ if (confirm != MessageBoxResult.Yes) return;
+
+ var removed = AppLogger.DeleteLogs();
+ LogActionStatus.Text = Strings.Format("Settings_DeleteLogsDone", removed);
+ }
+
+ private void CopyId_Click(object sender, RoutedEventArgs e) =>
+ Clipboard.SetText(_settings.Current.InstallId);
+
+ // A new ID also removes the local report copies, which carry the old one.
+ private void ResetId_Click(object sender, RoutedEventArgs e)
+ {
+ var confirm = MessageBox.Show(this,
+ Strings.Get("Settings_ResetIdConfirm"),
+ Strings.Get("Settings_SupportIdHeading"),
+ MessageBoxButton.YesNo, MessageBoxImage.Warning);
+ if (confirm != MessageBoxResult.Yes) return;
+
+ _settings.Update(s => s.InstallId = Guid.NewGuid().ToString("D"));
+ _telemetry.ClearLocalReports();
+ SupportIdText.Text = _settings.Current.InstallId;
+ }
+}
diff --git a/Views/TelemetryDataViewer.xaml b/Views/TelemetryDataViewer.xaml
new file mode 100644
index 0000000..d6334f1
--- /dev/null
+++ b/Views/TelemetryDataViewer.xaml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/TelemetryDataViewer.xaml.cs b/Views/TelemetryDataViewer.xaml.cs
new file mode 100644
index 0000000..cde0207
--- /dev/null
+++ b/Views/TelemetryDataViewer.xaml.cs
@@ -0,0 +1,45 @@
+using RigCheck.Localization;
+using RigCheck.Services;
+using System.Text;
+using System.Windows;
+
+namespace RigCheck.Views;
+
+/// Shows every telemetry report stored locally, newest first.
+public partial class TelemetryDataViewer : Window
+{
+ private readonly TelemetryService _telemetry;
+
+ public TelemetryDataViewer(TelemetryService telemetry)
+ {
+ _telemetry = telemetry;
+ InitializeComponent();
+ IntroText.Text = Strings.Format("DataViewer_Intro", BrandingInfo.AppName);
+ Load();
+ }
+
+ private void Load()
+ {
+ var reports = _telemetry.GetLocalReports();
+ if (reports.Count == 0)
+ {
+ ReportsText.Text = Strings.Get("DataViewer_Empty");
+ return;
+ }
+
+ var sb = new StringBuilder();
+ foreach (var (name, json) in reports)
+ {
+ sb.AppendLine($"── {name} ──");
+ sb.AppendLine(json);
+ sb.AppendLine();
+ }
+ ReportsText.Text = sb.ToString();
+ }
+
+ private void Clear_Click(object sender, RoutedEventArgs e)
+ {
+ _telemetry.ClearLocalReports();
+ Load();
+ }
+}
diff --git a/app.manifest b/app.manifest
new file mode 100644
index 0000000..9b442c8
--- /dev/null
+++ b/app.manifest
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true/pm
+ PerMonitorV2
+ true
+
+
+