diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a35535c..0572354 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -126,6 +126,9 @@ jobs: fi echo "serializeJsonPretty check passed" + - name: Run native unit tests + run: pio test -e native + - name: Populate data directory for LittleFS run: | rm -rf data @@ -197,11 +200,26 @@ jobs: mv littlefs/littlefs.bin sqmeter-littlefs-${VERSION}.bin mv complete-flash/complete-flash.bin sqmeter-complete-flash-${VERSION}.bin + # Any tag with a semver prerelease segment (e.g. v0.1.4-beta.1) is + # published as a GitHub prerelease, which is what the device's + # "Check for Updates" beta track filters on (GitHub's prerelease flag, + # not tag naming - see OtaUpdater::parseGithubReleases). + - name: Determine prerelease status + id: prerelease + run: | + VERSION="${GITHUB_REF#refs/tags/}" + if [[ "$VERSION" == *-* ]]; then + echo "value=true" >> "$GITHUB_OUTPUT" + else + echo "value=false" >> "$GITHUB_OUTPUT" + fi + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: name: SQMeter ${{ github.ref_name }} generate_release_notes: true + prerelease: ${{ steps.prerelease.outputs.value }} files: | sqmeter-firmware-*.bin sqmeter-littlefs-*.bin diff --git a/README.md b/README.md index bc4e25c..b568515 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ SQMeter measures light pollution in real time using an ESP32. It gives you SQM m - RG-15 rain sensor support (optional) - Real-time web dashboard over WebSocket - REST API and MQTT publishing -- OTA firmware updates from the browser +- OTA firmware updates from the browser, or self-updated directly from GitHub Releases +- Native ASCOM Alpaca SafetyMonitor + ObservingConditions device (N.I.N.A.-compatible, no separate bridge needed) - Captive portal Wi-Fi setup on first boot ## Quick Start diff --git a/docs/api/rest.md b/docs/api/rest.md index 6e45f88..9c28c45 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -382,3 +382,67 @@ Use this endpoint for web UI assets only. It does not update firmware and does n !!! warning "Unauthenticated update endpoints" `/api/update` and `/api/update/fs` are LAN-only convenience endpoints and currently have no HTTP authentication. Keep SQMeter on a trusted network and do not port-forward it. + +--- + +### `GET /api/updates/check` + +Checks GitHub Releases for the given track and returns matched firmware+filesystem asset pairs. See [OTA Updates](../user-guide/ota.md#check-for-updates-recommended) for the full flow. + +```bash +curl "http://sqm-esp32.local/api/updates/check?track=stable" +``` + +```json +[ + { + "tag": "v0.1.3", + "name": "SQMeter v0.1.3", + "prerelease": false, + "publishedAt": "2026-06-28T17:35:57Z", + "firmwareAssetUrl": "https://github.com/DeanJ87/SQMeter/releases/download/v0.1.3/sqmeter-firmware-v0.1.3.bin", + "firmwareAssetSize": 1123472, + "fsAssetUrl": "https://github.com/DeanJ87/SQMeter/releases/download/v0.1.3/sqmeter-littlefs-v0.1.3.bin", + "fsAssetSize": 524288 + } +] +``` + +`track` is `stable` (default) or `beta`, mapped directly from GitHub's `prerelease` flag. A release without both a `sqmeter-firmware-*.bin` and a `sqmeter-littlefs-*.bin` asset is omitted entirely. + +--- + +### `POST /api/updates/apply` + +Starts a self-download-and-flash of a release returned by `check`, using its asset URLs directly. + +```bash +curl -X POST http://sqm-esp32.local/api/updates/apply \ + -H "Content-Type: application/json" \ + -d '{ + "firmwareAssetUrl": "https://github.com/DeanJ87/SQMeter/releases/download/v0.1.3/sqmeter-firmware-v0.1.3.bin", + "firmwareAssetSize": 1123472, + "fsAssetUrl": "https://github.com/DeanJ87/SQMeter/releases/download/v0.1.3/sqmeter-littlefs-v0.1.3.bin", + "fsAssetSize": 524288 + }' +``` + +Returns immediately with `{"success":true,"message":"Update started"}` - the download and flash happen on a background task. Progress and errors are pushed over `/ws/status` as `{"type":"ota_progress","progress":N}` messages (or `{"error":"..."}` on failure). The device reboots automatically once both assets are flashed successfully. + +--- + +## ASCOM Alpaca API + +SQMeter can emit itself directly as an ASCOM Alpaca **SafetyMonitor** and **ObservingConditions** device - see [ASCOM Alpaca](../user-guide/alpaca.md) for the full setup guide, N.I.N.A. configuration, and safety-rule reference. Summary of the HTTP surface (all under the same port-80 server as the rest of the API, response envelope per the [ASCOM Alpaca API spec](https://ascom-standards.org/api/)): + +| Endpoint | Purpose | +|----------|---------| +| `GET /management/apiversions` | Supported Alpaca API versions | +| `GET /management/v1/description` | Server description | +| `GET /management/v1/configureddevices` | Lists the two devices (empty if Alpaca is disabled in settings) | +| `GET/PUT /api/v1/safetymonitor/0/connected` | Common ASCOM device API | +| `GET /api/v1/safetymonitor/0/issafe` | `true`/`false` from the safety-rule evaluation | +| `GET/PUT /api/v1/observingconditions/0/connected` | Common ASCOM device API | +| `GET /api/v1/observingconditions/0/` | One route per Alpaca property (`cloudcover`, `dewpoint`, `humidity`, `skybrightness`, `skyquality`, `skytemperature`, `temperature`, `averageperiod`); unsupported properties (`pressure`, `rainrate`, `starfwhm`, `wind*`) return Alpaca error `0x400` (NotImplemented) | + +A UDP listener on port `32227` answers Alpaca discovery broadcasts (`alpacadiscovery1` → `{"AlpacaPort":80}`) whenever Alpaca is enabled in settings - this requires a device restart to start/stop, unlike the HTTP routes above which reflect the setting live. diff --git a/docs/index.md b/docs/index.md index efe86f5..2108bbf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,7 +31,11 @@ SQMeter is an open-source sky quality meter built on the ESP32. It measures ligh - :material-update: **OTA Updates** - Flash new firmware from the browser — no USB cable needed after first flash. + Flash new firmware from the browser, or let the device update itself directly from GitHub Releases. + +- :material-connection: **ASCOM Alpaca** + + Native SafetyMonitor + ObservingConditions device for N.I.N.A. — no separate bridge required. - :material-lock-open: **Open Hardware** diff --git a/docs/user-guide/alpaca.md b/docs/user-guide/alpaca.md new file mode 100644 index 0000000..9142dd9 --- /dev/null +++ b/docs/user-guide/alpaca.md @@ -0,0 +1,81 @@ +# ASCOM Alpaca + +SQMeter can act as an ASCOM Alpaca **SafetyMonitor** and **ObservingConditions** device directly - no separate bridge/service needed. N.I.N.A. and other ASCOM Alpaca clients connect straight to the device's IP address. + +!!! note "Replaces the standalone bridge" + Earlier setups used a separate `SQMeter-ASCOM-Alpaca` Windows service/`.exe` that polled the device's REST API and re-served it as Alpaca. That bridge still works, but is no longer necessary - the device now speaks Alpaca natively. If you're migrating from it, disconnect N.I.N.A. from the bridge's devices first, then follow this guide to connect directly to the device instead. + +--- + +## Enabling Alpaca support + +1. Open the web UI and go to **Settings** +2. Scroll to **ASCOM Alpaca** and check **Enable Alpaca SafetyMonitor / ObservingConditions** +3. Set your safety thresholds (see [Safety rules](#safety-rules) below) and **Save** +4. Restart the device (Settings save doesn't require it, but the UDP discovery listener that N.I.N.A. uses to auto-find the device only starts at boot) + +Alpaca support is disabled by default. With it off, every Alpaca endpoint still responds (so tooling doesn't 404) but reports `connected: false` and a `NotConnected` error - it just isn't discoverable or usable until enabled. + +--- + +## Connecting from N.I.N.A. + +### SafetyMonitor + +1. Equipment → **Safety Monitor** → select **ASCOM Alpaca** +2. Click **Refresh** - N.I.N.A. broadcasts a UDP discovery request on port `32227`; SQMeter responds and N.I.N.A. lists **SQMeter SafetyMonitor** +3. Select it and click **Connect** + +### ObservingConditions + +1. Equipment → **Weather** (Observing Conditions) → select **ASCOM Alpaca** +2. Click **Refresh**, select **SQMeter ObservingConditions**, **Connect** + +Both devices are served from the same device/port - connecting one doesn't require or block the other. + +### If discovery doesn't find the device + +- Confirm **Enable Alpaca...** is checked in Settings and the device has been restarted since +- Discovery is a UDP broadcast - it won't cross VLANs/subnets or most VPNs; N.I.N.A. and the device need to be on the same local network segment +- As a fallback, most Alpaca clients (including N.I.N.A.) let you add a device manually by IP:port instead of relying on discovery - use the device's IP and port `80` + +--- + +## Safety rules + +`SafetyMonitor.IsSafe` is computed fresh on every request from the current sensor readings against the thresholds configured in **Settings → ASCOM Alpaca**. In order, any of the following makes it unsafe: + +1. **Manual override** - the "Force SafetyMonitor unsafe" checkbox is on +2. **No data yet** - the device hasn't completed a sensor read since boot +3. **Stale data** - the last successful read is older than the stale-data threshold (default 30s) +4. **Sensor fault** - the light sensor (TSL2591) or IR temperature sensor (MLX90614) is reporting a non-OK status +5. **Cloud cover** - at or above the configured threshold (default 90%, if enabled) +6. **Sky brightness (SQM)** - below the configured minimum (disabled by default) +7. **Humidity** - above the configured maximum (disabled by default) +8. **Temperature-dewpoint margin** - below the configured minimum (disabled by default) + +Each threshold has its own enable/disable toggle - a disabled threshold never contributes to the verdict. This mirrors the rule set from the standalone bridge it replaces, so behavior should feel identical if you're migrating. + +--- + +## ObservingConditions properties + +| Alpaca property | Source | +|---|---| +| `cloudcover` | Cloud detection (IR sky temperature vs. ambient, humidity-corrected) | +| `dewpoint` | BME280 | +| `humidity` | BME280 | +| `skybrightness` | TSL2591 lux | +| `skyquality` | Calculated SQM (mag/arcsec²) | +| `skytemperature` | MLX90614 IR object temperature | +| `temperature` | BME280 | +| `averageperiod` | Always `0` (no averaging is performed) | +| `pressure`, `rainrate`, `starfwhm`, `winddirection`, `windgust`, `windspeed` | Not implemented - no sensor for these; returns Alpaca error `0x400` | + +If sensor data is stale or hasn't been read yet, implemented properties return a driver error instead of a stale/zeroed value. + +--- + +## Reference + +See the [REST API reference](../api/rest.md#ascom-alpaca-api) for the full endpoint list and the [ASCOM Alpaca API spec](https://ascom-standards.org/api/) for the response envelope and standard error codes. diff --git a/docs/user-guide/ota.md b/docs/user-guide/ota.md index 84fd38d..f24ea15 100644 --- a/docs/user-guide/ota.md +++ b/docs/user-guide/ota.md @@ -4,7 +4,27 @@ Update firmware over WiFi without a USB cable. --- -## Via Web UI (Recommended) +## Check for Updates (Recommended) + +The **System > Updates** page can check GitHub Releases directly and update the device itself - no downloading or uploading required. + +1. Open the web UI and go to **Updates** +2. Under **Check for Updates**, pick a release track: + - **Stable** - tagged releases (`prerelease: false` on GitHub) + - **Beta** - pre-release builds (`prerelease: true` on GitHub) +3. Pick a specific release from the dropdown (defaults to the newest on the selected track) - a badge shows whether it's newer than the running firmware +4. Click **Update to ``** + +The device downloads `sqmeter-firmware-.bin` and `sqmeter-littlefs-.bin` directly from `api.github.com` over HTTPS and flashes both before rebooting - firmware and web UI are always updated together as a matched pair, so they never drift out of sync with each other. A release only appears in the list if both assets exist for it. + +Progress and errors are pushed to the page over the same WebSocket channel the System page uses; if the connection to GitHub fails partway through (no internet, DNS, etc.), the device aborts cleanly and keeps running exactly what it was running before - see [How self-update failure handling works](#how-self-update-failure-handling-works) below. + +!!! note "TLS" + The device validates GitHub's certificate chain against two pinned root CAs (covering `api.github.com` and the release-asset CDN) rather than trusting any certificate - it will refuse to update if GitHub's certificate doesn't chain to one of them. + +--- + +## Via Web UI (Manual Upload) 1. Download `sqmeter-firmware-vX.Y.Z.bin` from [GitHub Releases](https://github.com/DeanJ87/SQMeter/releases) 2. Open the web UI and go to **System** @@ -25,12 +45,14 @@ Update firmware over WiFi without a USB cable. ## API Endpoints -The System page uses these endpoints: +The Updates page uses these endpoints: | Endpoint | Purpose | Artifact | |----------|---------|----------| -| `POST /api/update` | Firmware OTA update | `sqmeter-firmware-vX.Y.Z.bin` | -| `POST /api/update/fs` | LittleFS/web UI update | `sqmeter-littlefs-vX.Y.Z.bin` | +| `GET /api/updates/check?track=stable\|beta` | List GitHub releases with a matched firmware+filesystem asset pair, filtered by track | - | +| `POST /api/updates/apply` | Self-download and flash a specific release. Body: `{"firmwareAssetUrl","firmwareAssetSize","fsAssetUrl","fsAssetSize"}` (from a `check` response entry) | fetched from GitHub | +| `POST /api/update` | Manual firmware upload | `sqmeter-firmware-vX.Y.Z.bin` | +| `POST /api/update/fs` | Manual LittleFS/web UI upload | `sqmeter-littlefs-vX.Y.Z.bin` | Both endpoints expect `multipart/form-data` uploads and return JSON with `success` on completion or `error` on failure. The firmware endpoint reboots automatically after a successful upload. @@ -76,3 +98,17 @@ The partition table has two app slots (`app0` at `0x10000`, `app1` at `0x190000` This means you always have a working rollback as long as you don't erase the flash. The LittleFS filesystem update is separate from app OTA slots. It replaces the dashboard/settings assets and preserves NVS configuration, but an interrupted filesystem upload can leave the web UI unavailable until LittleFS is flashed again over USB or a later successful OTA filesystem upload. + +--- + +## How Self-Update Failure Handling Works + +`POST /api/updates/apply` flashes the **filesystem first, then the firmware**, and only reboots once both have succeeded - deliberately the reverse of upload order, because writing the new firmware is the one irreversible step (it flips the boot partition the instant it succeeds). If the device loses connectivity or power at any point: + +- **Before the firmware write starts** (checking, or mid-filesystem-download): nothing has changed that affects what boots next time. The device keeps running exactly what it was running before. +- **During the firmware write**: the write is aborted and the boot partition is left untouched, same as above. +- **After the firmware write succeeds but before reboot**: this can't happen in practice - the reboot is triggered immediately after the firmware write completes, with no further network calls in between. + +In all cases config (WiFi, calibration, thresholds) is untouched, since it lives entirely in NVS, a separate partition from both `app0`/`app1` and the LittleFS filesystem. + +One known, pre-existing limitation shared with the manual filesystem-upload endpoint: the filesystem partition is erased before being rewritten, so a connection drop specifically *during* the filesystem write (not before, not after) leaves LittleFS corrupted until a later successful update repairs it. The REST API and OTA endpoints stay reachable either way - only the served dashboard/settings pages would be affected until then. diff --git a/include/Config.h b/include/Config.h index 50bce27..41fcc23 100644 --- a/include/Config.h +++ b/include/Config.h @@ -115,6 +115,29 @@ namespace SQM float humidityCorrection; // k1 factor for humidity correction (default: 0.75) }; + // Thresholds for the native ASCOM Alpaca SafetyMonitor's "is it safe" + // evaluation, ported from the SQMeter-ASCOM-Alpaca Go bridge's config. + // Each *_enabled flag matches that bridge's "if configured" semantics - + // disabled thresholds never contribute to an unsafe verdict. + struct AlpacaConfig + { + bool enabled; // master switch for the Alpaca HTTP+UDP endpoints + bool manualOverrideUnsafe; // force SafetyMonitor.IsSafe = false regardless of readings + uint32_t staleAfterSeconds; // sensor data older than this counts as unsafe + + bool cloudCoverEnabled; + float cloudCoverUnsafePercent; + + bool sqmMinEnabled; + float sqmMinSafe; + + bool humidityMaxEnabled; + float humidityMaxSafe; + + bool dewpointMarginEnabled; + float dewpointMarginMinC; + }; + struct Config { WiFiConfig wifi; @@ -128,13 +151,14 @@ namespace SQM SkyAveragingConfig skyAveraging; SkyCalibrationConfig skyCalibration; CloudDetectionConfig cloudDetection; + AlpacaConfig alpaca; std::string deviceName; std::string timezone; TimeSource primaryTimeSource; // Primary time source TimeSource secondaryTimeSource; // Fallback time source static constexpr const char *TAG = "Config"; - static constexpr size_t MAX_PERSISTED_JSON_BYTES = 4600; + static constexpr size_t MAX_PERSISTED_JSON_BYTES = 5100; static std::optional load(); bool save() const; diff --git a/include/WebServer.h b/include/WebServer.h index 8310e1f..628f53b 100644 --- a/include/WebServer.h +++ b/include/WebServer.h @@ -10,9 +10,12 @@ #include "TimeManager.h" #include "MQTTClient.h" #include "OtaUpdater.h" +#include "SafetyEvaluator.h" +#include "ObservingConditionsMapper.h" #include #include #include +#include #include #include #include @@ -115,12 +118,18 @@ namespace SQM std::unique_ptr otaUpdater; + WiFiUDP alpacaDiscoveryUdp; + bool alpacaDiscoveryStarted = false; + mutable uint32_t alpacaServerTransactionId = 0; + // Setup route handlers void setupStaticRoutes(); void setupAPIRoutes(); void setupWebSocket(); void setupOTA(); void setupGithubUpdates(); + void setupAlpacaRoutes(); + void handleAlpacaDiscovery(); // API endpoint handlers void handleGetStatus(AsyncWebServerRequest *request); @@ -160,6 +169,13 @@ namespace SQM static std::string createErrorJson(const char *error); static bool scheduleRestart(uint32_t delayMs); static uint32_t ageMs(uint32_t now, uint32_t timestamp); + + // Alpaca helpers + Alpaca::SafetyInputs buildAlpacaSafetyInputs() const; + Alpaca::ObservingConditionsSnapshot buildAlpacaObservingConditionsSnapshot() const; + std::string buildAlpacaResponseBool(AsyncWebServerRequest *request, bool value, int errorNumber, const std::string &errorMessage) const; + std::string buildAlpacaResponseDouble(AsyncWebServerRequest *request, double value, int errorNumber, const std::string &errorMessage) const; + std::string buildAlpacaResponseVoid(AsyncWebServerRequest *request, int errorNumber, const std::string &errorMessage) const; }; } // namespace SQM diff --git a/lib/AlpacaLogic/include/AlpacaDiscovery.h b/lib/AlpacaLogic/include/AlpacaDiscovery.h new file mode 100644 index 0000000..d1888ef --- /dev/null +++ b/lib/AlpacaLogic/include/AlpacaDiscovery.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include + +namespace SQM +{ + namespace Alpaca + { + + constexpr uint16_t DISCOVERY_UDP_PORT = 32227; + + // Validates an incoming UDP payload against the Alpaca discovery + // protocol: a valid request is the ASCII string "alpacadiscovery1" + // (case-sensitive, per the ASCOM Alpaca discovery spec), optionally + // with trailing data that's ignored. No networking - just parsing, + // so it's unit-testable without a socket. + bool isValidDiscoveryRequest(const uint8_t *data, size_t len); + + // Builds the JSON discovery response body: {"AlpacaPort": } + std::string buildDiscoveryResponse(uint16_t alpacaPort); + + } // namespace Alpaca +} // namespace SQM diff --git a/lib/AlpacaLogic/include/ObservingConditionsMapper.h b/lib/AlpacaLogic/include/ObservingConditionsMapper.h new file mode 100644 index 0000000..2f4fe7a --- /dev/null +++ b/lib/AlpacaLogic/include/ObservingConditionsMapper.h @@ -0,0 +1,49 @@ +#pragma once + +#include + +namespace SQM +{ + namespace Alpaca + { + + // ASCOM Alpaca standard error numbers (subset actually used here). + // See the ASCOM Alpaca API spec / ASCOM.Common.Alpaca.AlpacaErrors. + constexpr int ALPACA_ERR_NOT_IMPLEMENTED = 0x400; + constexpr int ALPACA_ERR_INVALID_VALUE = 0x401; + constexpr int ALPACA_ERR_NOT_CONNECTED = 0x407; + constexpr int ALPACA_ERR_DRIVER_BASE = 0x500; // 0x500-0xFFF: custom driver errors + + struct PropertyResult + { + bool ok = false; + double value = 0.0; + int errorNumber = 0; + std::string errorMessage; + }; + + // Sensor readings mapped into Alpaca's ObservingConditions property + // space. `dataValid` reflects whether the underlying sensor data is + // fresh/available - when false, every implemented property returns a + // driver error instead of a stale/zeroed value. + struct ObservingConditionsSnapshot + { + bool dataValid = false; + float cloudCoverPercent = 0.0f; + float dewpointC = 0.0f; + float humidityPercent = 0.0f; + float skyBrightnessLux = 0.0f; + float skyQualityMagArcsec2 = 0.0f; + float skyTemperatureC = 0.0f; + float temperatureC = 0.0f; + }; + + // Case-insensitive Alpaca property name -> value/error. Properties + // this device has no sensor for (pressure, rainrate, starfwhm, + // winddirection, windgust, windspeed) return NotImplemented, matching + // the Go bridge's documented behavior for the same gaps. + // "averageperiod" is implemented and always 0 (no averaging done). + PropertyResult getObservingConditionsProperty(const std::string &propertyName, const ObservingConditionsSnapshot &snapshot); + + } // namespace Alpaca +} // namespace SQM diff --git a/lib/AlpacaLogic/include/SafetyEvaluator.h b/lib/AlpacaLogic/include/SafetyEvaluator.h new file mode 100644 index 0000000..482f41a --- /dev/null +++ b/lib/AlpacaLogic/include/SafetyEvaluator.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include + +namespace SQM +{ + namespace Alpaca + { + + // Configurable safety thresholds, ported from the SQMeter-ASCOM-Alpaca + // Go bridge's rule set (see its README "Safety rules" section) so the + // firmware-native SafetyMonitor behaves identically to what it + // replaces. Threshold checks are individually enable-able, matching + // the Go bridge's "if configured" semantics - an unset threshold + // never contributes to an unsafe verdict. + struct SafetyThresholds + { + bool manualOverrideUnsafe = false; + uint32_t staleAfterSeconds = 30; + + bool cloudCoverEnabled = true; + float cloudCoverUnsafePercent = 90.0f; + + bool sqmMinEnabled = false; + float sqmMinSafe = 0.0f; + + bool humidityMaxEnabled = false; + float humidityMaxSafe = 100.0f; + + bool dewpointMarginEnabled = false; + float dewpointMarginMinC = 0.0f; + }; + + // Current sensor/data-freshness state to evaluate against the + // thresholds above. Values are only consulted when the + // corresponding threshold is enabled and data isn't stale/missing. + struct SafetyInputs + { + bool hasEverHadGoodData = false; + uint32_t secondsSinceLastGoodData = 0; + bool requiredSensorFault = false; // any required sensor reporting a non-OK status + + float cloudCoverPercent = 0.0f; + float sqm = 0.0f; + float humidityPercent = 0.0f; + float temperatureC = 0.0f; + float dewpointC = 0.0f; + }; + + struct SafetyResult + { + bool isSafe = false; + std::vector unsafeReasons; // empty when isSafe is true + }; + + // Pure evaluation, no I/O - matches the Go bridge's rule precedence: + // manual override, then data freshness/availability, then sensor + // health, then the individual threshold checks. All applicable + // reasons are collected, not just the first one, so the diagnostics + // endpoint can show everything that's wrong at once. + SafetyResult evaluateSafety(const SafetyInputs &inputs, const SafetyThresholds &thresholds); + + } // namespace Alpaca +} // namespace SQM diff --git a/lib/AlpacaLogic/src/AlpacaDiscovery.cpp b/lib/AlpacaLogic/src/AlpacaDiscovery.cpp new file mode 100644 index 0000000..b21c6da --- /dev/null +++ b/lib/AlpacaLogic/src/AlpacaDiscovery.cpp @@ -0,0 +1,28 @@ +#include "AlpacaDiscovery.h" +#include + +namespace SQM +{ + namespace Alpaca + { + namespace + { + constexpr char DISCOVERY_MAGIC[] = "alpacadiscovery1"; + constexpr size_t DISCOVERY_MAGIC_LEN = sizeof(DISCOVERY_MAGIC) - 1; // exclude trailing NUL + } + + bool isValidDiscoveryRequest(const uint8_t *data, size_t len) + { + if (data == nullptr || len < DISCOVERY_MAGIC_LEN) + return false; + + return std::memcmp(data, DISCOVERY_MAGIC, DISCOVERY_MAGIC_LEN) == 0; + } + + std::string buildDiscoveryResponse(uint16_t alpacaPort) + { + return "{\"AlpacaPort\":" + std::to_string(alpacaPort) + "}"; + } + + } // namespace Alpaca +} // namespace SQM diff --git a/lib/AlpacaLogic/src/ObservingConditionsMapper.cpp b/lib/AlpacaLogic/src/ObservingConditionsMapper.cpp new file mode 100644 index 0000000..9406669 --- /dev/null +++ b/lib/AlpacaLogic/src/ObservingConditionsMapper.cpp @@ -0,0 +1,85 @@ +#include "ObservingConditionsMapper.h" +#include +#include + +namespace SQM +{ + namespace Alpaca + { + namespace + { + std::string toLower(const std::string &s) + { + std::string out = s; + std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) + { return std::tolower(c); }); + return out; + } + + PropertyResult notImplemented() + { + PropertyResult r; + r.ok = false; + r.errorNumber = ALPACA_ERR_NOT_IMPLEMENTED; + r.errorMessage = "Property not implemented by this device"; + return r; + } + + PropertyResult noData() + { + PropertyResult r; + r.ok = false; + r.errorNumber = ALPACA_ERR_DRIVER_BASE; + r.errorMessage = "No valid sensor data available"; + return r; + } + + PropertyResult value(double v) + { + PropertyResult r; + r.ok = true; + r.value = v; + return r; + } + } + + PropertyResult getObservingConditionsProperty(const std::string &propertyName, const ObservingConditionsSnapshot &snapshot) + { + const std::string name = toLower(propertyName); + + if (name == "averageperiod") + { + return value(0.0); // no averaging performed + } + + if (name == "pressure" || name == "rainrate" || name == "starfwhm" || + name == "winddirection" || name == "windgust" || name == "windspeed") + { + return notImplemented(); + } + + if (!snapshot.dataValid) + { + return noData(); + } + + if (name == "cloudcover") + return value(snapshot.cloudCoverPercent); + if (name == "dewpoint") + return value(snapshot.dewpointC); + if (name == "humidity") + return value(snapshot.humidityPercent); + if (name == "skybrightness") + return value(snapshot.skyBrightnessLux); + if (name == "skyquality") + return value(snapshot.skyQualityMagArcsec2); + if (name == "skytemperature") + return value(snapshot.skyTemperatureC); + if (name == "temperature") + return value(snapshot.temperatureC); + + return notImplemented(); + } + + } // namespace Alpaca +} // namespace SQM diff --git a/lib/AlpacaLogic/src/SafetyEvaluator.cpp b/lib/AlpacaLogic/src/SafetyEvaluator.cpp new file mode 100644 index 0000000..c07be2a --- /dev/null +++ b/lib/AlpacaLogic/src/SafetyEvaluator.cpp @@ -0,0 +1,65 @@ +#include "SafetyEvaluator.h" + +namespace SQM +{ + namespace Alpaca + { + + SafetyResult evaluateSafety(const SafetyInputs &in, const SafetyThresholds &t) + { + SafetyResult result; + + if (t.manualOverrideUnsafe) + { + result.unsafeReasons.push_back("Manual override forces unsafe"); + } + + if (!in.hasEverHadGoodData) + { + result.unsafeReasons.push_back("No successful sensor data yet"); + } + else if (in.secondsSinceLastGoodData > t.staleAfterSeconds) + { + result.unsafeReasons.push_back("Sensor data is stale"); + } + + if (in.requiredSensorFault) + { + result.unsafeReasons.push_back("A required sensor is reporting a fault"); + } + + // Threshold checks only apply once we have fresh data - an unsafe + // verdict from missing/stale data above already covers that case, + // and comparing garbage/zeroed readings here would just produce + // misleading extra reasons. + const bool haveFreshData = in.hasEverHadGoodData && in.secondsSinceLastGoodData <= t.staleAfterSeconds; + + if (haveFreshData) + { + if (t.cloudCoverEnabled && in.cloudCoverPercent >= t.cloudCoverUnsafePercent) + { + result.unsafeReasons.push_back("Cloud cover at or above unsafe threshold"); + } + + if (t.sqmMinEnabled && in.sqm < t.sqmMinSafe) + { + result.unsafeReasons.push_back("Sky brightness (SQM) below minimum safe value"); + } + + if (t.humidityMaxEnabled && in.humidityPercent > t.humidityMaxSafe) + { + result.unsafeReasons.push_back("Humidity above maximum safe value"); + } + + if (t.dewpointMarginEnabled && (in.temperatureC - in.dewpointC) < t.dewpointMarginMinC) + { + result.unsafeReasons.push_back("Temperature-dewpoint margin below minimum"); + } + } + + result.isSafe = result.unsafeReasons.empty(); + return result; + } + + } // namespace Alpaca +} // namespace SQM diff --git a/mkdocs.yml b/mkdocs.yml index d6122bc..b74a264 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -75,6 +75,7 @@ nav: - Security: user-guide/security.md - MQTT Integration: user-guide/mqtt.md - OTA Updates: user-guide/ota.md + - ASCOM Alpaca: user-guide/alpaca.md - Hardware: - Overview: hardware/overview.md - RG-15 Rain Sensor: hardware/rg15.md diff --git a/platformio.ini b/platformio.ini index bbd1a0c..20b8b2c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -48,5 +48,14 @@ upload_speed = 115200 upload_protocol = esptool ; Default to serial, can override with --upload-port IP ; Build web UI before uploading filesystem -extra_scripts = +extra_scripts = pre:scripts/build_web.py + +; Native (desktop) unit-test environment for platform-independent logic +; only (lib/AlpacaLogic) - no ESP32 toolchain, no hardware, fast CI feedback. +[env:native] +platform = native +test_framework = unity +build_flags = + -std=gnu++17 + -Ilib/AlpacaLogic/include diff --git a/src/Config.cpp b/src/Config.cpp index 5d2d8fe..e2f0f40 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -306,12 +306,24 @@ namespace SQM cfg.cloudDetection.cloudyThreshold = -3.0f; cfg.cloudDetection.humidityCorrection = 0.75f; + cfg.alpaca.enabled = false; + cfg.alpaca.manualOverrideUnsafe = false; + cfg.alpaca.staleAfterSeconds = 30; + cfg.alpaca.cloudCoverEnabled = true; + cfg.alpaca.cloudCoverUnsafePercent = 90.0f; + cfg.alpaca.sqmMinEnabled = false; + cfg.alpaca.sqmMinSafe = 0.0f; + cfg.alpaca.humidityMaxEnabled = false; + cfg.alpaca.humidityMaxSafe = 100.0f; + cfg.alpaca.dewpointMarginEnabled = false; + cfg.alpaca.dewpointMarginMinC = 0.0f; + return cfg; } std::string Config::toJson(bool redactSecrets) const { - DynamicJsonDocument doc(4608); + DynamicJsonDocument doc(5120); doc["deviceName"] = deviceName; doc["timezone"] = timezone; @@ -397,6 +409,19 @@ namespace SQM cloudDetection["cloudyThreshold"] = this->cloudDetection.cloudyThreshold; cloudDetection["humidityCorrection"] = this->cloudDetection.humidityCorrection; + JsonObject alpaca = doc.createNestedObject("alpaca"); + alpaca["enabled"] = this->alpaca.enabled; + alpaca["manualOverrideUnsafe"] = this->alpaca.manualOverrideUnsafe; + alpaca["staleAfterSeconds"] = this->alpaca.staleAfterSeconds; + alpaca["cloudCoverEnabled"] = this->alpaca.cloudCoverEnabled; + alpaca["cloudCoverUnsafePercent"] = this->alpaca.cloudCoverUnsafePercent; + alpaca["sqmMinEnabled"] = this->alpaca.sqmMinEnabled; + alpaca["sqmMinSafe"] = this->alpaca.sqmMinSafe; + alpaca["humidityMaxEnabled"] = this->alpaca.humidityMaxEnabled; + alpaca["humidityMaxSafe"] = this->alpaca.humidityMaxSafe; + alpaca["dewpointMarginEnabled"] = this->alpaca.dewpointMarginEnabled; + alpaca["dewpointMarginMinC"] = this->alpaca.dewpointMarginMinC; + std::string output; serializeJson(doc, output); return output; @@ -568,12 +593,37 @@ namespace SQM return setError(error, "Cloud detection: clear-sky threshold must be less than cloudy threshold"); } + if (alpaca.staleAfterSeconds < 1 || alpaca.staleAfterSeconds > 3600) + { + return setError(error, "Alpaca: stale data threshold must be between 1 and 3600 seconds"); + } + + if (!std::isfinite(alpaca.cloudCoverUnsafePercent) || alpaca.cloudCoverUnsafePercent < 0.0F || alpaca.cloudCoverUnsafePercent > 100.0F) + { + return setError(error, "Alpaca: cloud cover threshold must be between 0 and 100 percent"); + } + + if (!std::isfinite(alpaca.sqmMinSafe) || alpaca.sqmMinSafe < 0.0F || alpaca.sqmMinSafe > 30.0F) + { + return setError(error, "Alpaca: minimum SQM threshold must be between 0 and 30"); + } + + if (!std::isfinite(alpaca.humidityMaxSafe) || alpaca.humidityMaxSafe < 0.0F || alpaca.humidityMaxSafe > 100.0F) + { + return setError(error, "Alpaca: maximum humidity threshold must be between 0 and 100 percent"); + } + + if (!std::isfinite(alpaca.dewpointMarginMinC) || alpaca.dewpointMarginMinC < 0.0F || alpaca.dewpointMarginMinC > 20.0F) + { + return setError(error, "Alpaca: dewpoint margin threshold must be between 0 and 20 degrees C"); + } + return true; } std::optional Config::fromJson(const std::string &json, const Config *baseConfig) { - DynamicJsonDocument doc(4608); + DynamicJsonDocument doc(5120); DeserializationError error = deserializeJson(doc, json); if (error) @@ -761,6 +811,33 @@ namespace SQM cfg.cloudDetection.humidityCorrection = cloudDetectionObj["humidityCorrection"] | 0.75f; } + JsonObject alpacaObj = doc["alpaca"]; + if (!alpacaObj.isNull()) + { + if (alpacaObj.containsKey("enabled")) + cfg.alpaca.enabled = alpacaObj["enabled"] | false; + if (alpacaObj.containsKey("manualOverrideUnsafe")) + cfg.alpaca.manualOverrideUnsafe = alpacaObj["manualOverrideUnsafe"] | false; + if (alpacaObj.containsKey("staleAfterSeconds")) + cfg.alpaca.staleAfterSeconds = alpacaObj["staleAfterSeconds"] | 30; + if (alpacaObj.containsKey("cloudCoverEnabled")) + cfg.alpaca.cloudCoverEnabled = alpacaObj["cloudCoverEnabled"] | true; + if (alpacaObj.containsKey("cloudCoverUnsafePercent")) + cfg.alpaca.cloudCoverUnsafePercent = alpacaObj["cloudCoverUnsafePercent"] | 90.0f; + if (alpacaObj.containsKey("sqmMinEnabled")) + cfg.alpaca.sqmMinEnabled = alpacaObj["sqmMinEnabled"] | false; + if (alpacaObj.containsKey("sqmMinSafe")) + cfg.alpaca.sqmMinSafe = alpacaObj["sqmMinSafe"] | 0.0f; + if (alpacaObj.containsKey("humidityMaxEnabled")) + cfg.alpaca.humidityMaxEnabled = alpacaObj["humidityMaxEnabled"] | false; + if (alpacaObj.containsKey("humidityMaxSafe")) + cfg.alpaca.humidityMaxSafe = alpacaObj["humidityMaxSafe"] | 100.0f; + if (alpacaObj.containsKey("dewpointMarginEnabled")) + cfg.alpaca.dewpointMarginEnabled = alpacaObj["dewpointMarginEnabled"] | false; + if (alpacaObj.containsKey("dewpointMarginMinC")) + cfg.alpaca.dewpointMarginMinC = alpacaObj["dewpointMarginMinC"] | 0.0f; + } + normalizeTimeSources(cfg); std::string validationError; diff --git a/src/WebServer.cpp b/src/WebServer.cpp index a735289..a856d3e 100644 --- a/src/WebServer.cpp +++ b/src/WebServer.cpp @@ -16,6 +16,7 @@ #include #include "calculations/CloudDetection.h" #include "sensors/RG15Sensor.h" +#include "AlpacaDiscovery.h" extern uint32_t bootCount; @@ -278,8 +279,22 @@ namespace SQM setupWebSocket(); setupOTA(); setupGithubUpdates(); + setupAlpacaRoutes(); setupStaticRoutes(); // Must be last - has catch-all serveStatic + if (getConfigCallback().alpaca.enabled) + { + if (alpacaDiscoveryUdp.begin(Alpaca::DISCOVERY_UDP_PORT)) + { + alpacaDiscoveryStarted = true; + Logger::info(TAG, "Alpaca UDP discovery listening on port %u", Alpaca::DISCOVERY_UDP_PORT); + } + else + { + Logger::error(TAG, "Failed to start Alpaca UDP discovery listener"); + } + } + // SPA fallback - serve index.html for any non-API routes server.onNotFound([](AsyncWebServerRequest *request) { @@ -303,6 +318,7 @@ namespace SQM wsSensors.cleanupClients(); wsStatus.cleanupClients(); pollWiFiConnect(); + handleAlpacaDiscovery(); const uint32_t now = millis(); @@ -762,6 +778,311 @@ namespace SQM server.addHandler(applyHandler); } + namespace + { + uint32_t getAlpacaClientTransactionId(AsyncWebServerRequest *request) + { + if (request->hasParam("ClientTransactionID")) + return request->getParam("ClientTransactionID")->value().toInt(); + if (request->hasParam("ClientTransactionID", true)) + return request->getParam("ClientTransactionID", true)->value().toInt(); + return 0; + } + } + + std::string WebServer::buildAlpacaResponseBool(AsyncWebServerRequest *request, bool value, int errorNumber, const std::string &errorMessage) const + { + StaticJsonDocument<192> doc; + doc["Value"] = value; + doc["ClientTransactionID"] = getAlpacaClientTransactionId(request); + doc["ServerTransactionID"] = ++alpacaServerTransactionId; + doc["ErrorNumber"] = errorNumber; + doc["ErrorMessage"] = errorMessage; + std::string json; + serializeJson(doc, json); + return json; + } + + std::string WebServer::buildAlpacaResponseDouble(AsyncWebServerRequest *request, double value, int errorNumber, const std::string &errorMessage) const + { + StaticJsonDocument<192> doc; + doc["Value"] = value; + doc["ClientTransactionID"] = getAlpacaClientTransactionId(request); + doc["ServerTransactionID"] = ++alpacaServerTransactionId; + doc["ErrorNumber"] = errorNumber; + doc["ErrorMessage"] = errorMessage; + std::string json; + serializeJson(doc, json); + return json; + } + + std::string WebServer::buildAlpacaResponseVoid(AsyncWebServerRequest *request, int errorNumber, const std::string &errorMessage) const + { + StaticJsonDocument<192> doc; + doc["ClientTransactionID"] = getAlpacaClientTransactionId(request); + doc["ServerTransactionID"] = ++alpacaServerTransactionId; + doc["ErrorNumber"] = errorNumber; + doc["ErrorMessage"] = errorMessage; + std::string json; + serializeJson(doc, json); + return json; + } + + namespace + { + std::string buildAlpacaResponseString(AsyncWebServerRequest *request, const std::string &value, uint32_t &txnCounter) + { + StaticJsonDocument<256> doc; + doc["Value"] = value; + doc["ClientTransactionID"] = getAlpacaClientTransactionId(request); + doc["ServerTransactionID"] = ++txnCounter; + doc["ErrorNumber"] = 0; + doc["ErrorMessage"] = ""; + std::string json; + serializeJson(doc, json); + return json; + } + + std::string buildAlpacaResponseIntArray(AsyncWebServerRequest *request, const std::vector &values, uint32_t &txnCounter) + { + DynamicJsonDocument doc(256); + JsonArray arr = doc.createNestedArray("Value"); + for (int v : values) + arr.add(v); + doc["ClientTransactionID"] = getAlpacaClientTransactionId(request); + doc["ServerTransactionID"] = ++txnCounter; + doc["ErrorNumber"] = 0; + doc["ErrorMessage"] = ""; + std::string json; + serializeJson(doc, json); + return json; + } + + std::string buildAlpacaResponseStringArray(AsyncWebServerRequest *request, const std::vector &values, uint32_t &txnCounter) + { + DynamicJsonDocument doc(256); + JsonArray arr = doc.createNestedArray("Value"); + for (const auto &v : values) + arr.add(v); + doc["ClientTransactionID"] = getAlpacaClientTransactionId(request); + doc["ServerTransactionID"] = ++txnCounter; + doc["ErrorNumber"] = 0; + doc["ErrorMessage"] = ""; + std::string json; + serializeJson(doc, json); + return json; + } + } + + Alpaca::SafetyInputs WebServer::buildAlpacaSafetyInputs() const + { + const SensorSnapshot snapshot = getSensorSnapshot(); + const uint32_t now = millis(); + const Config &cfg = getConfigCallback(); + + Alpaca::SafetyInputs in; + in.hasEverHadGoodData = snapshot.dataTimestamp != 0; + in.secondsSinceLastGoodData = ageMs(now, snapshot.dataTimestamp) / 1000; + in.requiredSensorFault = snapshot.tsl.status != SensorStatus::OK || + snapshot.mlx.status != SensorStatus::OK; + + SkyQualityMetrics sqm = SkyQuality::calculate(snapshot.tsl.lux); + in.sqm = sqm.sqm; + + bool usingHumidityFallback = snapshot.bme.status != SensorStatus::OK; + float humidity = usingHumidityFallback ? 53.0f : snapshot.bme.humidity; + CloudMetrics cloudMetrics = CloudDetection::calculate( + snapshot.mlx.objectTemp, + snapshot.mlx.ambientTemp, + humidity, + cfg.cloudDetection.clearSkyThreshold, + cfg.cloudDetection.cloudyThreshold, + cfg.cloudDetection.humidityCorrection); + in.cloudCoverPercent = cloudMetrics.cloudCoverPercent; + in.humidityPercent = humidity; + in.temperatureC = snapshot.bme.temperature; + in.dewpointC = snapshot.bme.dewpoint; + + return in; + } + + Alpaca::ObservingConditionsSnapshot WebServer::buildAlpacaObservingConditionsSnapshot() const + { + const SensorSnapshot snapshot = getSensorSnapshot(); + const uint32_t now = millis(); + const uint32_t staleAfter = getConfigCallback().sensor.readIntervalMs + SENSOR_STALE_GRACE_MS; + const bool stale = snapshot.dataTimestamp == 0 || ageMs(now, snapshot.dataTimestamp) > staleAfter; + + Alpaca::ObservingConditionsSnapshot snap; + snap.dataValid = !stale; + + const Config &cfg = getConfigCallback(); + bool usingHumidityFallback = snapshot.bme.status != SensorStatus::OK; + float humidity = usingHumidityFallback ? 53.0f : snapshot.bme.humidity; + CloudMetrics cloudMetrics = CloudDetection::calculate( + snapshot.mlx.objectTemp, + snapshot.mlx.ambientTemp, + humidity, + cfg.cloudDetection.clearSkyThreshold, + cfg.cloudDetection.cloudyThreshold, + cfg.cloudDetection.humidityCorrection); + snap.cloudCoverPercent = cloudMetrics.cloudCoverPercent; + + SkyQualityMetrics sqm = SkyQuality::calculate(snapshot.tsl.lux); + snap.skyQualityMagArcsec2 = sqm.sqm; + snap.skyBrightnessLux = snapshot.tsl.lux; + snap.skyTemperatureC = snapshot.mlx.objectTemp; + snap.temperatureC = snapshot.bme.temperature; + snap.humidityPercent = humidity; + snap.dewpointC = snapshot.bme.dewpoint; + + return snap; + } + + void WebServer::setupAlpacaRoutes() + { + // --- Management API --- + server.on("/management/apiversions", HTTP_GET, [this](AsyncWebServerRequest *request) + { request->send(200, "application/json", buildAlpacaResponseIntArray(request, {1}, alpacaServerTransactionId).c_str()); }); + + server.on("/management/v1/description", HTTP_GET, [this](AsyncWebServerRequest *request) + { + DynamicJsonDocument doc(384); + JsonObject value = doc.createNestedObject("Value"); + value["ServerName"] = FIRMWARE_NAME; + value["Manufacturer"] = "SQMeter"; + value["ManufacturerVersion"] = FIRMWARE_VERSION; + value["Location"] = getConfigCallback().deviceName; + doc["ClientTransactionID"] = getAlpacaClientTransactionId(request); + doc["ServerTransactionID"] = ++alpacaServerTransactionId; + doc["ErrorNumber"] = 0; + doc["ErrorMessage"] = ""; + std::string json; + serializeJson(doc, json); + request->send(200, "application/json", json.c_str()); }); + + server.on("/management/v1/configureddevices", HTTP_GET, [this](AsyncWebServerRequest *request) + { + DynamicJsonDocument doc(768); + JsonArray value = doc.createNestedArray("Value"); + if (getConfigCallback().alpaca.enabled) { + JsonObject safety = value.createNestedObject(); + safety["DeviceName"] = "SQMeter SafetyMonitor"; + safety["DeviceType"] = "SafetyMonitor"; + safety["DeviceNumber"] = 0; + safety["UniqueID"] = "sqmeter-safetymonitor-0"; + + JsonObject obsCond = value.createNestedObject(); + obsCond["DeviceName"] = "SQMeter ObservingConditions"; + obsCond["DeviceType"] = "ObservingConditions"; + obsCond["DeviceNumber"] = 0; + obsCond["UniqueID"] = "sqmeter-observingconditions-0"; + } + doc["ClientTransactionID"] = getAlpacaClientTransactionId(request); + doc["ServerTransactionID"] = ++alpacaServerTransactionId; + doc["ErrorNumber"] = 0; + doc["ErrorMessage"] = ""; + std::string json; + serializeJson(doc, json); + request->send(200, "application/json", json.c_str()); }); + + // --- Common ASCOM device API, registered identically for both devices --- + auto registerCommonRoutes = [this](const std::string &basePath, const std::string &name, const std::string &description) + { + server.on((basePath + "/connected").c_str(), HTTP_GET, [this](AsyncWebServerRequest *request) + { request->send(200, "application/json", buildAlpacaResponseBool(request, getConfigCallback().alpaca.enabled, 0, "").c_str()); }); + server.on((basePath + "/connected").c_str(), HTTP_PUT, [this](AsyncWebServerRequest *request) + { request->send(200, "application/json", buildAlpacaResponseVoid(request, 0, "").c_str()); }); + server.on((basePath + "/name").c_str(), HTTP_GET, [this, name](AsyncWebServerRequest *request) + { request->send(200, "application/json", buildAlpacaResponseString(request, name, alpacaServerTransactionId).c_str()); }); + server.on((basePath + "/description").c_str(), HTTP_GET, [this, description](AsyncWebServerRequest *request) + { request->send(200, "application/json", buildAlpacaResponseString(request, description, alpacaServerTransactionId).c_str()); }); + server.on((basePath + "/driverinfo").c_str(), HTTP_GET, [this](AsyncWebServerRequest *request) + { request->send(200, "application/json", buildAlpacaResponseString(request, "Native ESP32 firmware, no external bridge - https://github.com/DeanJ87/SQMeter", alpacaServerTransactionId).c_str()); }); + server.on((basePath + "/driverversion").c_str(), HTTP_GET, [this](AsyncWebServerRequest *request) + { request->send(200, "application/json", buildAlpacaResponseString(request, FIRMWARE_VERSION, alpacaServerTransactionId).c_str()); }); + server.on((basePath + "/interfaceversion").c_str(), HTTP_GET, [this](AsyncWebServerRequest *request) + { request->send(200, "application/json", buildAlpacaResponseDouble(request, 1, 0, "").c_str()); }); + server.on((basePath + "/supportedactions").c_str(), HTTP_GET, [this](AsyncWebServerRequest *request) + { request->send(200, "application/json", buildAlpacaResponseStringArray(request, {}, alpacaServerTransactionId).c_str()); }); + }; + + registerCommonRoutes("/api/v1/safetymonitor/0", "SQMeter SafetyMonitor", + "Reports observatory safety based on cloud cover, sky brightness, humidity, and dew-point margin from the onboard SQMeter sensors."); + registerCommonRoutes("/api/v1/observingconditions/0", "SQMeter ObservingConditions", + "Reports sky quality, cloud cover, sky temperature, humidity, dew point, and ambient temperature from the onboard SQMeter sensors."); + + // --- SafetyMonitor-specific --- + server.on("/api/v1/safetymonitor/0/issafe", HTTP_GET, [this](AsyncWebServerRequest *request) + { + const Config &cfg = getConfigCallback(); + if (!cfg.alpaca.enabled) { + request->send(200, "application/json", buildAlpacaResponseBool(request, false, Alpaca::ALPACA_ERR_NOT_CONNECTED, "Alpaca support is disabled in device settings").c_str()); + return; + } + + Alpaca::SafetyThresholds thresholds; + thresholds.manualOverrideUnsafe = cfg.alpaca.manualOverrideUnsafe; + thresholds.staleAfterSeconds = cfg.alpaca.staleAfterSeconds; + thresholds.cloudCoverEnabled = cfg.alpaca.cloudCoverEnabled; + thresholds.cloudCoverUnsafePercent = cfg.alpaca.cloudCoverUnsafePercent; + thresholds.sqmMinEnabled = cfg.alpaca.sqmMinEnabled; + thresholds.sqmMinSafe = cfg.alpaca.sqmMinSafe; + thresholds.humidityMaxEnabled = cfg.alpaca.humidityMaxEnabled; + thresholds.humidityMaxSafe = cfg.alpaca.humidityMaxSafe; + thresholds.dewpointMarginEnabled = cfg.alpaca.dewpointMarginEnabled; + thresholds.dewpointMarginMinC = cfg.alpaca.dewpointMarginMinC; + + Alpaca::SafetyResult result = Alpaca::evaluateSafety(buildAlpacaSafetyInputs(), thresholds); + request->send(200, "application/json", buildAlpacaResponseBool(request, result.isSafe, 0, "").c_str()); }); + + // --- ObservingConditions-specific: one route per Alpaca property --- + static const char *observingProperties[] = { + "averageperiod", "cloudcover", "dewpoint", "humidity", "pressure", + "rainrate", "skybrightness", "skyquality", "skytemperature", + "starfwhm", "temperature", "winddirection", "windgust", "windspeed"}; + + for (const char *property : observingProperties) + { + std::string path = std::string("/api/v1/observingconditions/0/") + property; + std::string propertyName = property; + server.on(path.c_str(), HTTP_GET, [this, propertyName](AsyncWebServerRequest *request) + { + const Config &cfg = getConfigCallback(); + if (!cfg.alpaca.enabled) { + request->send(200, "application/json", buildAlpacaResponseBool(request, false, Alpaca::ALPACA_ERR_NOT_CONNECTED, "Alpaca support is disabled in device settings").c_str()); + return; + } + + Alpaca::PropertyResult result = Alpaca::getObservingConditionsProperty(propertyName, buildAlpacaObservingConditionsSnapshot()); + if (result.ok) { + request->send(200, "application/json", buildAlpacaResponseDouble(request, result.value, 0, "").c_str()); + } else { + request->send(200, "application/json", buildAlpacaResponseDouble(request, 0, result.errorNumber, result.errorMessage).c_str()); + } }); + } + } + + void WebServer::handleAlpacaDiscovery() + { + if (!alpacaDiscoveryStarted) + return; + + int packetSize = alpacaDiscoveryUdp.parsePacket(); + if (packetSize <= 0) + return; + + uint8_t buf[64]; + int len = alpacaDiscoveryUdp.read(buf, sizeof(buf)); + if (len > 0 && Alpaca::isValidDiscoveryRequest(buf, static_cast(len))) + { + std::string response = Alpaca::buildDiscoveryResponse(PORT); + alpacaDiscoveryUdp.beginPacket(alpacaDiscoveryUdp.remoteIP(), alpacaDiscoveryUdp.remotePort()); + alpacaDiscoveryUdp.write(reinterpret_cast(response.data()), response.size()); + alpacaDiscoveryUdp.endPacket(); + } + } + void WebServer::handleGetStatus(AsyncWebServerRequest *request) { std::string json = createStatusJson(); diff --git a/src/WiFiManager.cpp b/src/WiFiManager.cpp index fac6ae7..6c14944 100644 --- a/src/WiFiManager.cpp +++ b/src/WiFiManager.cpp @@ -41,7 +41,19 @@ namespace SQM dnsServer->processNextRequest(); } - if (!apMode && config.autoReconnect && !isConnected()) + if (apMode || !config.autoReconnect) + { + return; + } + + if (isConnected()) + { + // Reset backoff once healthy, so the next outage starts from the + // short base delay again instead of resuming at whatever the + // previous outage had escalated to. + currentReconnectDelay = config.reconnectDelayMs; + } + else { handleReconnect(); } @@ -126,6 +138,7 @@ namespace SQM stopCaptivePortal(); WiFi.mode(WIFI_STA); + currentReconnectDelay = config.reconnectDelayMs; // fresh credentials get a fresh backoff connectToWiFi(); return true; @@ -135,7 +148,6 @@ namespace SQM { WiFi.begin(config.ssid.c_str(), config.password.c_str()); lastReconnectAttempt = millis(); - currentReconnectDelay = config.reconnectDelayMs; } void WiFiManager::handleReconnect() diff --git a/test/test_alpaca_logic/test_main.cpp b/test/test_alpaca_logic/test_main.cpp new file mode 100644 index 0000000..83a1156 --- /dev/null +++ b/test/test_alpaca_logic/test_main.cpp @@ -0,0 +1,280 @@ +#include +#include +#include "SafetyEvaluator.h" +#include "ObservingConditionsMapper.h" +#include "AlpacaDiscovery.h" + +using namespace SQM::Alpaca; + +void setUp(void) {} +void tearDown(void) {} + +// --- SafetyEvaluator --- + +void test_safe_when_all_thresholds_pass(void) +{ + SafetyThresholds t; + SafetyInputs in; + in.hasEverHadGoodData = true; + in.secondsSinceLastGoodData = 5; + in.cloudCoverPercent = 10.0f; + + SafetyResult r = evaluateSafety(in, t); + TEST_ASSERT_TRUE(r.isSafe); + TEST_ASSERT_EQUAL(0, r.unsafeReasons.size()); +} + +void test_unsafe_before_any_good_data(void) +{ + SafetyThresholds t; + SafetyInputs in; // hasEverHadGoodData defaults false + + SafetyResult r = evaluateSafety(in, t); + TEST_ASSERT_FALSE(r.isSafe); + TEST_ASSERT_EQUAL(1, r.unsafeReasons.size()); +} + +void test_unsafe_when_data_stale(void) +{ + SafetyThresholds t; + t.staleAfterSeconds = 30; + SafetyInputs in; + in.hasEverHadGoodData = true; + in.secondsSinceLastGoodData = 31; + + SafetyResult r = evaluateSafety(in, t); + TEST_ASSERT_FALSE(r.isSafe); +} + +void test_manual_override_forces_unsafe(void) +{ + SafetyThresholds t; + t.manualOverrideUnsafe = true; + SafetyInputs in; + in.hasEverHadGoodData = true; + in.secondsSinceLastGoodData = 0; + + SafetyResult r = evaluateSafety(in, t); + TEST_ASSERT_FALSE(r.isSafe); +} + +void test_required_sensor_fault_forces_unsafe(void) +{ + SafetyThresholds t; + SafetyInputs in; + in.hasEverHadGoodData = true; + in.requiredSensorFault = true; + + SafetyResult r = evaluateSafety(in, t); + TEST_ASSERT_FALSE(r.isSafe); +} + +void test_cloud_cover_threshold(void) +{ + SafetyThresholds t; + t.cloudCoverEnabled = true; + t.cloudCoverUnsafePercent = 90.0f; + SafetyInputs in; + in.hasEverHadGoodData = true; + in.cloudCoverPercent = 90.0f; // >= threshold => unsafe + + SafetyResult r = evaluateSafety(in, t); + TEST_ASSERT_FALSE(r.isSafe); +} + +void test_cloud_cover_disabled_ignored(void) +{ + SafetyThresholds t; + t.cloudCoverEnabled = false; + SafetyInputs in; + in.hasEverHadGoodData = true; + in.cloudCoverPercent = 100.0f; + + SafetyResult r = evaluateSafety(in, t); + TEST_ASSERT_TRUE(r.isSafe); +} + +void test_sqm_min_threshold(void) +{ + SafetyThresholds t; + t.cloudCoverEnabled = false; + t.sqmMinEnabled = true; + t.sqmMinSafe = 18.0f; + SafetyInputs in; + in.hasEverHadGoodData = true; + in.sqm = 17.9f; + + SafetyResult r = evaluateSafety(in, t); + TEST_ASSERT_FALSE(r.isSafe); +} + +void test_humidity_max_threshold(void) +{ + SafetyThresholds t; + t.cloudCoverEnabled = false; + t.humidityMaxEnabled = true; + t.humidityMaxSafe = 85.0f; + SafetyInputs in; + in.hasEverHadGoodData = true; + in.humidityPercent = 90.0f; + + SafetyResult r = evaluateSafety(in, t); + TEST_ASSERT_FALSE(r.isSafe); +} + +void test_dewpoint_margin_threshold(void) +{ + SafetyThresholds t; + t.cloudCoverEnabled = false; + t.dewpointMarginEnabled = true; + t.dewpointMarginMinC = 3.0f; + SafetyInputs in; + in.hasEverHadGoodData = true; + in.temperatureC = 10.0f; + in.dewpointC = 8.5f; // margin 1.5 < 3.0 => unsafe + + SafetyResult r = evaluateSafety(in, t); + TEST_ASSERT_FALSE(r.isSafe); +} + +void test_stale_data_suppresses_threshold_checks(void) +{ + // Stale data already makes it unsafe for a different reason; threshold + // checks against stale/garbage readings shouldn't add misleading extras. + SafetyThresholds t; + t.staleAfterSeconds = 10; + t.cloudCoverEnabled = true; + t.cloudCoverUnsafePercent = 90.0f; + SafetyInputs in; + in.hasEverHadGoodData = true; + in.secondsSinceLastGoodData = 100; + in.cloudCoverPercent = 5.0f; // would itself be safe + + SafetyResult r = evaluateSafety(in, t); + TEST_ASSERT_FALSE(r.isSafe); + TEST_ASSERT_EQUAL(1, r.unsafeReasons.size()); // only the staleness reason +} + +// --- ObservingConditionsMapper --- + +void test_observing_conditions_maps_known_property(void) +{ + ObservingConditionsSnapshot snap; + snap.dataValid = true; + snap.skyQualityMagArcsec2 = 21.3f; + + PropertyResult r = getObservingConditionsProperty("SkyQuality", snap); + TEST_ASSERT_TRUE(r.ok); + TEST_ASSERT_EQUAL_FLOAT(21.3f, r.value); +} + +void test_observing_conditions_case_insensitive(void) +{ + ObservingConditionsSnapshot snap; + snap.dataValid = true; + snap.temperatureC = 12.5f; + + PropertyResult r = getObservingConditionsProperty("TEMPERATURE", snap); + TEST_ASSERT_TRUE(r.ok); +} + +void test_observing_conditions_not_implemented_property(void) +{ + ObservingConditionsSnapshot snap; + snap.dataValid = true; + + PropertyResult r = getObservingConditionsProperty("windspeed", snap); + TEST_ASSERT_FALSE(r.ok); + TEST_ASSERT_EQUAL(ALPACA_ERR_NOT_IMPLEMENTED, r.errorNumber); +} + +void test_observing_conditions_average_period_always_zero(void) +{ + ObservingConditionsSnapshot snap; // dataValid false - shouldn't matter + PropertyResult r = getObservingConditionsProperty("averageperiod", snap); + TEST_ASSERT_TRUE(r.ok); + TEST_ASSERT_EQUAL_FLOAT(0.0, r.value); +} + +void test_observing_conditions_no_data_error(void) +{ + ObservingConditionsSnapshot snap; + snap.dataValid = false; + + PropertyResult r = getObservingConditionsProperty("humidity", snap); + TEST_ASSERT_FALSE(r.ok); + TEST_ASSERT_EQUAL(ALPACA_ERR_DRIVER_BASE, r.errorNumber); +} + +void test_observing_conditions_unknown_property(void) +{ + ObservingConditionsSnapshot snap; + snap.dataValid = true; + + PropertyResult r = getObservingConditionsProperty("bogus", snap); + TEST_ASSERT_FALSE(r.ok); + TEST_ASSERT_EQUAL(ALPACA_ERR_NOT_IMPLEMENTED, r.errorNumber); +} + +// --- AlpacaDiscovery --- + +void test_discovery_valid_packet(void) +{ + const char *payload = "alpacadiscovery1"; + TEST_ASSERT_TRUE(isValidDiscoveryRequest(reinterpret_cast(payload), strlen(payload))); +} + +void test_discovery_rejects_wrong_payload(void) +{ + const char *payload = "not-alpaca-at-all"; + TEST_ASSERT_FALSE(isValidDiscoveryRequest(reinterpret_cast(payload), strlen(payload))); +} + +void test_discovery_rejects_short_payload(void) +{ + const char *payload = "alpaca"; + TEST_ASSERT_FALSE(isValidDiscoveryRequest(reinterpret_cast(payload), strlen(payload))); +} + +void test_discovery_rejects_null(void) +{ + TEST_ASSERT_FALSE(isValidDiscoveryRequest(nullptr, 0)); +} + +void test_discovery_response_body(void) +{ + std::string body = buildDiscoveryResponse(80); + TEST_ASSERT_EQUAL_STRING("{\"AlpacaPort\":80}", body.c_str()); +} + +int main(int argc, char **argv) +{ + UNITY_BEGIN(); + + RUN_TEST(test_safe_when_all_thresholds_pass); + RUN_TEST(test_unsafe_before_any_good_data); + RUN_TEST(test_unsafe_when_data_stale); + RUN_TEST(test_manual_override_forces_unsafe); + RUN_TEST(test_required_sensor_fault_forces_unsafe); + RUN_TEST(test_cloud_cover_threshold); + RUN_TEST(test_cloud_cover_disabled_ignored); + RUN_TEST(test_sqm_min_threshold); + RUN_TEST(test_humidity_max_threshold); + RUN_TEST(test_dewpoint_margin_threshold); + RUN_TEST(test_stale_data_suppresses_threshold_checks); + + RUN_TEST(test_observing_conditions_maps_known_property); + RUN_TEST(test_observing_conditions_case_insensitive); + RUN_TEST(test_observing_conditions_not_implemented_property); + RUN_TEST(test_observing_conditions_average_period_always_zero); + RUN_TEST(test_observing_conditions_no_data_error); + RUN_TEST(test_observing_conditions_unknown_property); + + RUN_TEST(test_discovery_valid_packet); + RUN_TEST(test_discovery_rejects_wrong_payload); + RUN_TEST(test_discovery_rejects_short_payload); + RUN_TEST(test_discovery_rejects_null); + RUN_TEST(test_discovery_response_body); + + return UNITY_END(); +} diff --git a/web/src/__tests__/configSchema.test.ts b/web/src/__tests__/configSchema.test.ts index 439a5e1..cd46158 100644 --- a/web/src/__tests__/configSchema.test.ts +++ b/web/src/__tests__/configSchema.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { configSchema, authConfigSchema, + alpacaConfigSchema, getConfigValidationErrors, getConfigValidationMessage, hasConfigValidationErrors, @@ -220,6 +221,61 @@ describe("authConfigSchema", () => { }); }); +describe("alpacaConfigSchema", () => { + const validAlpaca = { + enabled: false, + manualOverrideUnsafe: false, + staleAfterSeconds: 30, + cloudCoverEnabled: true, + cloudCoverUnsafePercent: 90, + sqmMinEnabled: false, + sqmMinSafe: 0, + humidityMaxEnabled: false, + humidityMaxSafe: 100, + dewpointMarginEnabled: false, + dewpointMarginMinC: 0, + }; + + it("passes with valid defaults", () => { + expect(alpacaConfigSchema.safeParse(validAlpaca).success).toBe(true); + }); + + it("fails when staleAfterSeconds is zero", () => { + expect(alpacaConfigSchema.safeParse({ ...validAlpaca, staleAfterSeconds: 0 }).success).toBe(false); + }); + + it("fails when staleAfterSeconds exceeds 1 hour", () => { + expect(alpacaConfigSchema.safeParse({ ...validAlpaca, staleAfterSeconds: 3601 }).success).toBe(false); + }); + + it("fails when cloudCoverUnsafePercent is out of range", () => { + expect(alpacaConfigSchema.safeParse({ ...validAlpaca, cloudCoverUnsafePercent: 101 }).success).toBe(false); + expect(alpacaConfigSchema.safeParse({ ...validAlpaca, cloudCoverUnsafePercent: -1 }).success).toBe(false); + }); + + it("fails when sqmMinSafe is out of range", () => { + expect(alpacaConfigSchema.safeParse({ ...validAlpaca, sqmMinSafe: -1 }).success).toBe(false); + expect(alpacaConfigSchema.safeParse({ ...validAlpaca, sqmMinSafe: 31 }).success).toBe(false); + }); + + it("fails when humidityMaxSafe is out of range", () => { + expect(alpacaConfigSchema.safeParse({ ...validAlpaca, humidityMaxSafe: 101 }).success).toBe(false); + }); + + it("fails when dewpointMarginMinC is out of range", () => { + expect(alpacaConfigSchema.safeParse({ ...validAlpaca, dewpointMarginMinC: -1 }).success).toBe(false); + expect(alpacaConfigSchema.safeParse({ ...validAlpaca, dewpointMarginMinC: 21 }).success).toBe(false); + }); + + it("is accepted as an optional field on the full config schema", () => { + expect(configSchema.safeParse({ ...validBase, alpaca: validAlpaca }).success).toBe(true); + }); + + it("full config schema still passes when alpaca is omitted", () => { + expect(configSchema.safeParse(validBase).success).toBe(true); + }); +}); + describe("mockConfig", () => { it("has an auth field", () => { expect(mockConfig.auth).toBeDefined(); diff --git a/web/src/components/Settings.tsx b/web/src/components/Settings.tsx index e169a3e..b793afa 100644 --- a/web/src/components/Settings.tsx +++ b/web/src/components/Settings.tsx @@ -50,6 +50,20 @@ const defaultCloudDetectionConfig: Config['cloudDetection'] = { humidityCorrection: 0.75, }; +const defaultAlpacaConfig: NonNullable = { + enabled: false, + manualOverrideUnsafe: false, + staleAfterSeconds: 30, + cloudCoverEnabled: true, + cloudCoverUnsafePercent: 90, + sqmMinEnabled: false, + sqmMinSafe: 0, + humidityMaxEnabled: false, + humidityMaxSafe: 100, + dewpointMarginEnabled: false, + dewpointMarginMinC: 0, +}; + const fieldErrorAliases: Record = { mqttBroker: 'mqtt.broker', mqttPort: 'mqtt.port', @@ -115,6 +129,7 @@ const toConfigPayload = (source: Config): Config => { cloudDetection: source.cloudDetection ? { ...source.cloudDetection } : { ...defaultCloudDetectionConfig }, + alpaca: source.alpaca ? { ...source.alpaca } : { ...defaultAlpacaConfig }, }; return { @@ -309,6 +324,7 @@ const Settings: FunctionalComponent = () => { gps: { ...config.gps }, sensor: { ...config.sensor }, cloudDetection: { ...(config.cloudDetection ?? defaultCloudDetectionConfig) }, + alpaca: config.alpaca ? { ...config.alpaca } : { ...defaultAlpacaConfig }, auth: config.auth ? { ...config.auth } : { ...defaultAuthConfig }, rain: config.rain ? { ...config.rain } : { ...defaultRainConfig }, }; @@ -337,6 +353,7 @@ const Settings: FunctionalComponent = () => { gps: { ...config.gps }, sensor: { ...config.sensor }, cloudDetection: { ...(config.cloudDetection ?? defaultCloudDetectionConfig) }, + alpaca: config.alpaca ? { ...config.alpaca } : { ...defaultAlpacaConfig }, auth: config.auth ? { ...config.auth } : { ...defaultAuthConfig }, rain: { ...(config.rain ? { ...config.rain } : { ...defaultRainConfig }), @@ -368,6 +385,7 @@ const Settings: FunctionalComponent = () => { gps: { ...config.gps }, sensor: { ...config.sensor }, cloudDetection: { ...(config.cloudDetection ?? defaultCloudDetectionConfig) }, + alpaca: config.alpaca ? { ...config.alpaca } : { ...defaultAlpacaConfig }, auth: config.auth ? { ...config.auth } : { ...defaultAuthConfig }, rain: config.rain ? { ...config.rain } : { ...defaultRainConfig }, }; @@ -394,6 +412,8 @@ const Settings: FunctionalComponent = () => { ); } + const alpaca = config.alpaca ?? defaultAlpacaConfig; + return (
{message && ( @@ -1215,6 +1235,134 @@ const Settings: FunctionalComponent = () => {
+ {/* ASCOM Alpaca Settings */} +
+

ASCOM Alpaca

+

+ Exposes this device directly as an ASCOM Alpaca SafetyMonitor and ObservingConditions device (HTTP + UDP discovery on port 32227), for use with N.I.N.A. and other ASCOM Alpaca clients. Requires a restart to start/stop the UDP discovery listener. +

+
+ + + + +
+ + updateConfig(['alpaca', 'staleAfterSeconds'], parseInt((e.target as HTMLInputElement).value, 10))} + min="1" + max="3600" + class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-blue-500" + /> +

Sensor data older than this is treated as unsafe (default: 30s)

+
+ +
+ + updateConfig(['alpaca', 'cloudCoverUnsafePercent'], parseFloat((e.target as HTMLInputElement).value))} + disabled={!alpaca.cloudCoverEnabled} + min="0" + max="100" + step="1" + class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-blue-500 disabled:opacity-50" + /> +

Unsafe when cloud cover % is at or above this value

+
+ +
+ + updateConfig(['alpaca', 'sqmMinSafe'], parseFloat((e.target as HTMLInputElement).value))} + disabled={!alpaca.sqmMinEnabled} + min="0" + max="30" + step="0.1" + class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-blue-500 disabled:opacity-50" + /> +

