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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -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" /> + + + + + + + + + + + + +