Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions docs/api/rest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<property>` | 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.
6 changes: 5 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
81 changes: 81 additions & 0 deletions docs/user-guide/alpaca.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 40 additions & 4 deletions docs/user-guide/ota.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<tag>`**

The device downloads `sqmeter-firmware-<tag>.bin` and `sqmeter-littlefs-<tag>.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**
Expand All @@ -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.

Expand Down Expand Up @@ -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.
26 changes: 25 additions & 1 deletion include/Config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Config> load();
bool save() const;
Expand Down
16 changes: 16 additions & 0 deletions include/WebServer.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@
#include "TimeManager.h"
#include "MQTTClient.h"
#include "OtaUpdater.h"
#include "SafetyEvaluator.h"
#include "ObservingConditionsMapper.h"
#include <ESPAsyncWebServer.h>
#include <AsyncWebSocket.h>
#include <ArduinoJson.h>
#include <WiFiUdp.h>
#include <memory>
#include <vector>
#include <functional>
Expand Down Expand Up @@ -115,12 +118,18 @@ namespace SQM

std::unique_ptr<OtaUpdater> 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);
Expand Down Expand Up @@ -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
25 changes: 25 additions & 0 deletions lib/AlpacaLogic/include/AlpacaDiscovery.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#pragma once

#include <cstdint>
#include <cstddef>
#include <string>

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": <port>}
std::string buildDiscoveryResponse(uint16_t alpacaPort);

} // namespace Alpaca
} // namespace SQM
Loading
Loading