Automate on clear nights
Our clear-sky feed answers one question for one site: does tonight have a clear, dark window that passes your rule, and when does it start? It applies the Observing Planner widget's cloud and dark rule with your shortest window and Moon rule on top, and the clear-sky alerts send the same answer. Here it is in Home Assistant, on an iPhone and in Zapier, with the alerts' signed push in Zapier and IFTTT.
The feed
One GET, no key, open CORS. The feed is new, and its fields can still change. The clear-sky alerts page builds the address for your site, or write it from the table below:
curl "https://www.cyclecalcs.com/api/observing-windows?lat=39.74&lon=-104.99&tz=America/Denver&place=Backyard"
The answer, trimmed to what a device usually reads:
{
"summary": "Clear and moonless from 6:24 pm to 5:04 am (10h 40m).",
"next_text": "Sat 6:24 pm to 5:04 am (10h 40m)",
"go_tonight": true,
"next": {
"night_of": "2026-11-07",
"start": "2026-11-07T18:24:00.000-07:00",
"end": "2026-11-08T05:04:00.000-07:00",
"minutes": 640,
"moonless": true,
"sky": "clear",
"in_progress": false,
"starts_in_minutes": 120
},
"source": {
"attribution": "Clouds: MET Norway Locationforecast 2.0 (CC BY 4.0), adapted by CycleCalcs. Dark and the Moon: CycleCalcs."
}
}
go_tonightis true when tonight has a window that passes your rule.nextis the first such window ahead, tonight or a later night the forecast covers. It isnullwhen there is none.summarysays tonight in one sentence, andnext_textsays the next window in words, both on the site's clock.starts_in_minutesis counted when the answer was computed (computed_at). A device that polls should count down fromstarton its own clock, as the Home Assistant recipe below does.
A window is astronomical dark (the Sun 18° down) with every hour of it under your cloud limit and fog under 25%.
| Parameter | What it sets |
|---|---|
lat, lon | The site in decimal degrees, at most 4 decimals. Rounded to 2 (about 1 km), the precision of the forecast. |
tz | Required. The site's IANA time zone, such as America/Denver. "Tonight" and every time are the site's. |
place | Optional. A label for the site, up to 60 characters. |
cloudmax | The cloud limit: every hour of a window is under this much cloud. 10 to 50 percent, default 30. |
min | The shortest window worth it: 30 to 360 minutes, default 60. |
moon | down (the default) counts moonless dark only; any lets the Moon be up. |
format | json (the default) or ics, a calendar with one event per window and a reminder alarm minutes ahead (0 to 720, default 60; 0 for none). |
Each answer is cached for five minutes (one minute when there is no forecast), and polling more often repeats it; every 15 minutes is plenty. Past 30 uncached requests a minute from one address, the feed answers HTTP 429 with Retry-After: 60. Clouds come from MET Norway's Locationforecast under CC BY 4.0; keep the source.attribution line wherever you show the answer.
Home Assistant
REST and template sensors in YAML
Nothing to install. Paste this into configuration.yaml, put your own feed address in resource, and restart:
rest:
- resource: "https://www.cyclecalcs.com/api/observing-windows?lat=39.74&lon=-104.99&tz=America/Denver&place=Backyard"
scan_interval: 900
binary_sensor:
- name: "Clear tonight"
unique_id: cyclecalcs_clear_tonight
value_template: "{{ value_json.go_tonight }}"
sensor:
- name: "Clear sky plan"
unique_id: cyclecalcs_clear_sky_plan
value_template: "{{ value_json.summary }}"
json_attributes:
- next
- computed_at
- source
- name: "Next clear window"
unique_id: cyclecalcs_next_clear_window
value_template: "{{ value_json.next_text or 'none in the forecast' }}"
template:
- sensor:
- name: "Clear window start"
unique_id: cyclecalcs_clear_window_start
device_class: timestamp
state: "{{ (state_attr('sensor.clear_sky_plan', 'next') or {}).get('start') }}"
- name: "Clear window end"
unique_id: cyclecalcs_clear_window_end
device_class: timestamp
state: "{{ (state_attr('sensor.clear_sky_plan', 'next') or {}).get('end') }}"
- name: "Clear window starts in"
unique_id: cyclecalcs_clear_window_starts_in
unit_of_measurement: "min"
state: >-
{%- set n = state_attr('sensor.clear_sky_plan', 'next') -%}
{%- if n is mapping and now() < as_datetime(n.end) -%}
{{ [0, ((as_datetime(n.start) - now()).total_seconds() / 60) | round(0) | int] | max }}
{%- else -%}
{{ none }}
{%- endif -%}
The REST sensors ask the feed every 15 minutes, and the plan sensor keeps the credit in its source attribute. The template sensors read the window it returned, and "starts in" recounts every minute on Home Assistant's clock. Then tell yourself when tonight turns clear:
- alias: "Tell me when tonight is clear"
triggers:
- trigger: state
entity_id: binary_sensor.clear_tonight
to: "on"
actions:
- action: notify.notify
data:
title: "Clear tonight"
message: "{{ states('sensor.clear_sky_plan') }}"
Apple Shortcuts
A shortcut that checks your site every afternoon and tells you about the night. Build it once in the Shortcuts app on an iPhone, iPad or Mac:
- In My Shortcuts, tap + and name the shortcut "Clear tonight".
- Add Get Contents of URL and paste your feed address.
- Add Get Dictionary Value and set it to get the value for
go_tonightin Contents of URL. - Add If, with Dictionary Value as its input, the condition is, and the value true.
- Inside If, add Get Dictionary Value for
summaryin Contents of URL, then Show Notification with that Dictionary Value as the text and "Clear tonight" as the title. - Under Otherwise, add Get Dictionary Value for
next_textin Contents of URL, then an If on that Dictionary Value with the condition has any value, and inside it Show Notification with "Not tonight. Next: " followed by the Dictionary Value. Leave Otherwise empty to hear only about clear nights. - Run it once with the play button to check it.
- In Automation, tap +, choose Time of Day, pick 4:00 PM and Daily, choose Run Immediately, and have it run the "Clear tonight" shortcut.
Siri runs it too: ask "Clear tonight?"
The signed push
Clear-sky alerts can push to an address of your own. On the alerts page, paste any public https address that is not ntfy, Discord or Slack under "Phone, Discord or Slack", and it receives JSON:
- First a confirmation,
"type": "verify", with aconfirm_urlto open and press, and thesigning_keyfor every push after it. - Then at most one alert a night,
"type": "observing_window", withtitle,summary,window(itsstart,endandminutes) andlinks.stop. - With the Sunday summary,
"type": "week_summary"on Sundays, signed the same way.
Each push carries a CycleCalcs-Signature header, t=<unix seconds>,v1=<hex>. The hex is the HMAC-SHA256 of the timestamp, a period and the body exactly as received, keyed with the signing key as text (the key looks like hex; use it as it is). Refuse a push whose timestamp is more than five minutes from your clock. In Python:
import hashlib
import hmac
import time
def verify(body: bytes, header: str, signing_key: str, now: float | None = None) -> bool:
"""True when a CycleCalcs push is genuine and under five minutes old.
body is the raw request body, before any JSON parsing.
"""
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not (t.isascii() and t.isdigit()) or abs((now or time.time()) - int(t)) > 300:
return False
expected = hmac.new(signing_key.encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest()
return v1.isascii() and hmac.compare_digest(expected, v1)
Zapier
Webhooks by Zapier is a premium app, so both Zaps need a paid Zapier plan.
Every afternoon, from the feed
- Trigger: Schedule by Zapier, Every Day, at 4 pm.
- Action: Webhooks by Zapier, GET, with your feed address as the URL. Zapier lists the answer's fields, such as Go Tonight, Summary and Next Start.
- Filter by Zapier: only continue if Go Tonight (Boolean) is true.
- Your action: an email, a Slack message or a text, with Summary as the message.
The moment an alert fires, signed
This Zap takes the signed push.
- Trigger: Webhooks by Zapier, Catch Raw Hook. It keeps the headers and the exact body, which the signature needs. Copy its address.
- On the alerts page, paste that address and send the test push.
- Test the trigger in Zapier. The confirmation arrives: copy
signing_keyfrom its raw body, then openconfirm_urland press the button. - Add Code by Zapier, Run JavaScript. Under Input Data, map
bodyto the Raw Body,signatureto the CycleCalcs-Signature header, andkeyto the signing key you copied. Paste this code:
const crypto = require('crypto');
const parts = Object.fromEntries(String(inputData.signature || '').split(',').map(p => p.split('=')));
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
const expected = Buffer.from(crypto.createHmac('sha256', String(inputData.key || ''))
.update(parts.t + '.' + inputData.body).digest('hex'));
const given = Buffer.from(String(parts.v1 || ''));
const valid = fresh && given.length === expected.length
&& crypto.timingSafeEqual(given, expected);
const push = valid ? JSON.parse(inputData.body) : {};
output = {
valid,
type: push.type || '',
summary: push.summary || '',
start: (push.window && push.window.start) || '',
};
- Filter by Zapier: only continue if Valid is true and Type exactly matches
observing_window. - Your action, with Summary as the message.
IFTTT
IFTTT has no trigger that reads a JSON feed on a schedule, so its recipe takes the signed push. Webhooks and filter code are paid IFTTT features.
- Create an applet. If This: Webhooks, Receive a web request with a JSON payload, with an event name such as
clear_tonight. - Then That: Notifications, Send a rich notification from the IFTTT app.
- Add this filter code:
const push = JSON.parse(MakerWebhooks.jsonEvent.JsonPayload);
if (push.type === 'verify') {
IfNotifications.sendRichNotification.setTitle('Confirm clear-sky alerts');
IfNotifications.sendRichNotification.setMessage('Open this and press the button to start alerts.');
IfNotifications.sendRichNotification.setLinkUrl(push.confirm_url);
} else if (push.type === 'observing_window' || push.type === 'week_summary') {
IfNotifications.sendRichNotification.setTitle(push.title);
IfNotifications.sendRichNotification.setMessage(push.summary);
IfNotifications.sendRichNotification.setLinkUrl(push.links.plan);
} else {
IfNotifications.sendRichNotification.skip('Not a clear-sky alert');
}
- Your push address is
https://maker.ifttt.com/trigger/clear_tonight/json/with/key/followed by your Webhooks key (from the Webhooks service's Documentation page). The/json/part matters: without it the payload arrives empty. - Paste it on the alerts page and send the test push. The confirmation arrives as a notification; open it and press the button.
IFTTT does not pass request headers to filter code, so it cannot check the signature. Your Webhooks key is what keeps the address private. When the check matters, use Zapier or your own server.
Stuck, or found an error in this guide? Contact us at info@cyclecalcs.com.