32 Building and Contributing
Polaris is open source under the Mozilla Public License 2.0, the same license as upstream N.I.N.A. (Berg and the N.I.N.A. contributors, n.d.), and contributions flow through the ordinary GitHub workflow of forks, branches, and pull requests. This chapter covers the development side: building and running from source, the technology stack and project layout, the recipes for the most common kinds of change, remote-debugging a Raspberry Pi from Visual Studio, and how the web UI is translated. It assumes you have read Chapter 30; the two chapters are designed as a pair.
32.1 Quick start
Building needs the .NET 10 SDK. Then:
git clone https://github.com/DanWBR/nina-polaris.git
cd nina-polaris
dotnet build src/NINA.Polaris/NINA.Polaris.csproj
dotnet test tests/NINA.Polaris.Test/NINA.Polaris.Test.csproj
dotnet run --project src/NINA.Polaris/NINA.Polaris.csprojThe server comes up with its default listeners, HTTPS on port 5000 and loopback HTTP on 5080 (Chapter 31), so open https://localhost:5000 and accept the self-signed certificate. The development loop is edit, rebuild, refresh the browser; static assets under wwwroot/ (the HTML, JS, and CSS of the whole UI) reload without a rebuild, since there is no frontend build pipeline at all.
32.2 The stack
- .NET 10 (the latest standard-term-support release)
- ASP.NET Core minimal APIs (Microsoft, n.d.-a) plus raw WebSocket handlers
- Alpine.js 3 (Porzio and contributors, n.d.) for frontend reactivity, no build pipeline
- NUnit (NUnit Project, n.d.) for the test suite in
tests/NINA.Polaris.Test - SQLite (Hipp et al., n.d.) via
Microsoft.Data.Sqlitefor the STUDIO frame index - YARP (Microsoft, n.d.-b) for the
/phd2-gui/reverse proxy - SkiaSharp (Mono Project, n.d.) for JPEG, PNG, and TIFF encoding
- Microsoft.Extensions.Diagnostics.ResourceMonitoring for the host CPU and RAM gauges
32.3 Project layout
src/
NINA.Core.Portable/ # shared math / enums / utilities (no UI / IO)
NINA.Image.Portable/ # FITS reader/writer, XISF writer, star
# detection, image stretch, BaseImageData
# (pure code, no host deps)
NINA.INDI/ # INDI TCP/XML protocol + device wrappers
# (IndiCamera, IndiTelescope, ...)
NINA.Camera.CanonEdsdk/ # Windows-only Canon EDSDK driver wrapper
NINA.Camera.NikonSdk/ # ditto Nikon
NINA.Camera.SonySdk/ # ditto Sony
NINA.Mount.SynScanWifi/ # direct-WiFi SynScan driver
NINA.Relay.Protocol/ # shared types for the relay
NINA.Relay.Server/ # standalone VPS-deployed relay server
NINA.Polaris/ # the ASP.NET Core host: Services/,
# Endpoints/, WebSocket/, wwwroot/
tests/
NINA.Polaris.Test/ # all the unit tests
docs/ # user + dev docs
Chapter 30 describes what each project does and how they depend on one another.
32.4 Recipes for common changes
Adding a new INDI device kind
The concrete walkthrough in CONTRIBUTING.md adds a hypothetical weather safety monitor; the steps generalize:
- Define the protocol surface in
NINA.INDI/Devices/IndiSafety.cs: a wrapper class taking theIndiClientand a device name, with property getters reading the client’s property dictionary (IsSafe, for instance, reads theSAFETY_STATUSswitch) and async action methods. - Wire it into
EquipmentManager(Services/EquipmentManager.cs): a typedSafetyproperty and aSelectSafety(deviceName)method. - Add the endpoint file,
Endpoints/SafetyEndpoints.cs, mapping a/api/safetygroup with thin handlers that read the manager. - Register it in
Program.cswithapp.MapSafetyEndpoints();. - Add a card to the RIGS tab in
wwwroot/index.htmlfollowing the existing pattern (icon header, dropdown, connect and disconnect buttons, status dot). - Add the matching state and methods in
wwwroot/js/app.js. - Add tests in
tests/NINA.Polaris.Test/IndiSafetyTests.cs.
The existing IndiWeather device and WeatherEndpoints show the full pattern end to end.
Adding a sequencer instruction, trigger, or condition
The advanced sequencer discovers its building blocks through MEF exports in src/NINA.Polaris/Services/Sequencer/:
- Create a class deriving from the appropriate base (
SequenceInstruction,SequenceTrigger, orSequenceCondition). - Annotate it with
[Export]plus the metadata attributes for category and display name. - Add the type to
SequencerFactoryso it appears in the palette. - JSON serialization via
SequenceJsonConverterpicks it up from the type metadata automatically. - Add a test.
Services/Sequencer/Triggers/AutoFocusOnTemperatureTrigger.cs is a complete trigger and Services/Sequencer/Conditions/LoopUntilAltitudeCondition.cs a complete condition to crib from.
Adding a plate solver
Solvers follow the IPlateSolver strategy pattern. Implement Task<PlateSolveResult> SolveAsync(string fitsPath, PlateSolveOptions options, CancellationToken ct) in a new class under Services/PlateSolving/, register it as a singleton in Program.cs, and PlateSolveService (the dispatcher) auto-discovers it and adds it to the primary and blind solver dropdowns in Settings. AstapSolver.cs and AstrometryNetLocalSolver.cs are the references.
32.5 Coding conventions
- C#: match the existing style. Records for DTOs, classes for services, async/await everywhere; null-conditional reads from service properties, because devices may be null before
EquipmentManagerhooks them up. - Comments explain why, not what; reviewers reject comments that restate the next line. XML doc-comments are required on public service types and methods, a short paragraph minimum that mentions which other service consumes them.
- Logging is injected
ILogger<T>with structured templates (LogInformation("X finished {Frames} frames", count)), never string concatenation. - Async: every IO surface is async; synchronous wrappers are an anti-pattern here.
- JS: Alpine.js plus vanilla JavaScript, small composable methods on the single component, no build pipeline.
- CSS: BEM-ish naming (
.equip-card,.equip-card-header), one stylesheet, no SASS or Tailwind.
32.6 Commits, branches, and tests
Commit messages read <scope>: <imperative summary>, a blank line, then a short body explaining why. Scopes follow the project’s plan phase identifiers (LSTR-3, VIDPL-7, and so on); ad-hoc work uses ui:, feat:, fix:, docs:, or chore:. AI-assisted commits end with a Co-Authored-By line naming the model.
master is the trunk; maintainers may commit directly with build and tests green, everyone else branches (feat/short-name, fix/short-name) and opens a pull request, squash-merged by default.
All new services and non-trivial helpers get unit tests, and pure functions get golden-value tests (a fixed input with a known-correct expected output). The full suite runs with:
dotnet test tests/NINA.Polaris.Test/NINA.Polaris.Test.csprojabout 450 tests in roughly five seconds, even on a Raspberry Pi 5. WebSocket and endpoint integration tests are not yet in place, only manual smoke testing; contributions there are welcome. For contributors using Claude Code, project-specific tool permissions live in .claude/settings.json.
32.7 Debugging a Raspberry Pi from Visual Studio
Developing against real ARM hardware does not mean editing on the Pi. Four workflows, ranked by ergonomics: full SSH remote debugging from Visual Studio (A), a one-button deploy script (B), manual publish over SSH (C), and hot reload with dotnet watch (D).
A. SSH remote debugging (recommended)
This gives breakpoints, watch windows, and call stacks against the process running on the Pi, exactly as if it were local.
Prepare the Pi once. Install the ASP.NET Core 10 runtime (the SDK is not needed to run published output), the libraries SkiaSharp needs, and vsdbg, the debugger agent Visual Studio talks to over SSH:
ssh pi@<pi-ip>
sudo apt update
sudo apt install -y libicu-dev libssl-dev curl libfontconfig1
curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin \
--channel 10.0 --runtime aspnetcore --install-dir /usr/share/dotnet
sudo ln -sf /usr/share/dotnet/dotnet /usr/local/bin/dotnet
dotnet --info # should show "Microsoft.AspNetCore.App 10.x.x"
curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v latest -l ~/vsdbg
mkdir -p ~/polarislibfontconfig1 is required by SkiaSharp for image encoding, and the INDI server is a separate install (Chapter 2). Confirm a 64-bit OS with uname -m; it must print aarch64, because the ARM64 vsdbg needs a 64-bit kernel.
Configure Visual Studio. Add the Pi under Tools → Options → Cross Platform → Connection Manager (host, port 22, your SSH user, key-file authentication; generate a key with ssh-keygen and copy it over with ssh-copy-id). Then open NINA.Polaris.slnx, right-click the NINA.Polaris project, and add an SSH debug launch profile:
| Field | Value |
|---|---|
| Hostname | the saved connection |
| Project path on target machine | /home/pi/polaris |
| Executable | dotnet |
| Command line arguments | NINA.Polaris.dll |
| Working directory | /home/pi/polaris |
| Deploy on debug | checked |
| Environment variables | ASPNETCORE_URLS=http://0.0.0.0:5000 |
(The environment variable comes from the original guide and only matters on older builds; current builds bind their listeners from the Server:* keys and serve HTTPS on port 5000 on all interfaces regardless, see Chapter 31.)
Target ARM64 in the project file. NINA.Polaris.csproj must publish for linux-arm64:
<RuntimeIdentifier>linux-arm64</RuntimeIdentifier>
<SelfContained>false</SelfContained>SelfContained=false matters: the runtime is already on the Pi, so there is no need to ship it on every deploy. The Windows-only camera SDK projects will not compile for Linux, so their references are wrapped in a condition:
<ItemGroup Condition="!$(RuntimeIdentifier.StartsWith('linux'))">
<ProjectReference Include="..\NINA.Camera.CanonEdsdk\NINA.Camera.CanonEdsdk.csproj" />
<ProjectReference Include="..\NINA.Camera.NikonSdk\NINA.Camera.NikonSdk.csproj" />
</ItemGroup>NINA.Camera.SonySdk targets plain net10.0 (Sony ships Linux binaries) and needs no condition.
Hit F5. Visual Studio publishes for linux-arm64, copies the output to /home/pi/polaris/ over SCP (delta-only after the first deploy), runs dotnet NINA.Polaris.dll on the Pi with vsdbg attached, and breakpoints work as if local. Point a browser at the Pi to drive the UI.
Gotchas: the first publish is slow (about two minutes), a Pi 4 is borderline for live stacking with the debugger attached (a Pi 5 is much more comfortable), and if Visual Studio cannot find vsdbg, set its path explicitly in the debug profile’s pre-launch command to ~/vsdbg/vsdbg.
C. Manual publish over SSH
Skip the tooling entirely: run the publish script from deploy/, copy the output with scp, then SSH in and start the binary yourself, watching logs on stdout. Useful for a quick “does my fix actually launch on the Pi” check, a bad choice for hunting an actual bug (use A) or for a repeatable loop (use B).
D. Hot reload with dotnet watch
For tight iteration on CSS and JS, install the full .NET SDK on the Pi (about 500 MB more than the runtime) and run:
DOTNET_USE_POLLING_FILE_WATCHER=1 \
dotnet watch run --project NINA.Polarisagainst a source tree mounted from the Windows machine over SSHFS (a network filesystem over SSH), so edits on Windows restart the host on the Pi automatically. The polling file watcher is required because filesystem change events do not propagate through SSHFS. There is no step-debugging, SSHFS adds latency to every file read, and the first build after mounting takes two to three times longer; for C# changes, workflow A is more pleasant.
32.8 Internationalization
The web UI uses an English-source-as-key model in the gettext style: the English text already in index.html and app.js is itself the lookup key, and a per-language catalog at wwwroot/data/locales/{lang}.json maps { "English source": "Translation" }. English is the identity language, with no catalog, no fetch, and no runtime overhead. Released languages are en plus pt-BR, es, fr, and de.
At runtime, wwwroot/js/i18n.js (loaded before Alpine) translates non-English UIs with a scoped MutationObserver, a browser mechanism that watches the page for changes: it translates text nodes and the title, placeholder, aria-label, and alt attributes by looking up their current English content, so both static markup and Alpine-generated text are covered without per-binding edits. Strings built in JavaScript (toasts, chart labels) use t() or the Alpine $t() helper with {var} interpolation. Untranslated strings fall back to English, changing language reloads the page, and hot or numeric regions opt out with data-no-i18n.
The translation workflow has four steps:
- Extract the source strings after any UI text change:
node scripts/extract-i18n.mjswriteswwwroot/data/locales/_source.jsonand reports orphans. - Machine pre-translation fills only the missing keys, never overwriting curated ones, applying the glossary in
scripts/i18n-glossary.json:node scripts/pretranslate-i18n.mjs [lang], with the provider configured throughDEEPL_API_KEY(DeepL (DeepL SE, n.d.)) orLIBRETRANSLATE_URL(LibreTranslate (LibreTranslate contributors, n.d.)). Machine-filled keys are recorded in{lang}.machine.jsonfor review; with no provider configured, the script exits without touching anything. DeepL’s free tier has a monthly character quota, and a403 Forbiddenusually means it is spent. - Curated corrections live in
scripts/i18n-overrides.json(anallsection plus per-language overrides) and are applied withnode scripts/apply-i18n-overrides.mjs, which also de-marks those keys as machine output. - Community refinement happens on Crowdin (Crowdin, n.d.), the collaborative translation platform, configured by
crowdin.ymlwith English as the source language;crowdin upload sourcesandcrowdin pullmove strings in and out.
Conventions: jargon and product names stay English per the glossary’s do-not-translate list (ASTAP, ONNX, OpenCL, INDI, ASCOM, PHD2, GraXpert, FITS, HFR, plate solving, dithering, and so on); the language names in the picker are never translated; server logs and backend errors stay en-US by design; and the Locale.* files from desktop N.I.N.A. are not reused, Polaris keeps a separate catalog. Adding a language means adding its code to SUPPORTED in wwwroot/js/i18n.js, to the picker in index.html under Settings → Appearance, to RELEASED in pretranslate-i18n.mjs, and a languages_mapping entry in crowdin.yml, then running the pre-translation.
32.9 License and third-party code
Polaris is MPL 2.0 throughout, including the driver wrapper projects; the non-redistributable vendor SDK binaries the wrappers load at runtime remain under their vendors’ licenses (Chapter 6). When adding a NuGet package, include its license in docs/third-party-licenses.md, which is currently a stub and a good first contribution. Vendored browser libraries under wwwroot/js/lib/ each carry an adjacent license file.