Enhanced Arduino fail-safe firmware for AstroShell telescope domes with automatic rain protection, temperature-based dynamic motor timeout, and frozen dome detection
Also check out the Wiki for additional documentation.
| Feature | Description |
|---|---|
| Automatic Rain Protection | Closes dome automatically when rain detected via Cloudwatcher Solo |
| Network Failsafe | Auto-closes if Cloudwatcher becomes unreachable (10 failures in 30 min) |
| Cable Removal Detection | Instant dome closure if Ethernet cable is disconnected |
| Dynamic Motor Timeout | Temperature-based timeout from 253-cycle regression analysis (v4.0) |
| Frozen Dome Detection | VL53L0X ToF sensor detects frozen halves, auto-retries with lockout (v4.0) |
| Event Notifications | Pushover alerts for frozen dome, sensor failures, signal conflicts (v4.0) |
| Mobile-Friendly Web UI | Responsive design works on phones, tablets, and desktops |
| Hardware Watchdog | 8-second watchdog ensures automatic recovery from hangs |
| Motor Runtime Logging | Tick-based logging with temperature and ToF data for analysis |
Telescope Protection Priority:
Rain Detected Cloudwatcher Ethernet Cable Dome Halves
via Unreachable Removed Frozen (v4.0)
Cloudwatcher (30 min) (Instant) (Detected by ToF)
| | | |
v v v v
+-----------------------------------------------+ +--------------------+
| AUTOMATIC DOME CLOSURE | | 3x Retry + Lockout |
+-----------------------------------------------+ +--------------------+
| |
v v
Pushover Notification Pushover Notification
to Owner to Owner
| Feature | Description |
|---|---|
| DS18B20 Temperature Probe | Ambient temperature on pin 22 for dynamic timeout computation |
| Dynamic Motor Timeout | Linear regression model replaces static 107s timeout — adapts to temperature |
| VL53L0X ToF Sensor | Measures gap between dome halves via I2C (pins 20/21) |
| Frozen Dome Detection | 3 retry attempts with 20s gravity wait, then lockout |
| ToF Calibration | /?$C stores current closed distance in EEPROM |
| Dome Unlock | /?$U clears frozen lockout |
| Event Notifications | Arduino pushes events to Solo:88, Solo sends Pushover alerts |
| Conflicting Signals | Detects limit switch vs ToF disagreement (rate-limited alerts) |
| Graceful Degradation | All new features auto-disable if sensors disconnected |
| Sensor Hot-Plug | DS18B20 and VL53L0X auto-reconnect within 30 seconds after unplug/replug |
| Tick Logging Default ON | Motor runtime logging enabled by default after reboot |
| Feature | Description |
|---|---|
| Site profile | #define SITE SITE_GERMANY (mirrored wiring) or SITE_PORTUGAL (standard AstroShell wiring) at the top of the sketch — one source for both domes |
| Physical semantics layer | Every physical decision (commands, $S/$A, auto-close, frozen dome, tick logging, UI) uses DIR_PHYS_* / LIM*_PHYS_*, resolved at compile time at zero cost |
| Cloudwatcher check switch | /?$W (toggle), /?$W1 (on), /?$W0 (off) switch the Cloudwatcher link check at runtime, persisted in EEPROM byte 9 together with the site id — a site without a Solo is no longer auto-closed after every boot, and a board re-flashed with the other profile falls back to that profile's default |
| Web UI | "Cloudwatcher Check" row + toggle button, read-only "Limit Wiring" row |
| Rate limiter fix | The first event after a reboot is no longer dropped (10 s / 5 min blackout after boot removed) |
| Sensor layers switchable | ENABLE_TOF_FROZEN_DETECTION (default 0: no VL53L0X mounted on either dome yet) compiles the I2C/ToF layer out — no Wire/VL53L0X libraries, no I2C traffic; the frozen-dome logic, $C and $U stay inert. ENABLE_DS18B20_DYNAMIC_TIMEOUT (Germany 1, Portugal 0, v4.2.1) does the same for the temperature probe: no OneWire/DallasTemperature libraries, static 107 s timeout, "Temperature: Not installed" |
| Logic test suite + CI | test/host/ simulates the dome on a PC and runs the scenario suite per site, with and without the ToF layer; GitHub Actions also compiles all four variants with avr-gcc |
See Important: Limit Switch Wiring (Site Profile) for the details and the commissioning checklist.
-
Install Libraries in Arduino IDE Library Manager:
OneWireby Jim StudtDallasTemperatureby Miles BurtonVL53L0Xby Pololu
-
Upload
domecontrol_JK3.inoto Arduino MEGA -
Configure IP addresses in the code (dome: 192.168.1.177, Cloudwatcher: 192.168.1.151)
-
Wire sensors (see Hardware Setup below)
-
Calibrate ToF — close dome, navigate to web UI, click "Calibrate ToF Baseline"
-
Deploy tick logger + event server to Cloudwatcher Solo (optional but recommended)
-
Access web interface at
http://192.168.1.177 -
Addons: If you want to have it perfect I recommend you also use the batch files to control dome from NINA and also install the cloudwatcher-rainchecker and astroshell-ticklogger services on your cloudwatcher solo device. After that you're really super set. Also I recommend you registering a Pushover API account for messaging services if you don't already have one. Just have a look into the different files where you can see further explanations and howtos.
- Controller: Arduino MEGA 2560 — use original Arduino only (see warning below)
- Network: W5500 Ethernet Shield
- Weather: Lunatico Cloudwatcher Solo (optional but recommended)
- Temperature: DS18B20 waterproof probe (v4.0)
- Distance: VL53L0X Time-of-Flight sensor breakout (v4.0)
The original AstroShell kit ships with an Arduino UNO R3. This project upgraded to the Arduino MEGA 2560 because the UNO's 2KB SRAM and 32KB flash are insufficient for the Ethernet web server, HTML UI, and v4.0 sensor features (DS18B20, VL53L0X). The MEGA also provides dedicated I2C pins (20/21) and additional GPIO (pin 22+) that don't conflict with existing wiring.
Warning: Always use an original Arduino MEGA 2560 R3. Clone boards from manufacturers like AZ-Delivery (CH340 chip) or Keyestudio (CP2102 chip) can cause unpredictable failures. The Shutter 2 (West) limit switches use pins 0 and 1, which are shared with the USB-serial chip. The original Arduino MEGA has an ATmega16U2 with a series resistor that allows limit switches to function correctly on these pins. Clone boards often drive pins 0/1 directly from their USB-serial chip, overpowering the limit switch signals. This causes symptoms like: West shutter refusing to open (code thinks it's already at the open limit) while closing still works, or both shutters behaving erratically. This is a hardware-level issue — no code fix is possible.
Arduino MEGA Pin 22 ──── DS18B20 Data (yellow/white wire)
│
├── 4.7kΩ pullup to 5V
│
Arduino 5V ──────── DS18B20 Vcc (red wire)
Arduino GND ─────── DS18B20 GND (black wire)
- Use external Vcc power (not parasitic mode) for reliability
- 4.7kΩ pullup resistor required between Data and 5V (5.1kΩ also works)
- Without the pullup, the OneWire open-drain bus cannot communicate
- Pin 22 is MEGA-exclusive — guaranteed no conflict with existing wiring
Arduino MEGA Pin 20 (SDA) ──── VL53L0X SDA
Arduino MEGA Pin 21 (SCL) ──── VL53L0X SCL
Arduino 3.3V ────────────────── VL53L0X VIN
Arduino GND ─────────────────── VL53L0X GND
- Mount across the gap between dome halves at a point where movement is visible
- Closed distance will be calibrated via web UI — no need to hardcode
The VL53L0X should be mounted so it measures the distance across the gap between the two dome halves. When the dome opens, the gap increases. The exact mounting position doesn't matter — calibration stores whatever "closed" distance your installation produces.
Tips:
- Mount on the fixed part of the dome frame, pointing at the moving half
- Protect from rain/condensation with a small shield or enclosure
- Test by manually moving the dome — distance should increase clearly when opening
| File | Description |
|---|---|
domecontrol_JK3.ino |
Main Arduino firmware (v4.2.1, dual-site) with safety sensors, dynamic timeout, frozen dome detection |
cloudwatcher-raincheckerV3.sh |
Rain checker script for Cloudwatcher Solo - auto-closes dome on rain |
cloudwatcher-rainchecker.service |
Systemd service for rain checker autostart |
astroshell_ticklogger.py |
Motor runtime & event server - receives tick data + events, logs CSV, sends Pushover |
astroshell_ticklogger.service |
Systemd service for tick logger autostart |
open_astroshell.bat |
Windows batch script for NINA - opens dome with Pushover notification |
close_astroshell.bat |
Windows batch script for NINA - closes dome with Pushover notification |
motortick-statistic-batchrun.sh |
Automated dome open/close cycles for motor tick statistics collection |
ascom-driver/ |
Native ASCOM COM driver for NINA dome integration (v1.1.0) |
test/host/ |
Host-side logic tests: Arduino mocks + dome physics model, 35 scenarios per site profile (v4.2.0) |
.github/workflows/firmware-ci.yml |
CI: runs the logic tests and compiles both site profiles for the MEGA 2560 (v4.2.0) |
controller_wcapacitor_2025_germany.ino |
Alternative firmware with capacitor backup (has known bugs) |
nocapacitor2023.ino |
Original unmodified AstroShell reference code |
| Command | Action |
|---|---|
/?$1 |
CLOSE Shutter 1 (East) |
/?$2 |
OPEN Shutter 1 (East) — blocked during frozen lockout |
/?$3 |
CLOSE Shutter 2 (West) |
/?$4 |
OPEN Shutter 2 (West) — blocked during frozen lockout |
/?$5 |
Emergency STOP all motors |
/?$S |
Get plain text status: "OPEN" or "CLOSED" |
/?$A |
ASCOM status: S1_STATE|S1_MOTOR|S2_STATE|S2_MOTOR (v4.0.3) |
/?$R |
Reset EEPROM counters |
/?$L |
Toggle tick logging on/off (default: ON) |
/?$U |
Unlock dome from frozen lockout (v4.0) |
/?$C |
Calibrate ToF baseline — dome must be closed (v4.0) |
/?$W |
Toggle Cloudwatcher link check; /?$W1 = on, /?$W0 = off (idempotent) — persisted in EEPROM with the site id; OFF also disables the cable-removal auto-close and all pushes to the Solo (v4.2.0) |
A native ASCOM COM driver (IDomeV2) that lets NINA control the AstroShell dome directly — no extra server process needed. The driver communicates with the Arduino over HTTP using the $A status endpoint.
Quick Setup:
- Install ASCOM Platform 7
- Download the driver from GitHub Actions artifacts
- Run
install.batas Administrator - In NINA: Equipment > Dome > select AstroShell Dome > Setup > configure IP > Connect
See ascom-driver/README.md for full documentation, build instructions, and configuration options.
Automated script to collect motor tick runtime data by repeatedly opening and closing the dome. Runs manually on the Cloudwatcher Solo (Pi3) during daytime.
One run = dome CLOSED → OPEN → CLOSED. Default: 20 runs per session.
Motor runtime varies with temperature (oil viscosity). Rest periods between direction changes are randomized between 5 and 30 minutes to collect data at varying motor temperatures — short rests keep the oil warm, long rests let it cool to ambient.
- Enables tick logging on Arduino (
$L) - Verifies dome at closed endpoints (both shutters at limit switches)
- Checks weather — only opens when dry
- Opens dome, waits 200s, verifies both shutters at open endpoints
- Rests 5-30 min (random), monitors rain while open
- Checks dome state — detects if closed externally during rest
- Closes dome (no weather check needed), waits 200s, verifies closed endpoints
- Run complete — repeats until all runs finished
Weather strategy: Rain is only checked before opening. Closing is always safe. If rain starts while open, the dome closes immediately and the run does not count.
Endpoint verification: After each motor run, the script parses the Arduino web page to confirm both shutters reached their target limit switches. Any "Intermediate" state triggers a Pushover alert.
Pushover: Every notification includes "Run X/Y". Messages sent for: script start/complete/stop, every open and close, interrupted runs, rain events, and rest periods with planned wait time.
ssh root@192.168.1.151
/usr/local/bin/motortick-statistic-batchrun.sh
# Stop with Ctrl+C (dome will be closed safely)mount -o remount,rw /
cp motortick-statistic-batchrun.sh /usr/local/bin/
chmod +x /usr/local/bin/motortick-statistic-batchrun.sh
mount -o remount,ro /| Variable | Default | Description |
|---|---|---|
MAX_RUNS |
20 | Number of full open/close cycles |
MOTOR_WAIT_SECONDS |
200 | Wait for motors to reach endpoints |
MIN_REST_MINUTES |
5 | Minimum rest between direction changes |
MAX_REST_MINUTES |
30 | Maximum rest (randomized) |
RAIN_THRESHOLD |
2900 | Rain detection threshold (same as rain checker) |
astroshell_ticklogger.pyrunning on Solo port 88aag_json.datupdated by Cloudwatcher software- Rain checker may remain running for safety (script cooperates)
v4.2.1: needs
ENABLE_DS18B20_DYNAMIC_TIMEOUT 1(Germany profile). The Portugal profile has no temperature probe yet and compiles the OneWire layer out; it uses the static 6527-tick (107 s) timeout and shows "Temperature: Not installed".
Motor runtime varies with ambient temperature due to motor oil viscosity. The v4.0 firmware uses a linear regression model derived from 253 measured motor cycles (-1.6C to 22.6C) to compute temperature-appropriate timeouts.
- DS18B20 reads temperature every 5 seconds (non-blocking async)
- Four separate regression formulas for M1/M2 closing/opening
- Integer-only computation in loop() — no float math
- ISR reads precomputed timeout values atomically (16-bit AVR)
- Falls back to static 6527 ticks (107s) if sensor fails
| Temperature | Approx M1 Close Timeout | Approx M1 Open Timeout |
|---|---|---|
| -5C | ~6800 ticks (limited to 6527) | ~6260 ticks |
| 0C | ~6700 ticks (limited to 6527) | ~6190 ticks |
| 10C | ~6500 ticks | ~6040 ticks |
| 20C | ~6310 ticks | ~5900 ticks |
v4.2.0: compiled out by default (
ENABLE_TOF_FROZEN_DETECTION 0) because no VL53L0X is mounted on either dome yet. The web UI then shows "ToF Distance: Not installed" and "Frozen Detection: Disabled (not built in)". Set the define to 1 when a sensor is installed; everything below then applies unchanged.
In winter, dome halves can freeze together at the top. The motor opens the bottom but the top stays stuck, creating a crash risk when frost releases later. The VL53L0X ToF sensor detects this by measuring the gap between dome halves.
- When an OPEN command starts, frozen detection monitors the ToF distance
- After ~4 seconds of motor running, checks if gap increased by >30mm
- If gap hasn't increased: STOP motor → wait 20s for gravity → re-check
- If gravity separated halves: resume opening
- If still stuck: reverse motor to close → retry (up to 3 attempts)
- After 3 failed attempts: LOCKOUT — all open commands blocked
- Unlock via web UI (
/?$U) or physical button
Close the dome fully, then navigate to the web UI and click "Calibrate ToF Baseline". This stores the current ToF distance as the reference for "closed". The calibration persists in EEPROM across reboots.
All v4.0 features were lab-tested on real hardware (Arduino MEGA + DS18B20 + VL53L0X) with a 13-step structured test workflow:
| Test | Result |
|---|---|
| DS18B20 temperature reading | Passed |
| Dynamic timeout at -7.7°C and 19°C | Passed — all 8 values match regression model |
| DS18B20 graceful degradation + hot-plug | Passed |
| VL53L0X distance reading | Passed |
| ToF calibration + EEPROM persistence | Passed |
| VL53L0X graceful degradation + hot-plug | Passed |
| Frozen dome simulation (full 3-retry cycle) | Passed |
| Dome unlock from lockout | Passed |
| Frozen dome success path (not frozen) | Passed |
| Event notifications (Arduino → Solo → Pushover) | Passed |
| Conflicting signal detection | Passed |
| Tick data format (both temps + ToF in CSV) | Passed |
| Watchdog stability (no resets during testing) | Passed |
test/host/ compiles the unchanged firmware against small Arduino mocks and a dome physics model, then runs the scenario suite per site profile, once with the ToF layer compiled out (the default build) and once built in, each scenario in its own forked process (make -C test/host test). The physics decides which PWM pin physically opens and which input pin means "physically closed" for each site, independently of the firmware, so the suite checks what the dome does, not which constant the code uses.
Covered: all web commands and their $A/$S/web-page reporting, panel buttons and STOP, dynamic and static timeouts, IP auto-close (trigger, reversal of an opening shutter, stop when the link returns, no polling while closed), cable removal, tick/interrupt logging direction, frozen dome (normal opening, frozen at the closed position with lockout and $U, frozen mid-travel with physical reverse, cancel by a close command), ToF calibration gate, conflict event, 8 h preventive reset gate, and every $W behaviour including the EEPROM upgrade path from v4.1.0. Every scenario also asserts that the 8 s watchdog is never starved and that both PWM pins of a motor are never active together. The same suite runs in GitHub Actions together with an avr-gcc build of both profiles.
Running the suite with the Portugal physics against the old v4.1.0 firmware fails 20 of 26 scenarios, including "Cloudwatcher unreachable closes the dome" — that is the hazard the site profile removes.
5 bugs were discovered and fixed during lab testing:
- Sensor hot-plug failure — Sensors stayed "Not connected" after unplug/replug. Fixed: 30-second periodic bus re-scan.
- DS18B20 slow startup — Sensor not detected at boot (~80s delay). Fixed: 100ms power-on delay before bus init.
- Frozen detection display — Web UI showed "OK" when ToF disconnected instead of "Disabled". Fixed: added sensor connection check.
- ISR race condition — Frozen dome state machine never detected motor starts. Fixed: independent direction tracking variables (not shared with ISR).
- URL encoding — Event detail strings with spaces caused HTTP 400 on Solo. Fixed: buffer-based URL encoding before send.
The Solo Pi3 has a read-only root filesystem. To deploy updated files:
ssh root@192.168.1.151
mount -o remount,rw /
# Update tick logger + event server
cp astroshell_ticklogger.py /usr/local/bin/
chmod +x /usr/local/bin/astroshell_ticklogger.py
# Update service file (important: -u flag for unbuffered stdout)
cp astroshell_ticklogger.service /etc/systemd/system/
systemctl daemon-reload
mount -o remount,ro /
systemctl restart astroshell_tickloggerImportant: The service file must use python3 -u (unbuffered output) or print() output won't appear in journalctl. This is already set in the provided service file.
Pushover setup: Replace JOHNDOE_API_TOKEN and JOHNDOE_USER_KEY in the deployed /usr/local/bin/astroshell_ticklogger.py with your real Pushover credentials. Never commit real credentials to the git repo.
My German dome was installed mirrored: the motor polarity and the limit switches are inverted compared to the original AstroShell design. My second dome in Portugal follows the original design. Since v4.2.0 one firmware serves both through a site profile at the top of domecontrol_JK3.ino:
#define SITE SITE_GERMANY // mirrored installation (default)
// #define SITE SITE_PORTUGAL // standard AstroShell wiring| Germany (mirrored) | Portugal (standard) | |
|---|---|---|
Direction OPEN (PWM on motor*b, ends at lim*open) physically |
closes | opens |
lim1open (pin 7) / lim2open (pin 1) HIGH means |
physically CLOSED | physically OPEN |
| Panel button "up" | closes | opens |
Cloudwatcher check default ($W) |
ON | OFF |
DS18B20 temperature probe / dynamic timeout (ENABLE_DS18B20_DYNAMIC_TIMEOUT) |
built in | compiled out (static 6527-tick timeout) |
VL53L0X ToF / frozen-dome detection (ENABLE_TOF_FROZEN_DETECTION) |
compiled out | compiled out |
The $W setting is stored with the site id. If a board is ever flashed with the other profile, the stored value is ignored and the new profile's default applies, so a Portugal board that once ran a Germany build does not inherit "check ON" (and vice versa).
The hardware pairing in the ISR never changes (direction OPEN always ends at lim*open). Everything that needs to know what is physically open or closed goes through DIR_PHYS_CLOSE / DIR_PHYS_OPEN / LIM*_PHYS_CLOSED / LIM*_PHYS_OPEN, which the profile resolves at compile time. The web API stays physical on both sites: $1/$3 always close, $2/$4 always open, and the ASCOM driver needs no site setting.
Why this matters: before v4.2.0 the mirrored interpretation was hard-coded in 109 places, not only in the web display. Flashed unchanged on a standard-wired dome, the IP-fail auto-close drove the shutters to the physically open end stop. The earlier instructions in this README ("revert three display lines") were incomplete for the same reason and have been removed.
If your dome is wired like the original AstroShell, build with SITE_PORTUGAL. If only the limit switches are mirrored but the motor is not (or vice versa), neither profile fits and the pairing in the ISR itself would have to change — do not just relabel.
- In the Arduino IDE select Tools → Board → Arduino Mega or Mega 2560 (Processor ATmega2560). With "Arduino Uno" selected the build stops with "Sketch too big" at 32256 bytes. Flash the right profile. The web UI must show the expected Limit Wiring row (System Details → System Status) and the expected Cloudwatcher Check default.
- Click OPEN S1: the shutter must physically open, the page must say "Opening (physically)" and
/?$Amust reportINTERMEDIATE|OPENING|.... Stop, then CLOSE S1. - Let one shutter reach each end stop: the state line must read "Physically OPEN" / "Physically CLOSED" and the pin rows must show HIGH on the matching input.
- ASCOM: leave "Swap Open/Close" unchecked; NINA must show the shutter state that matches reality.
- Keep Cloudwatcher Check disabled until a Solo exists on the site; enabled without one, the dome closes about 16 minutes after every opening.
Feel free to use this code at your own risk. Always test with the dome in an intermediate position!
Cheers, Joerg
The dome controller has smart network monitoring that adapts to different scenarios. Below are all possible combinations and expected behaviors:
v4.2.0: this whole section only applies while the Cloudwatcher check is enabled (/?$W, shown as "Cloudwatcher Check" in the web UI). With the check disabled the controller neither polls the Cloudwatcher nor closes the dome on IP failure or cable removal, and it sends no tick or event data to the Solo. The German profile defaults to enabled, the Portuguese profile to disabled; the setting survives reboots and firmware updates (EEPROM byte 9).
| Ethernet Cable | Result | IP Monitoring |
|---|---|---|
| Not connected at startup | Standalone mode, buttons work immediately | Disabled |
| Connected at startup | Full network init (~5s), web interface active | Enabled |
| Inserted after startup (standalone) | Detected within 1 minute, network initializes | Becomes enabled |
| Dome State | Cable Removed | Action | Timing |
|---|---|---|---|
| Dome open | Cable physically removed | Immediate auto-close | Instant |
| Dome partially open | Cable physically removed | Immediate auto-close | Instant |
| Dome closed | Cable physically removed | No action needed | - |
| Dome opening | Cable physically removed | Stops opening, starts closing | Instant |
| Condition | Action | Timing |
|---|---|---|
| Target IP unreachable | Count failures | Every 90 seconds |
| 10 failures within 30 minutes | Auto-close dome | After 10th failure |
| Target responds again | Reset fail counter | Immediate |
| Connection restored during auto-close | Stop closing motors | Immediate |
| User Action | Result | Network State |
|---|---|---|
| Press STOP button | Motors stop immediately | Monitoring paused until cable reconnected |
| Press direction button | Motors stop (toggle behavior) | Monitoring paused until cable reconnected |
| Manually open dome after STOP | Dome opens, system won't interfere | Manual control mode |
| Manually close dome | Works normally | Manual control mode |
| Reconnect cable | Reset trigger flag, resume monitoring | Monitoring active again |
| Scenario | Behavior |
|---|---|
| Cable removed, auto-close, user STOPs, walks away | Dome stays stopped (user took control) |
| Cable reconnected after user STOP | Monitoring resumes, will auto-close on next removal |
| Rapid cable connect/disconnect | Only triggers once per removal (debounced) |
| No cable ever connected | Pure standalone mode, no network checks |
Every 10 seconds status line:
IP Status: Link=1 EthInit=1 NetMon=1 Fails=0/10 S1closed=1 S2closed=0
- Link: 0=cable disconnected, 1=cable connected
- EthInit: 0=Ethernet not initialized, 1=initialized
- NetMon: 0=monitoring disabled, 1=monitoring enabled
- Fails: current fail count / max before auto-close
- S1closed/S2closed: 0=shutter open/moving, 1=shutter at closed position