Unsafe when SQM (mag/arcsec²) drops below this value

+
+ +
+ + updateConfig(['alpaca', 'humidityMaxSafe'], parseFloat((e.target as HTMLInputElement).value))} + disabled={!alpaca.humidityMaxEnabled} + min="0" + max="100" + step="1" + class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-blue-500 disabled:opacity-50" + /> +

Unsafe when humidity % is above this value

+
+ +
+ + updateConfig(['alpaca', 'dewpointMarginMinC'], parseFloat((e.target as HTMLInputElement).value))} + disabled={!alpaca.dewpointMarginEnabled} + min="0" + max="20" + step="0.1" + class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-blue-500 disabled:opacity-50" + /> +

Unsafe when (temperature - dewpoint) drops below this margin, in °C

+
+
+
+ {/* Rain Sensor Settings */}

Rain Sensor

diff --git a/web/src/mocks/data.ts b/web/src/mocks/data.ts index 7664ecd..b37e304 100644 --- a/web/src/mocks/data.ts +++ b/web/src/mocks/data.ts @@ -339,6 +339,19 @@ export const mockConfig: Config = { cloudyThreshold: -3.0, humidityCorrection: 0.75, }, + alpaca: { + enabled: false, + manualOverrideUnsafe: false, + staleAfterSeconds: 30, + cloudCoverEnabled: true, + cloudCoverUnsafePercent: 90, + sqmMinEnabled: false, + sqmMinSafe: 0, + humidityMaxEnabled: false, + humidityMaxSafe: 100, + dewpointMarginEnabled: false, + dewpointMarginMinC: 0, + }, rain: { enabled: true, rxPin: 18, diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 6557cd4..a4b0e0e 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -335,6 +335,20 @@ export interface CloudDetectionConfig { humidityCorrection: number; } +export interface AlpacaConfig { + enabled: boolean; + manualOverrideUnsafe: boolean; + staleAfterSeconds: number; + cloudCoverEnabled: boolean; + cloudCoverUnsafePercent: number; + sqmMinEnabled: boolean; + sqmMinSafe: number; + humidityMaxEnabled: boolean; + humidityMaxSafe: number; + dewpointMarginEnabled: boolean; + dewpointMarginMinC: number; +} + export interface Config { deviceName: string; timezone: string; @@ -351,6 +365,7 @@ export interface Config { skyCalibration?: SkyCalibrationConfig; cloudDetection: CloudDetectionConfig; rain?: RainSensorConfig; + alpaca?: AlpacaConfig; } export interface RainSensorReading { diff --git a/web/src/validation/configSchema.ts b/web/src/validation/configSchema.ts index f661c2a..7cf8989 100644 --- a/web/src/validation/configSchema.ts +++ b/web/src/validation/configSchema.ts @@ -172,6 +172,20 @@ export const cloudDetectionConfigSchema = z path: ["clearSkyThreshold"], }); +export const alpacaConfigSchema = z.object({ + enabled: z.boolean(), + manualOverrideUnsafe: z.boolean(), + staleAfterSeconds: z.number().int().min(1, "Must be at least 1 second").max(3600, "Must be at most 1 hour"), + cloudCoverEnabled: z.boolean(), + cloudCoverUnsafePercent: z.number().min(0).max(100), + sqmMinEnabled: z.boolean(), + sqmMinSafe: z.number().min(0).max(30), + humidityMaxEnabled: z.boolean(), + humidityMaxSafe: z.number().min(0).max(100), + dewpointMarginEnabled: z.boolean(), + dewpointMarginMinC: z.number().min(0).max(20), +}); + export const rainSensorConfigSchema = z .object({ enabled: z.boolean(), @@ -225,6 +239,7 @@ export const configSchema = z skyCalibration: skyCalibrationConfigSchema.optional(), rain: rainSensorConfigSchema.optional(), cloudDetection: cloudDetectionConfigSchema, + alpaca: alpacaConfigSchema.optional(), }) .superRefine((data, ctx) => { if (!data.ntp.enabled && !data.gps.enabled) {