Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

MyAstroBoard Documentation

Documentation v1.8.3

Your sky, planned and remembered.

MyAstroBoard is a self-hosted astronomy dashboard: it tells you whether tonight is worth setting up, which targets suit your sky and your gear, and keeps a logbook of everything you image. Your data stays on your server.

Quick start Visual tour Live demo

I want to...

What it looks like

All pages

Note

Some parts of the documentation may be generated with AI.

Why? Documentation is one of the least exciting tasks, but one of the most important.

The pages are grouped by who reads them: using MyAstroBoard, running an instance, and contributing to the code. Not sure where a screen is documented? See Where is it? below.

Using MyAstroBoard

Getting started

  • Quick Start - from a fresh install to your first night plan
  • Visual Tour - every tab, with screenshots
  • Beginner Experience - setup wizard, difficulty ratings, “Tonight for you”, Beginner Catalog
  • Glossary - AstroScore, airmass, Bortle, seeing and the other terms on screen
  • My settings - theme, startup tab, locations, notifications, password and 2FA, your data
  • Phones and tablets - install the app, offline page, push notifications

Tonight

  • Astrophotography tab - best imaging window, Moon, Sun, aurora, events calendar
  • Weather - Open-Meteo forecast, 7Timer seeing, astro-analysis, alerts
  • SkyTonight - targets, AstroScore, catalogues, visibility calendar, framing simulator
  • Spaceflight - launches, astronauts, ISS and CSS passes and transits

Planning and logging

  • Plan My Night - the night’s timeline, optimizer, exports
  • Observation Log - one record per night, what you shot and in which conditions
  • Astrodex - your collection of imaged objects, pictures, Photo Map, Catalogue Collection
  • Your Sky and Wishlist - progress, sky coverage, best months, what to image next
  • Share Cards - ready-to-post images of a picture, a session, tonight’s sky or an event

Equipment

  • Equipment - telescopes, cameras, mounts, filters, combinations, FOV, sharing
  • Exposure Calculator - sub-exposure length, sky background, integration time

Observatory and integrations

  • Observatory - live AllSky image, keogram, timelapse, sensor data
  • Home Assistant - sky conditions, targets, events and your activity as Home Assistant devices
  • Astrodex Stream - your Astrodex as a Home Assistant camera
  • MyAstroShine - send a picture out for re-processing, get the result back

Running an instance

  • Installation - Docker, Home Assistant app, system requirements
  • Updating - update notice, update, back up, roll back
  • Reverse Proxy & HTTPS - NGINX Proxy Manager, Traefik, secure cookies
  • Troubleshooting - common problems and how to report one
  • Configuration - the App settings tab: constraints, application settings, backup, logs, metrics
  • Locations - up to five location presets, attribution, per-user active location
  • Users and authentication - roles, sessions, trusted networks, two-factor authentication
  • Connectors - AllSky, MQTT, MyAstroShine and Astrodex Stream setup
  • Notifications - browser and Web Push notifications (triggers N1-N9, VAPID, iOS)
  • Database - SQLite storage, upgrade from 1.6, recovery command line
  • Privacy & GDPR - personal data inventory, third parties, retention, user rights
  • Demo Mode - a public, read-only demo instance
  • Security policy - security model, reporting a vulnerability

Contributing

Where is it?

TabSub-tabPage
AstrophotographyAstrophotography, Best window, Moon, Sun, Aurora Borealis, Events CalendarASTROPHOTOGRAPHY.md
WeatherWeather, Seeing Forecast, Observation ConditionsWEATHER.md
SkyTonightPlot, Deep Sky Objects, Solar System Bodies, Comets, Logs, DSO not found?SKYTONIGHT.md
SkyTonightBeginnerBEGINNER_EXPERIENCE.md
AstrodexAstrodex, Photo Map, Catalogue CollectionASTRODEX.md
AstrodexPlan my nightPLAN_MY_NIGHT.md
AstrodexObservation LogOBSERVATION_LOG.md
AstrodexYour Sky, WishlistSESSION_ANALYTICS.md
SpaceflightLaunches, Astronauts, Space Events, Orbital StationsSPACEFLIGHT.md
Observatory(appears with a connector)OBSERVATORY.md
EquipmentCombinations, FOV Calculator, Telescopes, Cameras, Mounts, Filters, AccessoriesEQUIPMENT.md
EquipmentExposure CalcEXPOSURE_CALC.md
My settingsCustomize, Location, Notifications, SecurityUSER_SETTINGS.md
App settingsLocationsLOCATIONS.md
App settingsConfiguration, Logs, Metrics, Backup / RestoreCONFIGURATION.md
App settingsConnectorsCONNECTORS.md
App settingsUsersAUTHENTICATION.md
HeaderSky status widget, location switcherLOCATIONS.md
FooterUpdate notice, What’s new3.UPDATE.md
SeveralFraming simulatorSKYTONIGHT.md
SeveralShare cardsSHARE_CARDS.md
  • Install: docker compose up -d, then open http://localhost:5000
  • Default credentials: admin / admin (change them at once)
  • Update: docker compose pull && docker compose up -d
  • Source code: https://github.com/myastroboard/myastroboard
  • Report an issue: https://github.com/myastroboard/myastroboard/issues

License

MyAstroBoard is open source software licensed under the AGPL-3.0 License. Copyright (C) 2025-2026 WorldOfGZ and contributors.

In accordance with AGPL-3.0 requirements for network use, the corresponding source code for deployed versions is available in this repository: https://github.com/myastroboard/myastroboard

Quick Start

From a fresh installation to your first night plan. Each step links to the page that covers it in full.

1. Sign in and change the default password

Open http://<host>:5000 and sign in with admin / admin. A warning reminds you to change that password at each sign-in until it is done (My settings -> Security). See AUTHENTICATION.md.

2. Follow the setup wizard

On a fresh install the admin account opens a guided wizard: location, sky quality (Bortle / SQM), equipment, notifications, then a preview of tonight’s best targets. Every step can be skipped and done later. See BEGINNER_EXPERIENCE.md.

What the wizard fills in can be changed afterwards:

SettingWhere
Location, timezone, sky quality, horizon profileApp settings -> Locations (LOCATIONS.md)
SkyTonight constraints (altitude, airmass, size, Moon separation)App settings -> Configuration (CONFIGURATION.md)
Telescopes, cameras, combinationsEquipment (EQUIPMENT.md)
NotificationsMy settings -> Notifications (NOTIFICATIONS.md)
Theme, language, startup tab, experience levelMy settings -> Customize (USER_SETTINGS.md)

3. Wait for the first SkyTonight run

SkyTonight computes tonight’s targets in the background: once at startup, then one hour after nautical dawn and one hour before nautical dusk. The first run takes a few minutes; its progress shows in the SkyTonight tab. To rerun it after changing a setting, use Run SkyTonight Now in App settings -> Configuration. See SKYTONIGHT.md.

4. Decide whether tonight is worth it

  • Astrophotography tab: the night’s score, the best imaging window, the Moon, the Sun, aurora and upcoming events (ASTROPHOTOGRAPHY.md).
  • Weather tab: hourly forecast, seeing and transparency, observation conditions (WEATHER.md).

5. Pick targets and plan the night

  1. In SkyTonight -> Deep Sky Objects, sort by AstroScore, filter by type or constellation, and open a target for its altitude chart and details.
  2. Add targets to Plan My Night (Astrodex -> Plan my night), set a duration for each, and let the optimizer order them by visibility (PLAN_MY_NIGHT.md).
  3. Check the framing of a target with your camera in the framing simulator (SKYTONIGHT.md).

6. After the night

  • Turn the plan into an Observation Log session and record what you actually shot (OBSERVATION_LOG.md).
  • Add your pictures to the Astrodex, your personal collection of imaged objects (ASTRODEX.md).
  • Watch your progress in Your Sky and keep a Wishlist of what is next (SESSION_ANALYTICS.md).

Next steps

Last updated 2026-11-13

MyAstroBoard - Visual Tour

A walkthrough of MyAstroBoard’s interface, tab by tab - from tonight’s go/no-go decision to the logbook of everything you have imaged. Each section links to the page that documents the feature in full.

Table of Contents

  1. Astrophotography
  2. Weather
  3. SkyTonight
  4. Astrodex
  5. Spaceflight
  6. Observatory
  7. Equipment
  8. My settings
  9. App settings
  10. Everywhere

Astrophotography

Everything you need to decide whether tonight is worth setting up the telescope. Details: ASTROPHOTOGRAPHY.md.

Astrophotography

The night’s conditions through an astrophotographer’s eyes - clouds, transparency, seeing, wind, humidity, dew risk - with alerts when something will spoil the session.

Best window

The best imaging window of the night, computed in three modes: Moon below the horizon, Moon below 5 degrees, or Moon less than 15% lit.

Moon

Phase, rise and set, altitude chart, dark-sky window, the next nights and a monthly phase calendar. Lunar eclipses are highlighted.

Sun

Twilight times, altitude chart and the next solar eclipse for your location.

Aurora Borealis

Kp forecast and a visibility assessment for your latitude.

Events Calendar

Upcoming sky events - conjunctions, oppositions, meteor showers, eclipses, ISS transits - with the parameters that help you prioritize them. An event can be turned into a share card.


Weather

Details: WEATHER.md.

Weather

Hourly forecast for the night from Open-Meteo: temperature, precipitation, cloud layers, wind, pressure.

Seeing Forecast

Seeing and transparency from 7Timer for your location.

Observation Conditions

Time series of the atmospheric conditions to find the sweet spot of the night.


SkyTonight

SkyTonight computes the best targets for the coming night from a dozen catalogues and ranks them with an AstroScore (visibility, sky quality, brightness, comfort). Details: SKYTONIGHT.md.

Plot

A polar map of the sky with the path of every observable target tonight; click a target to inspect it.

Deep Sky Objects

Thousands of deep-sky objects, scored and sorted. Filter by type, size, magnitude, constellation, AstroScore, or by how well a target fits your equipment. “Tonight for you” picks a few targets matched to your experience level (BEGINNER_EXPERIENCE.md).

Each target opens a details panel with catalogue names, transit time, a visibility calendar over the year, and the equipment combinations that suit it best.

The Beginner catalogue is a curated list of forgiving targets, each with an explanation.

Solar System Bodies and Comets

The Moon and planets, and the comets currently observable, from live orbital elements.

Altitude charts

An altitude-vs-time chart for every target, with the astronomical night and the observable zone, and your horizon profile.

Framing simulator

Your sensor’s field drawn over a real image of the sky around a target, with rotation and up to three equipment combinations compared side by side. Also opens from the Wishlist, Plan My Night and the Astrodex. See SKYTONIGHT.md.


Astrodex

Your logbook, from the plan to the finished picture: Plan -> Observe -> Log -> Astrodex.

Astrodex

Your collection of imaged objects, with your pictures, their acquisition details, ratings and notes. A picture can be turned into a share card, or sent to MyAstroShine for re-processing (MYASTROSHINE.md). Details: ASTRODEX.md.

Plan my night

Turn your SkyTonight shortlist into a timeline for the night: durations, ordering (by hand or by the optimizer), live progress during the night, CSV and PDF export. Shared equipment combinations appear in the combination selector alongside your own. Details: PLAN_MY_NIGHT.md.

Observation Log

One record per night: conditions, equipment, and what you shot on each target. A plan becomes a session in one click, and frames logged on a target add it to the Astrodex. Details: OBSERVATION_LOG.md.

Your Sky

Everything the Observation Log knows, read back to you: total integration time, the object types and constellations you favour, which equipment did the work, and how your ratings line up with the seeing, transparency, SQM and Moon you recorded. Every comparison shows its own sample count, and a group with too few nights behind it says so. Details: SESSION_ANALYTICS.md.

The sky coverage chart draws your captures over the Milky Way and the ecliptic, with the band of sky that never rises from your location shaded out.

Wishlist

The targets you want to image next, with a priority, when each one is next visible, and how many you have already captured.

Photo Map

Where your pictures were taken, on a map, from the locations you entered on them. Whether other users’ pictures appear is an administrator setting.

Catalogue Collection

Pick a catalogue (Messier, Caldwell, Herschel 400…) and see which of its objects you have already caught.


Spaceflight

Details: SPACEFLIGHT.md.

Launches

Upcoming and recent launches, with a detailed view and the webcast when one is available.

Astronauts

Who is in space right now, on which vehicle, and for how long.

Space Events

Mission milestones, spacewalks, dockings and other spaceflight events.

Orbital Stations

Visible passes of the ISS and the China Space Station (Tiangong) over your location, and their transits across the Sun and the Moon.


Observatory

A live panel fed by your own equipment through connectors: AllSky camera image, keogram, startrails, timelapse and sensor data. The tab appears once a connector is enabled. Details: OBSERVATORY.md, CONNECTORS.md.


Equipment

Telescopes, cameras, mounts, filters and accessories, combined into named equipment combinations. Combinations feed the target recommendations, the field-of-view filters, the framing simulator and the exposure calculator. Details: EQUIPMENT.md, EXPOSURE_CALC.md.

Sharing equipment

Every piece of equipment can be marked Share with all users. Shared items show up, read-only, for every other user: in their Equipment tab, the FOV calculator, the target recommendations, the Astrodex picture form and the Plan My Night combination selector. A combination counts as shared only when every piece it uses is shared. When a shared combination goes away, plans built on it are kept and show a warning. Useful for a family or a club that shares a mount or a filter set.


My settings

Each user’s own preferences: theme (including a red night-vision theme), startup tab, density, time format, experience level, their locations, notifications, password and two-factor authentication, and the download of their personal data. Details: USER_SETTINGS.md.


App settings

Administration of the instance (admin only). Details: CONFIGURATION.md.

  • Locations: up to five location presets, attributed to users (LOCATIONS.md).
  • Configuration: SkyTonight constraints, Astrodex privacy, notifications contact, reverse proxy, MQTT connections.

  • Connectors: AllSky, MyAstroShine, MQTT / Home Assistant, Astrodex Stream (CONNECTORS.md, HOME_ASSISTANT.md).
  • Logs: live log viewer, levels, retention, and an export to attach to a GitHub issue.

  • Users: accounts and roles (admin, user, read-only), trusted networks, two-factor policy (AUTHENTICATION.md).

  • Metrics: system, database, scheduler and cache job health.

  • Backup / Restore: one archive with your configuration, accounts and collections.


Everywhere

  • Sky status widget in the header: the current state of the night and the active location, with a switcher when you have several.
  • Share cards: a picture, a session, tonight’s sky or an event as a ready-to-post image (SHARE_CARDS.md).
  • Phones: installable as an app, with push notifications (MOBILE.md).
  • Six languages: English, French, Spanish, German, Italian, Portuguese.

Last updated 2026-11-13

Beginner Experience

MyAstroBoard is built to stay useful as an astrophotographer’s skill grows, but the app previously assumed prior knowledge: no onboarding, no skill-aware guidance, no starter target list. The Beginner Experience (v1.1, “First Light”) closes that gap with four cooperating pieces:

PieceWhat it does
Guided Setup WizardMulti-step first-run modal: location, sky quality, equipment, notifications, “tonight” preview
Difficulty modelA static, per-target 0-100 difficulty score computed alongside AstroScore
“Tonight for you” recommendations3-5 targets matched to the user’s skill level, shown at the top of SkyTonight -> DSO
Beginner CatalogA curated, hand-picked list of ~30 forgiving starter targets with explanations

All of it is opt-out, not opt-in: existing users default to experience_level: advanced, which behaves exactly like before (all targets shown, no filtering).


Guided Setup Wizard

Module: static/js/first_run.js (logic) + templates/index.html#wizard-modal (markup)

Replaces the old single-step “set your location” popup with a skippable multi-step wizard. The steps shown depend on the user’s context, determined once at launch:

function _getWizardFlow(config, currentUser) {
    const locationSet = config.location_configured === true;
    const isAdmin = currentUser.role === 'admin';
    if (!locationSet && isAdmin) return 'full';   // Context A
    return 'user';                                // Context B
}
FlowWho sees itSteps
full (Context A)Admin, on a fresh install with no location configured yetLocation -> Sky quality (Bortle/SQM) -> Equipment -> Notifications -> Tonight
user (Context B)Any other user (location already configured by an admin)Welcome (read-only site info) -> Equipment -> Notifications -> Tonight

Location and Bortle steps only appear for the admin doing the initial install, since those settings are shared/global (see CONFIGURATION.md) rather than per-user.

  • Equipment step: preset picker (static/data/equipment_presets.json) or manual entry; also lists the user’s existing telescopes/cameras (including shared equipment). Saves via the existing POST /api/equipment/telescopes / cameras endpoints (see EQUIPMENT.md).
  • Notifications step: same triggers as My settings -> Notifications, toggle-only; on the fresh install it also asks for the VAPID contact email (see NOTIFICATIONS.md). Skip is always available and never pressured.
  • Tonight step: renders the same “Tonight for you” panel described below, then a button to jump straight to SkyTonight.

Trigger and completion state

A new wizard preference tracks completion:

"wizard": { "completed": false, "skipped": false }

The wizard shows when both are false. Skipping (single step or “skip all”) sets skipped: true and persists via PUT /api/auth/preferences - unlike the old popup, the skip is permanent across sessions/devices, not a sessionStorage flag. Users can run it again anytime from My settings -> Customize -> Redo Setup Wizard.


“Tonight for you” recommendations

Endpoint: GET /api/skytonight/recommendations (@login_required) - see API_ENDPOINTS.md

A panel injected above the DSO table on the SkyTonight tab. It reads the user’s experience_level preference, filters dso_results.json to matching difficulty tiers, sorts by AstroScore descending, and returns the top N (default 5, max 10 via ?limit=):

experience_levelDifficulties shown
beginnerbeginner only
intermediatebeginner + intermediate
advanced (default)everything

Each card shows the target name, type badge, difficulty badge, estimated integration time, and an “Add to Plan” button. estimated_integration_hours comes from the Beginner Catalog when the target is one of its ~30 curated entries; otherwise it’s a rough estimate derived from the difficulty tier (beginner -> 2h, intermediate -> 4h, advanced -> 8h) and flagged as such via estimated_integration_hours_is_estimate.

The preference is set in My settings -> Customize alongside density/theme, and defaults to advanced so existing users see no behavior change until they opt in.


Beginner Catalog

Module: backend/observation/beginner_catalog.py · Data: backend/catalogues/beginner_catalog.json (34 hand-picked entries) · Endpoint: GET /api/beginner-catalog - see API_ENDPOINTS.md

A “Beginner” sub-tab on SkyTonight showing a card grid (not a table) of curated, forgiving starter targets - bright nebulae, open clusters, and a few landmark galaxies. Each card explains why the object is beginner-friendly and suggests a framing, plus:

  • Visible tonight badge, cross-referenced against the current dso_results.json
  • Captured state, cross-referenced against the user’s Astrodex
  • In plan state, cross-referenced against Plan My Night
  • Filters: visible-tonight-only toggle, difficulty (capped at the user’s own experience_level), object type

The catalogue JSON stores only structural data and an i18n_key per object - no English text is hardcoded in the data file. The why_beginner and suggested_framing strings are resolved at request time via I18nManager from beginner_catalog.objects.<i18n_key>.why / .framing in the i18n files, so the whole catalogue is translated in all 6 supported languages.

This tab can be turned off entirely per-user via the beginner_catalog_enabled preference (My settings -> Customize) for users who find it redundant with the DSO table.


Difficulty model

Module: backend/skytonight/skytonight_calculator.py -> compute_difficulty_score()

Every target that SkyTonight computes gets a static difficulty score alongside its AstroScore. Unlike AstroScore, difficulty does not depend on location, date, or Bortle class - the same object is always the same difficulty everywhere. It is computed once per calculation run and stored in dso_results.json as difficulty_score (0-100, lower = easier) and difficulty (beginner / intermediate / advanced).

FactorWeightEasier when…
Surface brightness (mag/arcsec²)0.40Brighter (lower value)
Angular size (arcmin)0.30Larger
Visual magnitude0.20Brighter (lower value)

A fourth factor (minimum integration hours, weight 0.10) is a documented simplification: no per-target minimum-integration data exists in the general catalogue, so it always contributes 0. This caps the theoretical maximum raw score around ~90 instead of 100 but does not move any target across a threshold.

When magnitude or size is missing, the surface-brightness and size components are zeroed and only the magnitude factor is used (“magnitude-only” scoring). If magnitude is also missing, the target gets a neutral default of (50, 'intermediate').

Thresholds: score ≤ 35 -> beginner, 35 < score ≤ 65 -> intermediate, score > 65 -> advanced.

Difficulty is surfaced as a color-coded badge (green/orange/red) wherever a target appears: the SkyTonight DSO table (with a difficulty filter dropdown), Astrodex entries, and Plan My Night entries.


Preferences reference

New keys added to preferences (see AUTHENTICATION.md):

PreferenceAllowed valuesDefaultDescription
experience_levelbeginner, intermediate, advancedadvancedFilters recommendations and the Beginner Catalog’s difficulty ceiling
beginner_catalog_enabledbooleantrueShows/hides the “Beginner” SkyTonight sub-tab
wizard.completedbooleanfalseSet once the guided wizard is finished
wizard.skippedbooleanfalseSet if the user skips the wizard (any step, or “skip all”)

i18n namespaces

NamespaceContent
wizard.*Wizard step titles, navigation, skip/redo strings
difficulty.*Badge labels (beginner/intermediate/advanced) and the difficulty explanation tooltip
recommender.*“Tonight for you” panel strings
beginner_catalog.*Beginner Catalog tab strings, plus one objects.<i18n_key>.why / .framing pair per catalogue entry

Files reference

FileRole
static/js/first_run.jsWizard flow selection, step rendering, skip/completion persistence
static/data/equipment_presets.jsonGear presets offered in the wizard’s equipment step and in the Equipment tab’s “New …” modals - see EQUIPMENT.md#presets
backend/skytonight/skytonight_calculator.pycompute_difficulty_score()
backend/blueprints/skytonight_api.pyGET /api/skytonight/recommendations
backend/observation/beginner_catalog.pyCatalogue loading, i18n resolution, SkyTonight/Astrodex/Plan enrichment
backend/catalogues/beginner_catalog.jsonThe 34-entry curated dataset
backend/blueprints/astrodex.pyGET /api/beginner-catalog route
static/js/skytonight.jsRecommendations panel, difficulty badges/filter, Beginner sub-tab
static/js/auth.jsexperience_level / beginner_catalog_enabled controls in My settings -> Customize

Last updated 2026-11-13

Glossary

The terms MyAstroBoard shows on screen, in plain words, with the page that explains how each one is computed.

TermMeaning
AirmassHow much atmosphere the light crosses, relative to straight up (1 at the zenith, 2 at 30 degrees of altitude). The higher, the more blur and extinction. SkyTonight’s default limit is 2 (SKYTONIGHT.md).
Altitude / azimuthHeight of an object above the horizon (degrees) and its direction along the horizon, from north through east.
AstroScoreMyAstroBoard’s 0-to-1 rating of how good a target is tonight from your location: visibility 40%, sky quality 25%, brightness 25%, comfort 10%, plus small bonuses (SKYTONIGHT.md).
Astronomical nightThe Sun more than 18 degrees below the horizon: the sky is fully dark. The best imaging window is searched inside it (ASTROPHOTOGRAPHY.md).
Bortle classA 1 (pristine) to 9 (inner city) scale of light pollution at your site, set per location (SKYTONIGHT.md).
Culmination / transitThe moment an object is highest in the sky, crossing the meridian.
Dark windowThe part of the night with no Sun and no Moon in the sky.
DifficultyMyAstroBoard’s 0-to-100 estimate of how hard a target is for a beginner, used by “Tonight for you” (BEGINNER_EXPERIENCE.md).
DSODeep-sky object: galaxy, nebula, star cluster - anything beyond the solar system.
Equipment combinationA named set of telescope (or lens), camera, mount and filters that you image with (EQUIPMENT.md).
FOV (field of view)The patch of sky your camera covers through your telescope, in arcminutes (EQUIPMENT.md).
Integration timeThe total exposure time accumulated on a target, all sub-exposures added (EXPOSURE_CALC.md).
Kp indexA 0-to-9 measure of geomagnetic activity; the higher, the further from the poles aurorae can be seen (ASTROPHOTOGRAPHY.md).
MagnitudeBrightness on a reversed scale: the smaller the number, the brighter the object. For extended objects, the same light spread over a larger area looks fainter.
Meridian flipOn an equatorial mount, the turn the telescope must make when the target crosses the meridian; imaging pauses for a few minutes (PLAN_MY_NIGHT.md).
Nautical twilightThe Sun between 6 and 12 degrees below the horizon. SkyTonight runs one hour after nautical dawn and one hour before nautical dusk.
OppositionA planet opposite the Sun in the sky: closest, brightest, visible all night. AstroScore adds a bonus.
SeeingSteadiness of the air, which blurs fine detail; matters most for planets and small targets (WEATHER.md).
SQMSky Quality Meter reading, in magnitudes per square arcsecond: the darker the sky, the higher the value (about 22 at a dark site) (EXPOSURE_CALC.md).
Sub-exposureOne single frame of a series; many are stacked to reach the integration time.
TransparencyClarity of the air (haze, humidity, dust), which dims faint objects (WEATHER.md).
TLETwo-line element set: the orbit data used to predict ISS and CSS passes (SPACEFLIGHT.md).

Last updated 2026-11-13

My settings

Every account has its own preferences, in the My settings tab. They follow the account on every device and never affect other users. Instance-wide settings are in App settings, for administrators (CONFIGURATION.md).

Customize

SettingChoicesWhat it does
Startup tab and sub-tabany tabWhat opens after you sign in
Time formatAuto, 24h, 12hAuto follows the browser’s language
DensityComfortable, CompactCompact fits more rows, useful on a phone
ThemeAuto, Light, Dark, Red LightRed Light keeps your eyes dark-adapted at the telescope; Auto follows the device
First day of the weekMonday, SundayCalendars and date pickers
Experience levelBeginner, Intermediate, AdvancedFilters “Tonight for you” and the Beginner Catalog by difficulty (BEGINNER_EXPERIENCE.md)
Beginner Catalogon / offShows the Beginner sub-tab in SkyTonight
“Tonight for you”on / offRecommended targets at the top of SkyTonight -> Deep Sky Objects
Publish my activity to Home Assistanton / off (off by default)Creates a Home Assistant device with your own Astrodex counters, tonight’s plan and your log totals, when the administrator runs the MQTT connector (HOME_ASSISTANT.md)

Reset to defaults restores every choice above. The interface language is picked in the footer and is remembered for the account too.

Location

The locations an administrator gave you access to (LOCATIONS.md):

  • Default location: the one every tab uses when you sign in.
  • Order: the order of the quick switcher in the sky status widget, where you change the active location for the current session.

Notifications

Which alerts you receive, how long before each event, and on which locations. Not available on a demo instance. See NOTIFICATIONS.md, and MOBILE.md for phones.

Security

  • Change password.
  • Two-factor authentication: turn on an authenticator app (TOTP), when the administrator allows it (AUTHENTICATION.md).
  • Download my data: a ZIP with everything the instance stores about you - account, preferences, Astrodex, equipment, plans, observation log, wishlist, pictures and attachments (PRIVACY.md).

Reference

The preferences are stored on the account (users table, preferences) and saved with PUT /api/auth/preferences; the full key list and allowed values are in AUTHENTICATION.md.

Last updated 2026-11-13

Phones and Tablets

MyAstroBoard works in any modern mobile browser, and can be installed as an app (a Progressive Web App) for a full-screen window, an icon on the home screen, an offline page, and push notifications.

Requirements

Browsers only allow installation, offline support and notifications on a secure address:

AddressInstall, offline page, notifications
HTTPS (behind a reverse proxy, see 6.REVERSE_PROXY.md)Yes
http://localhostYes
Plain HTTP on the network (http://192.168.1.10:5000)No - the site works, but as a plain web page
Home Assistant sidebar panelNo - use the Home Assistant app on the phone, and the MQTT connector for alerts (HOME_ASSISTANT.md)

Installing the app

Android (Chrome, Edge, Samsung Internet): an Install button appears in the footer when the browser allows it; otherwise use the browser menu -> Install app or Add to Home screen.

iPhone and iPad (Safari): tap Share -> Add to Home Screen, then open MyAstroBoard from the new icon. Push notifications on iOS need Safari 16.4 or later and only reach the installed app, never a Safari tab.

Desktop (Chrome, Edge): the install icon in the address bar, or the Install button in the footer.

Offline

The installed app keeps its shell (pages, styles, translations, icons) on the device. When the server cannot be reached, an offline page appears and retries the connection every 30 seconds. Data (forecasts, targets, your Astrodex) always comes from the server: nothing is edited offline.

Notifications

  1. Open My settings -> Notifications on the device that should receive them, allow notifications when the browser asks, and pick the alerts you want (plan start, ISS and CSS transits, eclipses, nightfall, aurora, meteor showers…).
  2. Tap Test: it sends a real push through the server. On iOS, background or lock the app right after tapping to see it.
  3. Each device subscribes on its own; the preferences are shared by all your devices.

The administrator must set a real contact email in App settings -> Configuration -> Notifications, or Apple drops every push. Full reference: NOTIFICATIONS.md.

At the telescope

  • Red Light theme (My settings -> Customize) keeps your eyes dark-adapted.
  • Compact density fits more rows on a small screen.
  • Set the startup tab to the one you open first, Plan My Night for example (USER_SETTINGS.md).

Last updated 2026-11-13

Astrophotography Tab

The Astrophotography tab is the central planning dashboard. It consolidates every piece of data an astrophotographer needs to decide whether to observe tonight and what to expect.


Tab layout

Sub-tabContent
Astro weatherSummary conditions card + best imaging window
WindowDetailed best-window breakdown (3 modes)
MoonLunar phase, position, 7-night planner, monthly phase calendar
SunSolar times, twilight windows, next eclipse
AuroraKp index, geomagnetic forecast, visibility assessment
CalendarUpcoming celestial events (planetary, meteor showers, phenomena)

Best imaging window

Module: backend/astroweather/moon_astrotonight.py

Class: AstroTonightService

Cache TTL: 3 hours (CACHE_TTL_BEST_WINDOW).

The best imaging window is the longest continuous block of time where the sky is astronomically dark and moonlight is minimal, according to three selectable modes.

What “astronomical night” means

Astronomical night begins when the Sun’s altitude drops below −18° (astronomical dusk) and ends at −18° on the way back up (astronomical dawn). Only during this window can faint deep-sky objects be properly imaged.

Twilight typeSun altitudeNotes
Civil twilight0° to −6°Sky still blue
Nautical twilight−6° to −12°Horizon visible at sea
Astronomical twilight−12° to −18°Sky not yet fully dark
True astronomical night< −18°Required for deep-sky imaging

The three window modes

ModeMoon constraintUse case
strictMoon must be below the horizonMaximum darkness; works well near full moon
practicalMoon altitude < 5°30-min margin for moonrise/moonset
illuminationMoon illumination < 15%New moon neighbourhood; moon may be above horizon but is dim

All three modes use Sun < −18° as the primary constraint. The API returns all three simultaneously - the UI shows the one that gives the longest usable window.

Score

The window score (0-100) reflects how good the dark window is:

  • 100: No moon interference during full astronomical night.
  • 0: No dark window exists (full moon, high latitude in summer, polar day).

API endpoint

GET /api/tonight/best-window returns:

{
  "best_window": {
    "strict": { "start": "22:15", "end": "04:40", "duration_hours": 6.4, "score": 100 },
    "practical": { "start": "22:15", "end": "04:40", "duration_hours": 6.4, "score": 100 },
    "illumination": { "start": "22:15", "end": "04:40", "duration_hours": 6.4, "score": 100 }
  }
}

Moon

Modules: backend/astroweather/moon_phases.py, backend/astroweather/moon_planner.py, backend/astroweather/moon_eclipse.py

Cache TTL: 2 hours (CACHE_TTL_MOON_REPORT).

Moon report (astroweather/moon_phases.py)

MoonService computes:

FieldDescription
phase_nameNamed phase (New Moon, Waxing Crescent, First Quarter…)
illumination_percentFraction of disk illuminated (0-100 %)
distance_kmGeocentric distance
altitude_deg / azimuth_degCurrent topocentric position
next_moonrise / next_moonsetNext rise and set times
next_full_moon / next_new_moonTimestamps of next phase extremes
next_dark_night_start/endNext window where Sun < −18° AND Moon below horizon

Libraries used: astronomy (Astronomy Engine) for rise/set and phase search; astropy for AltAz position.

Moon planner (astroweather/moon_planner.py)

7-night forecast table. For each night it computes moonrise, moonset, illumination, and whether there is a usable dark window. This lets astrophotographers pick the best night within the coming week.

API: GET /api/moon/next-7-nights

Lunar phase calendar (astroweather/moon_calendar.py)

Full calendar-month grid shown under “Moon next days”. For every day it computes the illuminated fraction and waxing/waning state from the Moon phase angle (astronomy.MoonPhase, the same formula as the Moon report, so the drawings match the main Moon visual), flags near-new-moon nights (illumination < 15%) as better deep-sky windows, and locates the month’s four principal phases with astronomy.SearchMoonQuarter / NextMoonQuarter, bucketed into local calendar days via the active location’s timezone. Navigation is restricted to a 2-month window: the current month and the next one.

Every day is sampled at the same fixed night hour (23:00 local) - the calendar is a night-observation planning aid, so each cell shows the Moon as it is when you would be out under it, and every cell is computed the same way. This can differ by a few percent from the live #moon-display widget during the day (that one is a “right now” readout); the two converge at night.

API: GET /api/moon/phase-calendar?year=<yyyy>&month=<1-12> - year / month are clamped to the allowed window; missing or malformed values fall back to the current month. The response also carries is_current_month, can_go_prev, can_go_next, today and principal_phases.

Lunar eclipse (astroweather/moon_eclipse.py)

LunarEclipseService finds the next lunar eclipse visible from the configured location.

FieldDescription
typeTotal, Partial, or Penumbral
peak_timeLocal time of maximum eclipse
partial_begin / total_begin / total_end / partial_endContact times
peak_altitude_degMoon altitude at peak (negative = below horizon = not visible)
total_duration_minutes / partial_duration_minutesDuration of each phase
astrophotography_score0-10 score for imaging value
altitude_vs_timeArray of alt/az at 5-min resolution for chart display

Astrophotography score factors in: eclipse type (total > partial > penumbral), altitude at peak, and total duration.

API: GET /api/moon/next-eclipse


Sun

Modules: backend/astroweather/sun_phases.py, backend/astroweather/sun_eclipse.py

Cache TTL: 6 hours (CACHE_TTL_SUN_REPORT).

Sun report (astroweather/sun_phases.py)

SunService computes for the current date at the configured location:

FieldDescription
sunrise / sunsetLocal times
civil_dusk / civil_dawnSun = −6°
nautical_dusk / nautical_dawnSun = −12°
astronomical_dusk / astronomical_dawnSun = −18°
true_night_hoursDuration of full astronomical night

Library: astropy - get_sun() + AltAz transforms over a dense time grid to find threshold crossings.

API: GET /api/sun/today

Solar eclipse (astroweather/sun_eclipse.py)

SolarEclipseService finds the next solar eclipse visible from the configured location.

FieldDescription
typeTotal, Annular, Partial, or Hybrid
maximum_timeLocal time of maximum coverage
obscuration_percentFraction of solar disk covered at maximum
altitude_degSun altitude at maximum
magnitudeEclipse magnitude (fraction of solar diameter covered by Moon)
duration_minutesDuration of partial phase
altitude_vs_timeAlt/az array for chart display

API: GET /api/sun/next-eclipse


Horizon graph

Module: backend/astroweather/horizon_graph.py

Cache TTL: 6 hours (CACHE_TTL_HORIZON_GRAPH).

The horizon graph displays the altitude vs. time curves for the Sun and Moon throughout the current day. This gives a quick visual of when targets are above the horizon and allows users to identify the dark window at a glance.

HorizonGraphService samples altitude and azimuth at 1-hour resolution using astropy’s get_sun() and get_body('moon') with AltAz transforms.

API: GET /api/astro/horizon-graph


Sidereal time

Module: backend/observation/sidereal_time.py

Cache TTL: 1 hour (CACHE_TTL_SIDEREAL_TIME).

Local Apparent Sidereal Time (LAST) is displayed as a quick reference. LAST equals the Right Ascension (RA) of objects currently on the meridian - useful when manually slewing to targets.

API: GET /api/astro/sidereal-time


Aurora

Module: backend/astroweather/aurora_predictions.py

Class: AuroraService

Cache TTL: 1 hour (CACHE_TTL_AURORA).

Data source: NOAA Space Weather Prediction Center

Kp index

The Kp index (0-9) is the primary indicator of global geomagnetic activity. Higher Kp = larger aurora oval extending further toward the equator.

KpActivity levelTypical aurora visibility
0-1QuietPolar regions only (> ~70° lat)
2-3UnsettledHigh latitudes (~65-70°)
4ActiveMid-high latitudes (~60-65°)
5Minor storm (G1)Visible ~55°
6Moderate storm (G2)Visible ~50°
7Strong storm (G3)Visible ~45°, sometimes ~40°
8-9Severe/ExtremeMid-latitude aurora possible

NOAA API: https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json (current), noaa-planetary-k-index-forecast.json (3-day forecast).

Aurora visibility probability

AuroraService.calculate_aurora_probability() estimates aurora probability (0-100 %) from the current Kp and observer latitude:

aurora_edge_latitude = 67° - (Kp × 3.5°)
  • Observer poleward of the edge -> probability = 25% + Kp × 7 (max 88%)
  • Observer near the edge -> probability = 15% + Kp × 6
  • Observer 5° equatorward -> probability = 5% + Kp × 4
  • Observer far equatorward -> probability = max(0, (Kp − 3) × 6)

The latitude of 67° is an approximation of the mean auroral oval edge during quiet conditions; the factor of 3.5°/Kp unit is a well-known empirical rule of thumb.

Best aurora window

Auroras are most visible between 22:00 and 02:00 local time (midnight maximum). The service reports whether the current night has a suitable window given Kp and darkness.

API: GET /api/aurora/predictions


Celestial events

Three separate services feed the Calendar sub-tab.

Planetary events

Module: backend/observation/planetary_events.py

Class: PlanetaryEventsService

Cache TTL: 24 hours (CACHE_TTL_PLANETARY_EVENTS).

Covers all seven naked-eye planets (Mercury, Venus, Mars, Jupiter, Saturn, Uranus, Neptune).

Event typeDescriptionWhich planets
OppositionPlanet is 180° from the Sun - closest approach, highest altitude at midnightOuter planets only (Mars and beyond)
ConjunctionPlanet appears very close to another planet or the SunAll
Greatest elongationMaximum angular distance from the Sun - best for inner planetsMercury (18°), Venus (46°)
Retrograde begin/endApparent backward motion as Earth overtakes an outer planetAll outer planets

Events are computed over a 365-day rolling window from today using astropy body positions.

API: GET /api/events/planetary

Special phenomena

Module: backend/observation/special_phenomena.py

Class: SpecialPhenomenaService

Cache TTL: 24 hours (CACHE_TTL_SPECIAL_PHENOMENA).

EventDescription
Vernal equinoxSun crosses the celestial equator northward (≈ March 20)
Summer solsticeLongest day in the northern hemisphere (≈ June 21)
Autumnal equinoxSun crosses the celestial equator southward (≈ September 22)
Winter solsticeShortest day (≈ December 21)
Zodiacal light windowBest period to see the faint dust band in the ecliptic (western sky after dusk in spring, eastern sky before dawn in autumn)
Milky Way core visibilityPeriod when the galactic centre (Sagittarius) rises and sets in darkness

Library: astropy - get_sun() position for equinox/solstice crossing detection; SkyCoord for galactic-plane altitudes.

API: GET /api/events/phenomena

Solar system events (meteor showers)

Module: backend/observation/solar_system_events.py

Class: SolarSystemEventsService

Cache TTL: 24 hours (CACHE_TTL_SOLAR_SYSTEM_EVENTS).

Contains a curated database of annual meteor showers with:

FieldDescription
nameShower name
peak_dateDate of peak activity
zenith_hourly_rateZHR at peak
parent_bodyComet or asteroid producing the shower
radiant_ra / radiant_decRadiant position
radiant_altitudeRadiant altitude at peak local midnight from configured location
hemispherenorth, south, or both

Major showers included: Quadrantids, Lyrids, Eta Aquariids, Delta Aquariids, Perseids, Orionids, Leonids, Geminids, Ursids.

Radiant altitude is computed using astropy AltAz transforms - a shower with a radiant below the horizon is flagged as not visible from the configured location.

API: GET /api/events/solarsystem

Upcoming events aggregator

Module: backend/utils/events_aggregator.py

GET /api/events/upcoming merges all three event sources and returns a single sorted list of events in the coming days/weeks - used for the Calendar sub-tab and for push notification triggers N4 and N5.


Push notification triggers

Two astrophotography events feed the push notification system (see NOTIFICATIONS.md):

TriggerEventDefault lead
N4Lunar eclipse totality begins30 min
N5Solar eclipse maximum30 min
N6Astronomical darkness begins20 min

Last updated 2026-11-13

Weather & Conditions

The Weather tab provides atmospheric conditions specifically analysed for astronomical observation and astrophotography. It combines two independent data sources: Open-Meteo for standard meteorological data and 7Timer for seeing and transparency forecasts.


Tab layout

LocationContent
Weather tab -> WeatherHourly weather forecast cards (temperature, humidity, wind, clouds, precipitation), each with a quality label from observation_score
Weather tab -> Seeing7Timer astronomical seeing and transparency forecast
Weather tab -> Observation ConditionsA fast go / no-go scan of the coming night: a hero “Night score” chart (the observation_score line, colour-banded, best-window caption), then a “Sky” chart (cloud cover total + layers, fog, precipitation) and an “Atmosphere & tracking” chart (seeing, transparency, mount stability, lifted index). Window = now to ~2 h past dawn, minimum 6 h. Reads /api/weather/astro-analysis.
Astrophotography tab -> AstrophotographyCurrent conditions, the “Score de la Nuit” hourly timeline, horizon graph, best observation periods, weather alerts and the advanced hour-by-hour analysis charts

Weather alerts

Module: backend/weather/weather_astro.py (alert generation)

Alerts are generated from the Open-Meteo data and include:

AlertTrigger condition
High cloud coverCloud cover > 50 % during the observing window
Dew riskDew point within 2 °C of ambient temperature
Strong windWind speed > 30 km/h
High humidityRelative humidity > 85 %
Rain riskPrecipitation probability > 30 %

Alerts are translated using the i18n system and displayed in the Alerts sub-tab.


Open-Meteo weather forecast

Source: Open-Meteo - free, no API key required.

Cache TTL: 1 hour (WEATHER_CACHE_TTL in utils/constants.py).

Module: backend/weather/weather_openmeteo.py

Variables fetched

VariableUse
temperature_2mTemperature at 2 m (°C / °F)
relative_humidity_2mRelative humidity (%)
dew_point_2mDew point - distance from ambient drives fog/dew warning
precipitation_probabilityRain/snow probability (%)
cloud_coverTotal cloud cover (%)
cloud_cover_lowLow-altitude clouds (0-3 km)
cloud_cover_midMid-altitude clouds (3-8 km)
cloud_cover_highHigh-altitude clouds (cirrus, 8+ km)
wind_speed_10mSurface wind speed
wind_direction_10mSurface wind direction
surface_pressureAtmospheric pressure (hPa)
visibilityHorizontal visibility (km)
wind_speed_80m, 120mUpper-atmosphere wind for jet-stream analysis

Cloud layer discrimination

The three cloud-cover layers help astrophotographers distinguish:

  • Low clouds (fog, stratus) - completely block imaging.
  • Mid clouds (altocumulus) - usually opaque, block imaging.
  • High clouds (cirrus) - thin and sometimes transparent; imaging may still be possible.

Total cloud cover is shown as the primary go/no-go indicator.

Rate-limiting behaviour

Open-Meteo free tier allows a limited number of concurrent requests. weather/weather_openmeteo.py implements two protections:

GuardBehaviour
Single-flight lockOnly one concurrent call is made for the hourly forecast
Global concurrency cooldownIf a “Too many concurrent requests” error is received, all Open-Meteo callers back off for 90 seconds (_GLOBAL_CONCURRENCY_COOLDOWN)

When rate-limited, the API returns the last successful response (stale-while-error) to avoid empty screens.


Astro weather analysis

Module: backend/weather/weather_astro.py

Class: AstroWeatherAnalyzer

This module transforms raw Open-Meteo variables into astrophotography-specific metrics. It calculates conditions for up to 48 hours and caches results for 30 minutes (in-process), and the cache scheduler additionally keeps a 24 h analysis warm per location in the astro_weather shared cache (see docs/CACHE_SYSTEM.md).

The observation score (single source of truth)

observation_score (0-10) is the one overall astrophotography-quality number in the app:

observation_score = (seeing_pickering*10 + transparency_score + cloud_discrimination + tracking_stability_score) / 4 / 10 * precipitation_factor
  • seeing_pickering is 1-10; the other three components are 0-100 (so transparency_score, cloud_discrimination and tracking_stability_score must all stay on that scale - a component accidentally left on a 0-1 scale silently contributes almost nothing).
  • precipitation_factor (0-1) is a multiplicative veto, not an averaged component.

It is shown, unchanged, by the navbar sky widget pill, the location switcher, the Weather tab’s hourly cards (served on /api/weather/forecast as condition, x10 to a 0-100 range) and the “Score de la Nuit” hourly cards. weather/weather_openmeteo.py no longer derives its own overall score.

Seeing estimate (Pickering scale)

The seeing estimate is derived from wind speed at multiple altitudes and atmospheric stability proxies available from Open-Meteo:

PickeringQualityConditions
1-3PoorStrong winds, turbulent atmosphere
4-5FairModerate conditions
6-7GoodSuitable for deep-sky imaging
8-9Very goodSuitable for planetary imaging
10PerfectExceptional stability

Note: This is a model-derived estimate. For high-resolution planetary imaging, cross-check with the 7Timer seeing forecast below.

Transparency (limiting magnitude)

transparency_score (0-100) is estimated from cloud cover, humidity, and visibility, and is then mapped linearly onto a limiting magnitude between MAGNITUDE_LIMIT_ZENITH_MIN (4.0) and MAGNITUDE_LIMIT_ZENITH_MAX (8.0):

mag/arcsec²Sky quality
≥ 7.5Excellent dark sky
6.5 - 7.4Good
5.5 - 6.4Average suburban
≤ 5.0Light-polluted / cloudy

Dew point warning

When the difference between ambient temperature and dew point is ≤ 2 °C (DEW_POINT_WARNING_THRESHOLD), a dew alert is raised. Dew on optics ends a session; plan dew heaters accordingly.

Jet stream impact

Wind speed at 80 m and 120 m altitude is used as a proxy for jet-stream influence. High upper-atmosphere winds correlate with poor seeing even when the surface is calm.

Best astro period

AstroWeatherAnalyzer identifies the best consecutive window within the forecast where:

  • Cloud cover is low
  • Humidity is acceptable
  • Wind is calm
  • Dew margin is safe

These “best period” cards are displayed in the Astrophotography tab.


7Timer seeing forecast

Source: 7Timer ASTRO product

Cache TTL: 6 hours (CACHE_TTL_SEEING_FORECAST). On a fetch failure, the cache is left untouched (previous good forecast keeps being served) instead of being overwritten with a failure marker - a transient 7Timer outage no longer blanks the tab for the full TTL window, since the next 5-minute scheduler tick retries once the existing entry’s TTL naturally elapses.

Module: backend/astroweather/seeing_forecast_7timer.py

Class: SeeingForecastService

7Timer’s ASTRO product returns eight fields per 3-hourly timepoint: seeing, transparency, cloudcover, lifted_index, rh2m (humidity), wind10m (direction + speed class), temp2m and prec_type. All of them are decoded and surfaced - not just seeing - and combined into a single composite quality_score (0-10) per timeslot.

Seeing scale (7Timer)

ValueLabelFWHMNotes
1Excellent< 0.5 arcsecPerfect for planetary imaging
2Very Good0.5 - 0.75 arcsecExcellent planetary detail
3Good0.75 - 1 arcsecVery good for planetary imaging
4Moderate1 - 1.25 arcsecFair for planetary imaging
5Fair1.25 - 1.5 arcsecUsable with reduced fine detail
6Poor1.5 - 2 arcsecPoor conditions
7Very Poor2 - 2.5 arcsecUnsuitable for high-res imaging
8Bad> 2.5 arcsecUnsuitable for planetary imaging

Transparency scale (7Timer)

7Timer’s transparency field uses the same 1-8 shape as seeing (1 = worst, 8 = best), expressed as limiting magnitude per air mass:

ValueLabelMag per air mass
1Very Poor< 0.3
2Poor0.3 - 0.4
3Below Average0.4 - 0.5
4Average0.5 - 0.6
5Above Average0.6 - 0.7
6Good0.7 - 0.85
7Very Good0.85 - 1
8Excellent> 1

Cloud cover scale (7Timer)

1-9 scale, 1 = clearest (0-6% cover), 9 = fully overcast (94-100% cover).

Wind speed scale (7Timer, 10m)

1-8 scale from calm (< 0.3 m/s) to hurricane (> 32.6 m/s), paired with a compass direction.

Composite quality score

Each forecast point’s quality_score (0-10, higher is better) combines four of the raw 7Timer fields:

ComponentWeightWhy
Seeing35%7Timer’s own astronomy-specific model output (not a proxy)
Transparency30%Same - 7Timer’s own model output
Cloud cover25%Strongest remaining go/no-go signal
Wind speed10%Tracking-stability proxy

Active precipitation (prec_type != none) is a hard multiplicative veto (score × 0.1) rather than an averaged component, the same convention used by weather_astro.py’s precipitation_factor: rain or snow shouldn’t be masked by otherwise-clear metrics. lifted_index is decoded and exposed but not scored, since 7Timer’s seeing value already folds atmospheric stability into its own model.

The Seeing sub-tab renders this as an hourly timeline in the same visual style as the Astrophotography tab’s “Score de la Nuit” (night-score-timeline): one card per timeslot with the combined score, a quality badge, and an icon row for every underlying metric (seeing, transparency, cloud cover, wind, humidity, precipitation).

Best windows

Two “best window” cards are computed from the forecast:

  • Best Seeing Window - longest consecutive run with seeing ≤ 3 (Good or better), unchanged from the original seeing-only definition - still the relevant metric for planetary imagers.
  • Best Overall Window - longest consecutive run with quality_score ≥ 6 (Good or better), reflecting the full picture (seeing + transparency + clouds + wind).

Data sources

  • Open-Meteo: open-meteo.com - free, open-source weather API; no account required.
  • 7Timer: 7timer.info - free astronomical weather service based on GFS model data; no account required.

API endpoints

MethodPathDescription
GET/api/weather/forecastFull hourly weather forecast from Open-Meteo; each hour’s condition is the unified observation_score x10, merged from the astro_weather cache
GET/api/weather/astro-analysisAstrophotography metrics (seeing, transparency, best period); the 24 h / default-language request is served from the astro_weather cache
GET/api/weather/astro-currentCurrent-hour astrophotography conditions snapshot (served from the astro_weather cache when warm)
GET/api/weather/alertsActive weather alerts list
GET/api/seeing-forecast7Timer seeing and transparency time-series

Last updated 2026-11-13

SkyTonight

SkyTonight is the built-in observability calculator. It computes, for every target in the dataset, which objects are worth imaging tonight - ranked by AstroScore - and exposes the results through a cached JSON API.


Using SkyTonight

The SkyTonight tab shows the results of the last calculation, in sub-tabs:

Sub-tabWhat you do there
PlotA polar map of the sky with the path of every target tonight; filter by category and AstroScore, click a target to inspect it
Deep Sky ObjectsThe ranked table: sort by AstroScore, filter by type, constellation, size, magnitude or equipment fit (Advanced DSO filters); “Tonight for you” picks a few targets for your level (BEGINNER_EXPERIENCE.md)
BeginnerThe curated starter list (can be hidden in My settings)
Solar System Bodies, CometsThe Moon, planets and comets observable tonight
LogsThe log of the last calculation
DSO not found?Search any object to see whether it passes tonight’s constraints, and which one filters it out

From a row you can open the target’s altitude chart, add it to the Astrodex or to Plan My Night, open its visibility calendar and the framing simulator from their icons, and open More: catalogue names, transit time, the best equipment combinations and the Wishlist button.

The results are recomputed twice a night (Scheduler); after changing a constraint, Run SkyTonight Now in App settings -> Configuration recomputes them at once.


Advanced DSO filters

The Deep Sky Objects tab has a collapsible Advanced filters panel above the table. Unlike the text / constellation / type / catalogue / difficulty filters (which run client-side over the rows already on screen), these five run server-side in _build_dso_section_payload, applied before the max_deep_sky_rows truncation cut - so a filtered result set is a genuine catalogue-wide search, not a filter over the top N by AstroScore. They are passed as query params on GET /api/skytonight/data/dso and debounced (~400 ms) with a spinner.

ParamFilterNotes
size_min, size_maxAngular size (arcmin)A view filter inside the configured size_constraint_min/max band, which the panel shows as “within your configured N-M arcmin”.
sb_maxSurface brightness threshold (mag/arcmin^2)Keeps only targets at or brighter than the threshold (lower number = brighter).
alt_window_min_deg + alt_window_start + alt_window_endBest altitude window“above X deg between HHh and HHh”. Evaluated against the row’s hourly_altitude vector; the window may wrap past midnight.
combination_id + fov_fitFOV fitNeeds a chosen equipment combination. Per row: fov_fit: {fits, fill_pct} with fill_pct = size_arcmin / min(fov_h_arcmin, fov_v_arcmin). The guide camera is never part of FOV math; a camera-only combination uses its lens fields.
combination_id + max_integration_hEstimated minimum integration timeNeeds a chosen combination. See EXPOSURE_CALC.md.

Unknown values are never silently dropped. A target with no magnitude or size (so no surface brightness, no FOV fill, no integration estimate) stays in the result and is shown as unknown; a size / SB / integration filter only excludes a target whose value is present and fails. The response echoes advanced_filters.size_band and the resolved combination so the UI can label the result set and state how many targets were considered after filtering.


Target visibility calendar

GET /api/skytonight/visibility-calendar?target=<id_or_name>&year=<yyyy> answers “when is this object best this year?” for the user’s active location. It is computed on demand (the SkyTonight pipeline only ever covers tonight) and cached in a small bounded in-process LRU keyed (target, location, year) - deliberately not a scheduler cache job, because the key space (any target x any location) is unbounded and the underlying ephemeris changes only once a year.

Deep-sky objects only. Planets and comets move measurably over a year, so a fixed-RA calendar is meaningless for them - the response returns supported: false with reason: "moving_target". reason: "not_found" means the identifier could not be resolved to coordinates (SkyTonight dataset first, then a SIMBAD fallback for Astrodex items outside the dataset).

Sampling scheme

  • 24 sample nights: the 1st and 15th of each month across the requested year. Two samples per month keep a single unlucky Moon phase from dominating the monthly figure.
  • Per sample night, the Sun and Moon altitude grid (18:00 -> 06:00 local, 10-minute steps) is built once via the shared astroweather.moon_planner.night_body_altitude_grid helper (one Astropy AltAz transform per body).
  • The target’s own altitude/azimuth is analytic, from the hour angle H = LST - RA -> alt = asin(sin(dec)*sin(lat) + cos(dec)*cos(lat)*cos(H)), with LST from the same time grid. Arcminute accuracy at O(1) trig per sample.

Constraint gates (kept in sync with the live calculator)

A sample step counts toward observable_hours when the Sun is below -18 deg (astronomical dark) and the target sits between the effective altitude floor and altitude_constraint_max. The floor is the stricter of altitude_constraint_min, the airmass-derived altitude (asin(1 / airmass_constraint)), and the active location preset’s custom horizon_profile at the target’s azimuth (same _horizon_floor_array helper the calculator uses). moonless_observable_hours additionally requires the Moon below the horizon.

Response shape

{
  target:   { id, name, type },
  location: { id, name },
  year, supported, reason?,
  months:  [ { month, dark_hours, observable_hours, moonless_observable_hours,
               max_altitude, moon_illumination_pct, score, bucket } ],   // 12 aggregates
  samples: [ { date, dark_hours, observable_hours, moonless_observable_hours,
               max_altitude, transit_local_time, moon_illumination_pct } ], // 24 raw nights
  constraints: { altitude_min, altitude_max, has_horizon_profile }
}

score is moonless_observable_hours normalized against the best month of the year (falling back to observable_hours when the target is never moonless-observable); bucket is score mapped to 0-5 for the heatmap cell colour. The 24 raw samples drive the per-month tooltip.


Equipment combination recommendations

For any target, POST /api/skytonight/combination-recommendations rates how well each of the user’s enabled and valid equipment combinations (own + shared, see EQUIPMENT.md) suits that target, on a 1-5 scale.

Unlike AstroScore (which rates the night), this rates the gear - it runs on demand per target, independent of the scheduled calculation (Scheduler).

Scoring factors

FactorWeight (no camera)Weight (with camera)Basis
Focal length vs. ideal range50%30%Target apparent size/type -> an ideal focal-length band
Field-of-view match-30%Real sensor FOV (via calculate_fov) vs. target apparent size - only when the combination’s camera has sensor dimensions
Aperture vs. brightness35%25%Target magnitude -> ideal aperture range
Optical speed (f-ratio)15%15%Faster f-ratios favor nebulae/wide-field targets

A combination without a telescope but with lens_focal_length_mm/lens_focal_ratio set (e.g. a DSLR + lens on a star tracker) is scored the same way, using the lens specs in place of a telescope’s optics. A combination that resolves neither a telescope nor usable lens specs is omitted from the results rather than scored arbitrarily.

Disabled or invalid combinations (see EQUIPMENT.md) never appear in recommendations.


Framing simulator

What does my sensor see on this target? The framing simulator shows a real image of the sky around a deep-sky object with the sensor rectangle of an equipment combination drawn to scale. It opens from the frame icon next to a target in the Deep Sky Objects table, from a Wishlist item, from a Plan My Night entry, and as a Framing section in an Astrodex item detail.

  • Select up to three combinations to compare them on the same image. One is selected on opening: from Plan My Night, the plan’s own combination; otherwise the best fit - a good fit before a tight one, and among equals the one whose target fill is closest to 35% of the short side.
  • Rotate the sensor (position angle, from north through east) and drag the frame - or use the arrow keys - to move it off the target. The coordinates of the frame centre are shown.
  • A dashed circle marks the target’s apparent size, when the catalogue gives one.
  • Each selected combination gets a verdict from the ratio “target size / short side of the field”: below 10% small (with the target’s width in pixels), 10-60% good fit, 60-100% tight, above larger than the field. In that last case an approximate mosaic grid is given, assuming 15% overlap; only the target’s longest dimension is known, so it is an upper bound for an elongated object and is labelled as an estimate.
  • Download image saves the view as a PNG with the target name, the combinations and the image credit.

A combination appears when it is enabled and valid (own or shared), has a camera with sensor dimensions and pixel size, and a focal length - the telescope’s effective one, or the lens of a camera-only combination. Planets and comets are not supported: they move against the stars, so a fixed image of the sky does not apply (supported: false, reason: "moving_target").

Sky image

The background is a 1000 px DSS2 colour cut-out from CDS hips2fits, north up and east left, in gnomonic (TAN) projection centred on the target. A camera sensor is a gnomonic projection too, so the frame is an exact rectangle on the image whatever the field of view.

The server downloads each image once and keeps it in data/cache/framing_images/ (trimmed to 200 MiB, oldest first); the browser never calls CDS. To bound that cache, the field of view of a background is one of a fixed ladder (0.25 to 45 degrees): the page picks the smallest step that is about 2.2 times wider than the widest selected frame. The request carries the target’s catalogue coordinates and nothing about the user.

When CDS hips2fits cannot be reached, the server builds the same cut-out itself from the tiles of the DSS2 colour HiPS published by mirror sites (ESA ESASky, MAST, CSIRO, China-VO - the list CDS published during its November 2026 incident), tried in that order (object_info.DSS2_HIPS_MIRRORS). observation/hips_cutout.py maps each output pixel to the sky, finds its HEALPix NESTED pixel at the tile resolution hips2fits would use, and averages a 2x2 sub-pixel grid; the result matches hips2fits on the same field. The mirrors serve CDS’s own tiles, so the credit stays “DSS2 Color / CDS HiPS”. The same fallback serves the object thumbnails (/api/object-image/<filename>).

Each server (hips2fits and every mirror) is backed off for a few minutes after a failure, so an unreachable one costs a single timeout. When none answers, the frame is still drawn to scale on a dark background, and image cards show a neutral placeholder.

API

  • GET /api/skytonight/framing?target=<id_or_name> - the resolved target (coordinates, apparent size), the image key and allowed fields of view, and one frame per usable combination: field of view, image scale, half extent in tangent-plane units, verdict, fill ratio, target size in pixels and the mosaic estimate. default_combination_id names the best fit.
  • GET /api/skytonight/framing/image/<ra>_<dec>_<fov>.jpg - the cached background image.

Implementation: backend/observation/sky_framing.py (target resolution, image cache), backend/equipment/framing.py (sensor geometry and verdict), static/js/framing.js.


Architecture overview

Scheduler (backend/skytonight/skytonight_scheduler.py)
  └─ run_calculations()  (backend/skytonight/skytonight_calculator.py)
       ├─ load_targets_dataset()       <- DSOs (OpenNGC / OpenIC / Caldwell / Messier / Herschel400 / Pensack500 / LBN)
       ├─ load_comets_dataset()        <- MPC / JPL comets (auto-updated)
       ├─ Solar-system bodies          <- Skyfield ephemeris (de421.bsp)
       └─ Writes shared JSON to /data/skytonight/
              calculations/dso_results.json
              calculations/bodies_results.json
              calculations/comets_results.json
              calculations/skymap_data.json
              calculations/calculation_results.json   <- metadata-only "all done" signal
              outputs/<target_id>_alttime.json        <- altitude-vs-time graph data

The API (app.py) reads these JSON files; it never re-runs the heavy calculation per request.


Scheduler

ConditionSchedule
System clock is valid (NTP / correct date-time)1 hour after nautical dawn + 1 hour before nautical dusk + on first startup
System clock is invalid / unknownEvery 6 hours

Multiple Gunicorn workers are protected by a file-level lock so only one worker runs the calculator at a time.


Target catalogues

CatalogueSourceNotes
OpenNGCPyOngcNGC objects
OpenICPyOngcIC objects
Messiersubset of OpenNGCflagged via identifiers[0]
Caldwellsubset of OpenNGCextracted from PyOngc other_identifiers
Herschel 400static list in skytonight_catalogue_builder.pyAstronomical League H400 program - cross-ref tag injected on matching NGC records
Pensack 500backend/catalogues/pensack500.json502 NGC/IC objects - cross-ref tag injected on matching records
LBNbackend/catalogues/lbn.jsonLynds’ Bright Nebulae - 94 cross-refs injected on matching NGC/IC records
GaryImmbackend/catalogues/garyimm_crossrefs.json + garyimm_standalone.json206-object astrophotography list by Gary Imm - 133 cross-refs on existing records + 33 standalone objects with no NGC/IC ID (Arp groups, LBN/LDN standalone, etc.)
Arpbackend/catalogues/arp.jsonArp Atlas of Peculiar Galaxies - 120 NGC/IC cross-refs injected on matching records
Sharplessbackend/catalogues/sharpless.json313 HII emission nebulae (Sh2 catalogue, Sharpless 1959) - all standalone records
Barnardbackend/catalogues/barnard.json343 dark nebulae (Barnard 1927 catalogue) - all standalone records
vdBbackend/catalogues/vdb.json158 reflection nebulae (van den Bergh 1966 catalogue) - all standalone records
Abell PNebackend/catalogues/abell_pne.json71 large planetary nebulae (Abell 1966 catalogue) - all standalone records
Abell Clustersbackend/catalogues/abell_clusters.json2,712 rich galaxy clusters (Abell, Corwin & Olowin 1989) - all standalone records, size bypassed (null)
CometsMinor Planet Center / JPL SBDBauto-refreshed
Planets / Moon / SunSkyfield de421.bspalready computed elsewhere

Cross-ref catalogues (Herschel400, Pensack500, LBN, GaryImm, Arp) add a catalogue_names entry and a source_catalogues tag to existing NGC/IC records; they never add new coordinates or change the preferred display name. Standalone catalogues (GaryImm, Sharpless, Barnard, vdB, AbellPNe, AbellClusters) create new records for objects with no NGC/IC identifier. Objects selected by Gary Imm in the Sharpless/Barnard/vdB catalogues receive both the catalogue tag and the GaryImm tag via extra_catalogues.

Target names are preferred in this order: CommonName -> Messier -> OpenNGC -> OpenIC -> Caldwell -> LBN -> Herschel400 -> Pensack500 -> GaryImm -> Arp -> Sharpless -> Barnard -> vdB -> AbellPNe -> AbellClusters.


Observability constraints

All constraints are always active (no on/off toggle).

ParameterDefaultDescription
altitude_constraint_min30°Minimum altitude above horizon
altitude_constraint_max80°Maximum altitude (avoids zenith blind spot)
airmass_constraint2.0≈ 30°+ elevation
size_constraint_min0 arcminMinimum angular size (DSOs only); 0 = no lower limit
size_constraint_max300 arcminMaximum angular size (DSOs only)
moon_separation_min45°Minimum angular distance from Moon
moon_separation_use_illuminationtrueWhen enabled, overrides moon_separation_min with Moon illumination % -> degrees (1 % = 1°)
fraction_of_time_observable_threshold0.50DSOs must be within constraints for ≥ 50 % of the night
north_to_east_ccwfalseAzimuth convention; false = clockwise (standard)

Bodies (planets, Moon) use a relaxed minimum fraction of 0.05 (≈ 22 min) instead of the DSO threshold, because a planet visible for 3 h in a 7 h night is still a prime target.


AstroScore

AstroScore is a dimensionless number in [0, 1] that ranks targets by their suitability for astrophotography tonight from the configured location.

Normalisation helper

All sub-scores use a linear clamp:

1 - Visibility score (weight 0.40)

Measures how well-placed the target is in the sky:

InputDescription
alt_maxPeak altitude reached during the night (degrees)
obs_hoursTotal hours within all constraints
alt_meridianAltitude at meridian transit (degrees)

2 - Sky quality score (weight 0.25)

Penalises Moon interference:

InputDescription
moon_phaseIlluminated fraction of Moon disk, 0 (new) - 1 (full)
angular_dist_moonAngular separation between target and Moon (degrees); defaults to 180° when unavailable

3 - Object score (weight 0.25)

Rewards intrinsically bright, high-contrast targets using surface brightness:

Inverting the normalisation means a low SB value (brighter, easier to image) -> high score.

RangeMeaning
SB ≤ 12Very bright extended object -> score = 1.0
SB = 17Mid-range -> score ≈ 0.5
SB ≥ 22Very faint/diffuse -> score = 0.0

When magnitude or size data are unavailable, a neutral value of 0.5 is used.

4 - Comfort score (weight 0.10)

Rewards targets that are observable during convenient evening hours:

obs_hours_in_window is the subset of observable hours that fall within the prime-time window, not the total observable hours.

5 - Final weighted sum

6 - Bonuses

Applied after the weighted sum, before final clamping:

ConditionBonus
Planet at opposition (is_planet=True AND is_opposition=True)+0.20
Object in Messier catalogue+0.05

The final value is clamped to [0.0, 1.0] and rounded to 4 decimal places.

Score interpretation

AstroScoreInterpretation
0.85 - 1.00Exceptional - ideal conditions
0.65 - 0.84Good - recommended target
0.45 - 0.64Average - worth imaging if nothing better
< 0.45Poor - significant limitation (moon, low altitude, faint object)

Difficulty rating

Alongside AstroScore, every target also gets a static difficulty_score (0-100) and difficulty label (beginner / intermediate / advanced), computed once per calculation run from magnitude, angular size, and surface brightness. Unlike AstroScore, difficulty does not depend on location, date, or Bortle class - the same object is always the same difficulty everywhere. It powers the SkyTonight difficulty filter/badges, the “Tonight for you” recommendations panel, and the Beginner Catalog. Full model and thresholds: BEGINNER_EXPERIENCE.md.


Light Pollution Integration

AstroScore includes an optional light pollution penalty based on your site’s Bortle class or a direct SQM (Sky Quality Meter) reading.

Configuration

Set your site’s Bortle class in App settings -> Locations -> (edit a preset) -> Sky quality:

  • Bortle Class (dropdown 1-9): choose the class that matches your observing site. You can look up your location on lightpollutionmap.info using the World Atlas layer.
  • SQM (optional, mag/arcsec²): enter if you have a real SQM-meter reading. This overrides the Bortle midpoint estimate and gives more precise weighting.

Leaving both fields empty disables the feature entirely - AstroScore behaves exactly as before.

How it affects AstroScore

The LP factor is applied to the sky score component (weight 0.25) of AstroScore, which already accounts for moon phase and angular distance. The combined factor degrades the sky score in proportion to your light pollution level.

Different object types respond differently to light pollution:

Object typeLP sensitivityNotes
Galaxy1.0 (full)Very sensitive to sky gradient
Nebula0.85Broadband is affected
Cluster0.50Moderately affected
Comet0.70Moderately affected
Planet0.05Effectively immune
Moon0.00Completely immune

At a Bortle 5 (suburban) site, a galaxy loses roughly 20-25 % of its sky score compared to a Bortle 2 site, while planets are unaffected.

API

GET /api/skyquality returns the current sky quality configuration:

{
  "bortle": 5,
  "sqm": 20.55,
  "sqm_source": "bortle_midpoint",
  "light_pollution_factor": 0.5196,
  "description": "Suburban"
}

sqm_source is one of "user_measured", "bortle_midpoint", or "not_configured".


  • Barnard dark nebulae: Barnard (1927), VizieR VII/220A
  • van den Bergh reflection nebulae: van den Bergh (1966), VizieR VII/21
  • Abell planetary nebulae: Abell (1966), ApJS 12, 391; coordinates from SIMBAD (CDS, Strasbourg)
  • Abell galaxy clusters: Abell, Corwin & Olowin (1989), ApJS 70, 1; VizieR VII/110A
  • Minor Planet Center comet elements: minorplanetcenter.net
  • JPL Small-Body Database: NASA JPL
  • Skyfield ephemeris: DE421 / DE430 via Skyfield
  • Astropy / Astroplan: coordinate transforms, moon illumination

Output files

All paths are relative to /data/skytonight/.

FileDescription
calculations/dso_results.jsonDeep-sky objects passing all constraints, sorted by AstroScore desc. Each entry also carries surface_brightness (mean mag/arcmin^2 from integrated magnitude + catalogue size, or null) and hourly_altitude (a compact [{h, alt}] vector, altitude linearly interpolated to the top of each local hour spanned by the night grid) - both feed the v1.4 advanced DSO filters without re-reading per-target alttime files
calculations/bodies_results.jsonSolar-system bodies (planets, Moon, etc.)
calculations/comets_results.jsonComets from MPC
calculations/skymap_data.jsonSky map trajectory data (az/alt arrays per visible target)
calculations/calculation_results.jsonMetadata-only file written last; signals that all calculation files are complete
outputs/<id>_alttime.jsonAltitude-vs-time series for the popup chart (15-min resolution)
runtime/scheduler_status.jsonScheduler run state, progress, last duration
logs/last_calculation.logLast run log lines (JSONL format: one entry per phase)

API endpoints

MethodPathDescription
GET/api/skytonight/scheduler/statusScheduler status, progress, last run time
POST/api/skytonight/scheduler/triggerManually trigger a recalculation (admin)
GET/api/skytonight/dataset/statusDataset build status and catalogue counts
POST/api/skytonight/dataset/rebuildForce a dataset rebuild
GET/api/skytonight/data/dsoDeep-sky results for the UI (reactive, per-section) - optional ?catalogue= filter, plus the advanced filters
GET/api/skytonight/data/bodiesSolar-system body results (reactive, per-section)
GET/api/skytonight/data/cometsComet results (reactive, per-section)
POST/api/skytonight/combination-recommendationsRate the user’s equipment combinations (1-5) for one target
GET/api/skytonight/visibility-calendar12-month visibility calendar for one fixed deep-sky target - see Target visibility calendar
GET/api/skytonight/framingFraming simulator data for one fixed deep-sky target - see Framing simulator
GET/api/skytonight/framing/image/<filename>Cached DSS2 background image of the framing simulator
GET/api/skytonight/alttime/<id>Altitude-time data for one target
GET/api/skytonight/skymapSky map trajectory data
GET/api/skytonight/logLast calculation log content (JSONL)

Full parameter details: API_ENDPOINTS.md


Source references

Last updated 2026-11-13

Spaceflight Tracker

The Spaceflight tab keeps you connected to what is happening above the atmosphere: rocket launches, current ISS crew, orbital transits, and upcoming space events.


Tab layout

Sub-tabContent
LaunchesUpcoming and recent rocket launches with details and video links
AstronautsCrew currently in orbit
Space EventsSpacewalks, dockings, and mission milestones
ISSReal-time ISS position + pass predictions for your location

Launches

Module: backend/space/spaceflight_tracker.py

Data source: Launch Library 2 (The Space Devs)

API base: https://ll.thespacedevs.com/2.2.0/

Cache TTL: 2 hours (CACHE_TTL_SPACEFLIGHT_LAUNCHES).

Launch Library 2 is the de-facto standard for launch data. The free tier allows ~15 requests/hour; MyAstroBoard’s cache keeps live calls minimal.

What is displayed

FieldDescription
Mission nameLaunch or payload name
Agency/providerLaunch operator (SpaceX, ULA, Roscosmos…)
VehicleRocket family and variant
Launch sitePad location
NETNo Earlier Than date-time (in your local timezone)
StatusGo, TBD, Hold, Success, Failure, Partial Failure
Launch videoEmbedded webcast URL (YouTube, SpaceflightNow…)
Mission imageLaunch patch or vehicle image (cached locally)

Image caching

Launch images from the LL2 CDN are downloaded and served locally via GET /api/spaceflight/img/<filename>. This avoids CORS issues and makes the UI work without external CDN access. A .url sidecar file is written alongside each image so it can be re-fetched if deleted.

Rate limiting

If the LL2 API returns HTTP 429 (rate limit), all further calls to the same endpoint are suppressed for 1 hour (_BACKOFF_TTL = 3600 s) to respect the free-tier quota.

Video URLs

GET /api/spaceflight/launch/<launch_id>/vidurls returns available video stream URLs for a given launch, fetched on demand (not cached) to ensure the latest live-stream link is returned.


Astronauts

Module: backend/space/spaceflight_tracker.py

Cache TTL: 6 hours (CACHE_TTL_SPACEFLIGHT_ASTRONAUTS).

Fetches from Launch Library 2 the list of all people currently in orbit:

FieldDescription
NameAstronaut full name
AgencySpace agency
VehicleSpacecraft they arrived on
RoleMission role (Commander, Flight Engineer…)
Days in spaceDays since launch
Profile imageLocally cached from LL2 CDN

API: GET /api/spaceflight/astronauts


Space events

Module: backend/space/spaceflight_tracker.py

Cache TTL: 2 hours (CACHE_TTL_SPACEFLIGHT_EVENTS).

Space events include EVAs (spacewalks), dockings, undockings, and other mission milestones tracked by Launch Library 2.

API: GET /api/spaceflight/events


ISS passes

Module: backend/space/iss_passes.py

Cache TTL: 6 hours (CACHE_TTL_ISS_PASSES).

TLE data

Pass prediction requires up-to-date Two-Line Element (TLE) data. The service tries three sources in priority order:

PrioritySourceNotes
1Celestrak GP catalogAuthoritative, NORAD #25544
2api.wheretheiss.atIndependent aggregator
3tle.ivanstanojevic.meIndependent aggregator

TLE data is cached locally in data/cache/iss_tle_cache.json for up to 6 hours. (Skyfield’s own ephemeris files are stored separately under data/cache/skyfield/.) If all sources fail, the last valid TLE is used for up to 3 hours before the prediction is considered unreliable.

After Celestrak blocked the server, an admin can clear the block with POST /api/iss/celestrak/restart (/api/css/celestrak/restart for the CSS); the banner offers the button to admins only.

Pass prediction algorithm

Library: Skyfield with EarthSatellite propagation (SGP4 model).

The service predicts passes over a 20-day window (max 30) from the configured location.

For each pass:

FieldDescription
start_time / end_timeRise and set in local timezone
duration_secondsTotal pass duration
max_altitude_degMaximum elevation above the horizon
start_azimuth_deg / end_azimuth_degDirection at rise and set
visibility_classvisible (ISS in sunlight, ground in darkness), daylight, night_only, below_horizon
visibility_score0-100 quality score

Passes with max altitude < 10° (MIN_EVENT_ALTITUDE_DEG) are filtered out.

Visible passes require:

  • ISS illuminated by the Sun
  • Observer in darkness (Sun altitude < −4°, MAX_VISIBLE_SKY_SUN_ALTITUDE_DEG)

Solar transits

A solar transit occurs when the ISS passes directly in front of the solar disk. This is a rare photographic opportunity.

The service refines transit timing to 0.1 s resolution (SOLAR_TRANSIT_REFINE_SAMPLE_SECONDS) and checks whether the ISS angular path crosses the Sun’s angular disk (radius ≈ 0.267°).

Lunar transits

Similarly, a lunar transit occurs when the ISS crosses the lunar disk. Both transit types are returned separately in the API response.

ISS real-time location

GET /api/iss/location returns the current ISS ground position (latitude, longitude, altitude, velocity). The ground track ±50 minutes is computed once per 5 minutes (server-side cache _TRACK_CACHE_TTL_SECONDS = 300) to reduce SGP4 computation.

API endpoints

MethodPathDescription
GET/api/iss/passesUpcoming passes, solar transits, and lunar transits
GET/api/iss/locationReal-time ISS position and ground track
POST/api/iss/celestrak/restartClear the Celestrak block flag (admin only)

The /api/iss/passes response includes:

KeyContent
passesList of predicted passes
solar_transitsList of solar transit events
lunar_transitsList of lunar transit events
next_visible_passageNext pass with visibility_class = visible
next_solar_transitNext solar transit
next_lunar_transitNext lunar transit
total_passes / total_solar_transits / total_lunar_transitsCounts

Push notification triggers

TriggerEventDefault lead
N3ISS solar or lunar transit10 min

See NOTIFICATIONS.md for the full notification system.

Last updated 2026-11-13

Plan My Night

Plan My Night lets each admin or user build a private target timeline for a single observing night.

SkyTonight Integration

From SkyTonight report tables (deep sky, bodies, comets), a dedicated Plan My Night column is available:

  • If no plan exists, first add creates the plan automatically.
  • If a current-night plan exists, add appends the target.
  • If only a previous-night plan exists, add is disabled until plan is cleared.
  • Alias matching is powered by catalogue_aliases to avoid duplicates.

Editing Features

  • Per-target planned duration (HH:MM).
  • Reorder targets (up/down) inside night timeline.
  • Mark targets done / undo.
  • Remove targets.
  • Add target to Astrodex directly.
  • Timeline progress bar and current-target banner while within night timeframe.

Equipment Combination Selector

When a user owns or shares more than one equipment combination, adding a target from SkyTonight shows a picker to choose which combination’s plan to target (badges show each combination’s plan state: active/expired/no plan). With exactly one combination, it’s picked automatically. Only enabled and valid combinations (see EQUIPMENT.md) are offered for a new plan; an existing plan on a combination that’s since been disabled still shows correctly instead of disappearing.

A combination used by any plan can’t be deleted while that plan exists (see EQUIPMENT.md) - plans are never silently orphaned by a combination going away.

Schedule optimizer

GET /api/plan-my-night/optimize previews a target order plus a single pre-first-target delay, derived from each target’s real altitude-based visibility window; POST /api/plan-my-night/optimize/apply applies it (rejected if the plan changed since the preview). As of v1.4 the ordering is flip-aware: targets are clustered by observable-run start (those within _FLIP_TIE_BREAK_WINDOW_MINUTES, ~20 min, of the earliest in the cluster), and inside each cluster the one whose meridian flip comes sooner is scheduled first, so it can finish before flipping. Targets with no flip or an unknown mount keep their prior order at the back of the cluster. A flip_mid_session plan warning is raised when a target still ends up straddling its flip.

Meridian flip

Each timeline entry shows a meridian-flip indicator when the plan is pinned to a location and its equipment combination has a mount. The flip time is derived at read time from the entry’s stored RA plus the plan’s pinned location, offset by the mount’s meridian_flip_delay_min - old plans need no migration. The transit is projected in O(1) from a single local-sidereal-time reading at the start of the night (skytonight_calculator._meridian_transit_from_lst), computed once per plan rather than per entry, since the timeline is polled every 30-60 s. Pre-v1.4 mount files with no meridian_flip_required key are backfilled on read (equipment_profiles.normalize_mount_flip_fields), so the indicator works for existing mounts without a re-save.

StateMeaningColour
noneFlip not required, no meridian transit tonight, or the flip falls before this slot startsno badge
afterFlip at or after the slot endgreen
midFlip strictly inside the slotorange
earlyFlip within the first 10 minutes of the slotred
unknownThe plan’s combination has no resolvable mount - no flip is guessedgrey

The tooltip carries the flip time and the mount’s meridian_flip_duration_min (minutes lost to the flip). The indicator also appears in the schedule-optimizer preview and the PDF export.

Log this session

Once a plan has targets, a Log this session button copies them into an Observation Log session (POST /api/observation-sessions/from-plan), then switches to the Observation Log sub-tab.

  • It works on both current and previous plans. A plan flips to previous the moment night_end passes - which is exactly when a user sits down to log what they actually captured, so the button is deliberately not gated the way the current-only CSV/PDF export buttons are. Importing is a read operation from the plan’s perspective; the plan itself is never modified.
  • Re-importing is idempotent: a plan entry already imported into the target session is skipped, so pressing the button twice never duplicates targets.
  • If exactly one session created today already came from this same plan, the user is offered a merge into it instead of starting a second session for the same night.

See OBSERVATION_LOG.md for the full contract.

Exports

  • CSV export: GET /api/plan-my-night/export.csv
  • PDF export: GET /api/plan-my-night/export.pdf

Plan States

  • none: no plan currently stored.
  • current: plan can be edited.
  • previous: plan is locked for edits; targets can still be added to Astrodex; plan can be cleared.

Pinned Location

A plan is pinned to the user’s active location at creation time (location_id + a frozen location_name snapshot in the plan payload). Its altitude/timeline math is never silently recomputed against different coordinates:

  • The plan view shows which location the plan was computed for.
  • If the viewer’s current active location differs from the plan’s pinned location, a non-blocking warning banner appears (altitudes shown may not match what you’ll actually see).
  • When an admin deletes a location preset, plans pinned to it are cascade-deleted by default (DELETE /api/locations/<id>?plans=cascade), or kept orphaned with the stale-location banner (?plans=orphan). See LOCATIONS.md.

Access Rules

  • admin and user can create, edit, reorder, complete, and clear plans.
  • read-only users can view the Astrodex tab but cannot access Plan My Night actions.
  • Plans are stored per user, one per equipment combination (database tables plans and plan_entries, keyed by the combination id, or default for the plan with no combination selected). A user can hold a separate plan per combination at once.

Legacy Plan Purge

Plans are daily/ephemeral, so the pre-combination (telescope-keyed) plan schema is never migrated: on every app startup, any plan file whose plan dict still contains the old telescope_id key is deleted outright rather than converted.

API Summary

See API_ENDPOINTS.md for the full list.

Last updated 2026-11-13

Observation Log

The Observation Log (v1.3) is the Log step of the Plan -> Observe -> Log -> Astrodex loop: a private, chronological record of what actually happened on a given night, and what was actually captured.

It lives as the 5th sub-tab of the Astrodex main tab, next to Astrodex, Plan My Night, Photo Map and Catalogue Collection - so the whole loop stays under one navigational roof.


Table of Contents

  1. Concept
  2. Import from Plan
  3. Multi-night sessions
  4. Attachments
  5. PDF export
  6. The one-directional Astrodex relationship
  7. Deletion and reference counting
  8. Data model
  9. Storage and write safety
  10. API endpoints
  11. Out of scope for v1.3
  12. Related documentation

Concept

QuestionAnswered by
“What do I want to shoot tonight?”Plan My Night (planned durations, timeline, per-combination)
“What did I actually do on the night of X?”Observation Log (this feature)
“Everything I have ever captured, as a gallery”Astrodex

A session is a trip: location, equipment combination and free-text (trip-level) notes, plus one or more nights - each with its own actual start/end times and sky conditions (SQM, seeing, transparency, moon illumination) and its own notes. The common case is one night; a multi-night dark-sky trip is just a session with more than one. Within the session, an ordered list of entries (one per target) each records the real numbers - frame count, sub-exposure length, integration minutes, a 0-5 rating and per-target notes - and is attributed to whichever night it was captured on. An entry can also optionally override the session’s own equipment combination, for the common case of switching telescopes/cameras partway through a night - see Multi-night sessions.

Sessions are permanently private. There is no private_mode toggle and no cross-user merged view: this is a personal logbook, not a second Astrodex. (The storage mechanics are copied from astrodex.py; the sharing model deliberately is not.)

Access rules

  • admin and user can create, edit and delete their own sessions and entries.
  • read-only users can view the Observation Log but cannot mutate anything (the two list/detail routes are @login_required; every mutating route is @user_required).
  • A session is only ever visible to its owner - there is no admin-wide view.

Import from Plan

POST /api/observation-sessions/from-plan, body {combination_id, session_id?}:

  1. Loads the plan via plan_my_night.get_plan_with_timeline().
  2. Both 'current' and 'previous' plans are importable. A plan flips to 'previous' the moment night_end passes - which is exactly when a user sits down to log what they captured. Importing is a read operation from the plan’s perspective; only 'none' (nothing to import) is refused with a 404.
  3. No target session (session_id omitted): creates a new session, seeding its location and combination from the plan’s frozen fields, with one night built from the plan’s date and nautical-twilight window.
  4. Existing target session (session_id given): finds-or-creates the night matching the plan’s date within that session (_find_or_create_night_for_date()) - reusing an existing night of the same date refreshes its start/end from the plan rather than creating a duplicate, so importing “day 2” of a multi-night trip’s plan just adds a second night to the ongoing session. A different date always appends a new night.
  5. Every imported entry is attributed to that night. Re-import is idempotent at the entry level too: any plan entry whose id already appears as a source_plan_entry_id in the target session is skipped.

plan_my_night.py itself is unchanged - this is a new blueprint route only.

In the UI, Plan My Night gains a “Log this session” button rendered in its own row (gated only on the plan having entries, independent of state - unlike the CSV/PDF export block, which is 'current'-only). The Observation Log list has an equivalent “Import from Plan” entry point, shown only when at least one importable plan exists. When exactly one session created today already came from the same plan, the user is offered a merge into it instead of a second session for the same night.


Multi-night sessions

A session always has at least one night and can have more - a week at a dark site is one session, not one per night, so the trip stays grouped and its total integration is one number.

  • Location is session-level (fixed for the whole trip), not per-night - a deliberate simplification: relocating mid-trip means starting a new session.
  • Every entry records its own equipment (entry.combination_id), frozen from the session’s own default at add time and editable afterwards - the session-level combination_id stays the trip’s “usual” equipment, while an individual entry’s own field is what actually gets displayed and counted for that target, whether or not it was changed from the default.
  • Everything that changes night to night lives on the night: date, actual start/end, SQM, seeing, transparency, moon illumination, and a per-night notes field (weather, mishaps specific to that night). The session’s own notes field stays trip-level (gear problems, overall impressions).
  • Every entry is attributed to a night via night_id. The same target can appear on several different nights of the same trip as separate entries - nothing dedupes across nights, matching “log what actually happened,” not “log each object once.”
  • Moon illumination is computed automatically, never typed in. Unlike seeing/transparency (a 7Timer forecast, only available same-day), it is a pure ephemeris calculation (astroweather/moon_planner.py’s moon_illumination_percent() - the same engine behind the 7-night Moon Planner forecast, for consistency app-wide), so it works for any date including the past - recomputed server-side whenever a night’s date changes.
  • A night can be added, edited or deleted independently (POST/PUT/DELETE /api/observation-sessions/<id>/nights[/<night_id>]). Deleting a night is refused while it’s the session’s last remaining one, or while any entry still points at it - move or delete those entries first. This mirrors the app’s established delete-guard convention (blocked while referenced) rather than leaving a dangling night_id behind.
  • Existing single-night sessions upgrade transparently. A session written before this existed has no nights field; load_user_sessions() synthesizes one nights[0] from its old scalar date/start_time/end_time/sqm/seeing/transparency fields (and stamps every entry with that night’s id) on read. This is lazy - nothing is rewritten to disk until the session is next saved for any reason - and idempotent, so there is no separate migration step to run.
  • Importing a Plan My Night plan reuses a night of the same date within the target session rather than duplicating it - see Import from Plan.

Attachments

A session can hold generic files - guiding graphs, subframe logs, planning notes exported from another tool - separate from the entry -> Astrodex picture link above: an attachment never lives in Astrodex, and the actual keeper photo attached through .../astrodex-picture never lives here. Two independent upload paths into two different places, on purpose.

  • Allowed types: jpg, jpeg, png, webp, pdf, txt, doc, docx - extension-only validation via secure_filename(), the same allow-list mechanism as Astrodex’s own image upload (just a different, wider set of extensions). No MIME sniffing, no size cap - matches Astrodex’s own upload route, which has neither either. Image attachments (jpg/jpeg/png/webp) are the exception: they must be real images (400 otherwise) and lose their EXIF/GPS metadata on upload, like Astrodex pictures - see Privacy & GDPR.
  • Storage: data/observation_sessions/attachments/ (flat, mirroring data/astrodex/images/), filenames regenerated as {user_id}_{uuid4()}.{ext}, never the original name. The directory path is computed by observation_sessions.attachments_dir() - a function, not a module-level constant, so it re-reads OBSERVATION_SESSIONS_DIR on every call and stays correct for test fixtures (and any future runtime reconfiguration) that swap that directory out.
  • Upload is one combined step (POST .../attachments, multipart), unlike Astrodex’s own upload-then-attach split - an attachment only ever belongs to the one session it’s uploaded to, so there’s no reason to separate “store the file” from “record it.”
  • Download is ownership-checked, not just login-checked: GET /api/observation-sessions/ attachments/<filename> verifies the filename appears in one of the caller’s own sessions before serving it (sessions are never shared, so this is simpler than Astrodex’s own private/shared-mode check on can_user_view_image()). An unrecognized filename and someone else’s real file both get the same 403, so the response never confirms whether a given filename exists. The response’s Content-Disposition filename is display_name (with the real extension re-attached if the custom name omits it) or else original_name - never the on-disk {uuid}.{ext} storage name, which is what a browser would otherwise suggest since it’s the last path segment of the URL.
  • Renaming (PUT /api/observation-sessions/<session_id>/attachments/<attachment_id>, {"name": "..."}) only ever writes display_name. It never touches filename or original_name, so the file on disk and its storage key are untouched - purely a cosmetic label plus the download filename above. An empty/blank name clears display_name back to null, reverting the UI to original_name.
  • Deleting a session deletes its attachment files too - unlike the entry -> Astrodex link (a soft reference into a different feature’s data, deliberately left untouched on delete), an attachment file is owned entirely by its session, so delete_session() removes them from disk before removing the session record. A missing file on disk (already cleaned up by other means) doesn’t block the deletion - only logged.
  • Covered by the admin backup ZIP: observation_sessions/attachments/ is one of the binary folders of utils/backup_archive.py, restored with the sessions themselves.

PDF export

Two routes, both built on the same page-rendering code (backend/observation/observation_sessions.py’s _render_session_section()), styled to match Plan My Night’s own PDF export (same header/footer bar, palette and A4 layout):

MethodPathProduces
GET/api/observation-sessions/<session_id>/export.pdfOne session
GET/api/observation-sessions/export.pdfEvery own session in an optional date range

Per-session PDF - a button on the session detail view. First page: a common-information card, then the logged targets. The card’s shape depends on the session:

  • Single night (still the overwhelmingly common case): unchanged from the original layout - date, location, equipment, start/end time, SQM, seeing, transparency, trip notes, plus a one-line summary (target count, average rating). Targets follow as a flat list.
  • Multiple nights: a shorter card with only location, equipment, total integration (across every night) and night count, plus trip notes. Targets are grouped: each night that has at least one logged target gets its own compact sub-header (date, start/end, SQM, seeing, transparency, moon illumination, that night’s own notes) immediately before its targets; an entry whose night_id doesn’t resolve to any of the session’s nights falls back to a final “Other” group rather than being silently dropped. Long nights/target lists paginate onto further pages exactly like the single-night list always did, without repeating a night’s sub-header on the continuation page.

Every target row shows its attached Astrodex photo when it has one (a neutral “No photo” placeholder otherwise) alongside its identity, capture numbers (frames, sub-exposure, integration) and notes - this part is unchanged by the multi-night breakdown.

Global PDF - a button on the session list, shown only once at least one session exists (nothing to configure otherwise). It opens a modal asking for a date range - prefilled with the earliest and latest night dates across all of the user’s sessions, so the untouched default is “every session” - and a sort order (asc/oldest-first is the default, desc available). The resulting PDF is: a cover page (title, date range or “All sessions”, generation timestamp, and aggregate stats - sessions/targets/integration/average rating), a summary table (one row per session: date range for a multi-night session or a single date otherwise, location, equipment, target count, integration, rating), then every session in the range rendered exactly like the per-session export, in the requested order.

Both routes are @login_required (read action, no @user_required gate) and only ever operate on the caller’s own sessions. from_date/to_date match a session when any of its nights falls in range (inclusive, string comparison against YYYY-MM-DD) - a multi-night trip straddling a filter boundary still matches, and every one of its nights still renders regardless of which of them individually fell inside the window. An empty range means every session. lang (or Accept-Language) selects the PDF’s own translated labels, independently of the query’s paging.

An entry’s photo is resolved by the blueprint layer (_resolve_entry_image_path()), never by the storage module - observation/observation_sessions.py never imports astrodex (see its module docstring), so the PDF generators receive a pre-built {entry_id: absolute_file_path} map instead of resolving Astrodex links themselves. A picture whose file has since been deleted, or whose linked Astrodex item/picture no longer resolves, degrades to the “No photo” placeholder rather than failing the export.


The one-directional Astrodex relationship

This is the decision most likely to be re-litigated later, so it is stated explicitly.

A session entry is its own record. It is the source of truth for “what happened this session”, independent of whether the target was ever processed into a keeper image. It is not a view onto an Astrodex picture, and the two are never kept in sync.

Two distinct links exist, with deliberately different triggers:

1. Astrodex item registration is automatic

The moment an entry gets real capture evidence - frame_count > 0, or a picture attached - the matching Astrodex item is found-or-created silently in the background (same dedup rules as Plan My Night’s existing add-to-astrodex), and its id is stored on the entry as astrodex_item_id.

There is no button for this. “Catch them all” is a keypoint of the app, and a manual push button could simply be forgotten, silently defeating it. Auto-linking the item costs nothing (no picture required - it is purely a catalogue-membership fact).

It is never auto-reversed. Editing frame_count back down to 0 leaves astrodex_item_id in place. Once catalogued, always catalogued; removing an Astrodex item stays a deliberate, guarded user action and no automated process undoes it.

2. Attaching the actual picture stays manual

Processing and stacking genuinely happen days after the session, so the photo is attached separately via POST /api/observation-sessions/<id>/entries/<id>/astrodex-picture. (Astrodex picture location was made editable rather than frozen for exactly this reason.)

No picture ever lives in Observation Log storage. The only place an image file is uploaded is the existing, unchanged POST /api/astrodex/upload route, which writes straight into Astrodex’s own image directory; the attach route then calls add_picture_to_item() and stores the resulting astrodex_picture_id on the entry.

Editing the entry afterwards does not update the linked Astrodex item/picture, and vice versa - mirroring Plan My Night’s existing one-shot copy behavior. If the linked item or picture is later deleted through Astrodex’s own UI, the entry’s pointer simply stops resolving; nothing is repaired or cascaded, and no reverse delete-guard is added to astrodex.py.

The forward link (entry -> item/picture) is the only thing ever stored. Astrodex’s own item/picture detail views additionally show a read-only “Logged on <date>” backlink back to the session that produced them, computed on every GET /api/astrodex request via observation_sessions.build_astrodex_session_backlink_index() (a one-pass reverse scan of the caller’s own sessions, keyed by astrodex_item_id/astrodex_picture_id). This is purely a navigation convenience - nothing is written back to either side, and it never crosses users (sessions are private, so the scan only ever covers the requesting user’s own sessions, regardless of whether the item view itself is a merged multi-owner one).

Consequence for v1.5 Session Analytics

Aggregation (“total integration hours”, “objects captured”) should read from Observation Log sessions/entries as the primary source, not from Astrodex pictures. Astrodex’s frames / exposition_time / integration_minutes (v1.3+: validated numeric fields, mirroring this module’s own trio - see docs/ASTRODEX.md) are a per-photo record and may not cover every entry (attaching a picture is a manual, optional step), whereas an entry’s own frame_count / sub_exposure_seconds / integration_minutes are set for every logged target. Pre-v1.3 pictures may also still carry a legacy free-text exposition_time value that predates the numeric field. Cross-reference Astrodex only for gallery/photo display.


Deletion and reference counting

Deleted objectEffect on sessions
Equipment combinationBlocked while any session (any user) references it - either as its own combination_id or as any entry’s per-target override - delete_combination() returns in_use_by_session, joining the existing in_use_by_picture / in_use_by_plan guards
Location presetNever cascaded, never orphan-flagged. GET /api/locations/<id>/references reports the count under observation_sessions for information only
Astrodex item / pictureNothing happens to the session. The entry’s pointer just stops resolving
NightBlocked while it’s the session’s last remaining night, or while any entry still references it via night_id - move or delete those entries first (see Multi-night sessions)
SessionIts nights and entries go with it. Any Astrodex item or picture it linked to is left completely untouched

The location choice is deliberate: a session, like an Astrodex picture, is a historical record - its frozen location_name snapshot stays valid as display-only history. That is different from a Plan My Night plan, which is an active object that needs a live location to keep computing altitude data against, and is therefore cascade-deleted.

The reference scans (count_sessions_for_combination, count_sessions_for_location) are fail-open: an unreadable file is skipped rather than aborting the scan, matching count_pictures_for_combination / count_plans_for_combination.

count_sessions_for_combination counts a session once whether the match is its own session-level combination_id, any entry’s own per-target combination_id override, or both - a single session can reference more than one combination across its different targets. An entry’s separate combination_used_components field is unrelated: it’s a checklist of which parts of whichever combination is effective for that entry (its own override, or the session’s) were actually used.


Data model

Session object

FieldTypeNotes
idstr (uuid4)
location_idstr | nullAccess-checked location preset id, resolved server-side
location_namestr | nullFrozen snapshot: preset name, or a free-text “somewhere else” label
location_latitude / location_longitude / location_elevationfloat | nullSame 3-state preset/custom/none pattern as an Astrodex picture’s location
combination_idstr | nullOwned or shared equipment combination, access-checked server-side
combination_namestr | nullFrozen snapshot
notesstrTrip-level notes (gear problems, overall impressions) - distinct from each night’s own notes
nightslist[night]See below. Never empty - a session always has at least one. Storage order is insertion order; sort/display always go through the earliest-date-first helpers (_sorted_nights(), _primary_night(), session_date_range()) rather than assuming list order is chronological
entrieslist[entry]See below. List order is entry order; there is no explicit position field (same convention as Plan My Night)
imported_from_plan_combination_idstr | nullProvenance marker set by “Import from Plan” ('default' for the no-combination plan). Never re-resolved live
created_at / updated_atiso8601 str

A session’s “date” for sorting, filtering and display purposes is always derived, never stored: the earliest night’s date (session_date_range(session)[0]) for a single value, or the full [earliest, latest] range where a range makes sense (list card, PDF summary table).

Night object

FieldTypeNotes
idstr (uuid4)
datestr YYYY-MM-DDUsually in the past (sessions are logged the morning after). Required - a night can never be undated, mirroring the pre-v1.3.1 session-level rule this replaced
start_time / end_timestr (iso8601) | nullActual observing start/end - distinct from Plan My Night’s planned night_start/night_end
sqmfloat | nullMeasured/estimated sky quality; pre-filled from the location preset’s own sqm when the night is created, always editable
seeingint 1-8 | nullSame scale as astroweather/seeing_forecast_7timer.py’s SEEING_SCALE (1 = best … 8 = worst)
transparencyint 1-8 | nullSame scale as 7Timer’s TRANSPARENCY_SCALE (1 = worst … 8 = best - inverted vs. seeing, per 7Timer’s own convention)
moon_illumination_percentfloat 0-100 | nullServer-computed, never client-supplied - see Multi-night sessions
notesstrThis night’s own notes (weather, mishaps specific to it)
created_at / updated_atiso8601 str

Entry object (one per target)

FieldTypeNotes
idstr (uuid4)
night_idstr | nullWhich of the session’s nights this entry belongs to. Defaults to the most recently added night when the client doesn’t specify one; validated server-side to resolve within the same session
name, catalogue, type, constellation, ra, dec, mag, sizemirrors Plan My Night’s _build_target_payload()Frozen snapshot at add time - never re-resolved live against SkyTonight
catalogue_group_id, catalogue_aliasesstr / dictCross-catalogue identity (dedup + alias display), same purpose as in Plan My Night entries
alttime_filestr | nullKey (not the embedded series) for the GET /api/skytonight/alttime/<id>?location_id= popup chart, exactly like Plan My Night. The underlying JSON is recalculated for “tonight” on every SkyTonight run and old files are purged, so the frontend only shows the chart button while now is within this entry’s own night’s start_time/end_time window - past that, the file no longer represents the logged observation
source_plan_entry_idstr | nullSet only on Import from Plan; makes re-import idempotent
planned_minutesfloat | nullFrozen snapshot of the plan target’s scheduled duration, carried straight from Plan My Night’s own planned_minutes on import; null for a manually-added entry with no plan origin. Shown next to integration_minutes so what was scheduled can be compared against what was actually captured - never recomputed or reconciled server-side
frame_countint | null
sub_exposure_secondsfloat | nullOptional; when present alongside frame_count, the frontend auto-computes integration_minutes as a convenience. Never recomputed server-side
integration_minutesfloat | nullThe one guaranteed-summable value for future aggregation, regardless of whether sub-exposure length was tracked
ratingfloat | null0-5 in 0.5 steps - the same widget and validation contract as Astrodex picture ratings (a deliberate correction of the roadmap’s “1-5”, so the app has exactly one rating convention)
notesstrPer-target notes
combination_idstr | nullThe equipment actually used for this one target (e.g. switched telescopes mid-session). Resolved/access-checked server-side exactly like the session’s own field. The Observation Log UI pre-selects and freezes the session’s own combination as this value at add time - never a live reference - so editing the session’s default equipment afterwards never silently reshapes an already-logged target’s equipment history; null is only the fallback for entries added directly through the API without one, or logged before this field existed, and is displayed as “whatever the session’s own equipment currently is”
combination_namestr | nullFrozen snapshot of combination_id’s name at the time it was set
combination_used_componentsdict | nullOptional checklist of which parts of the effective combination were used (the entry’s own override if it has one, else the session’s) - e.g. a filter swapped mid-session. Same shape as Astrodex’s picture field; null means “everything in the effective combination”
astrodex_item_idstr | nullAuto-populated on first capture evidence; never auto-cleared (see above)
astrodex_picture_idstr | nullSet only by the manual attach-picture action; null is the normal state for an entry with no keeper image yet
created_at / updated_atiso8601 str

Only the “what actually happened” fields (night_id, frame_count, sub_exposure_seconds, integration_minutes, rating, notes, combination_id, combination_name, combination_used_components) are writable through the update route - night_id is reassignable both when adding an entry and afterwards (unlike the frozen identity fields), so a target logged under the wrong night can be moved. The target identity snapshot is frozen, and the two astrodex_* pointers are set through link_entry_to_astrodex() rather than by a client payload.

Attachment object

FieldTypeNotes
idstr (uuid4)
filenamestrThe regenerated on-disk name ({user_id}_{uuid4()}.{ext}) - never the original, mirroring Astrodex’s own upload convention
original_namestrThe filename as the user’s browser sent it
content_typestrBest-effort MIME type from the upload, used only to pick a display icon - never trusted for validation (the extension allow-list is what’s actually enforced)
display_namestr or nullOptional user-set custom name, shown in the UI (and used as the download filename) in place of original_name when set. null by default - upload never sets it, only the rename route does
uploaded_atiso8601 str

Attachments are session-level, not per-night or per-entry - a guiding graph or a subframe log belongs to the whole night’s session, not one specific target. See Attachments.


Storage and write safety

Mechanically identical to backend/observation/astrodex.py:

  • Database tables observation_sessions, observation_nights, observation_entries and observation_attachments, one row per object (observation_sessions/<user_id>_sessions.json inside a backup ZIP).
  • Attachment files: data/observation_sessions/attachments/ (flat, mirroring data/astrodex/images/)
  • Document shape: {user_id, username, created_at, updated_at, sessions: [...]}.
  • load_user_sessions() never raises to the caller: an unreadable or malformed stored value yields an empty payload (replaced on the next save).
  • save_user_sessions() stamps updated_at, runs validate_sessions_data() (root is a dict, has username, has a list sessions; each session has id and a non-empty nights list, each night has id and date; any entry’s night_id, if set, must resolve within that session’s own nights) and writes the user’s rows in one transaction - nothing is stored when validation fails.
  • The observation log and its attachments are included in the admin backup ZIP (GET /api/backup/download) and restored by POST /api/backup/restore.

backend/observation/observation_sessions.py never imports astrodex or plan_my_night at module scope - resolving an entry to an Astrodex item is the blueprint layer’s job.


API endpoints

All routes are self-scoped to the caller. Mutating routes return {"status": "success", "data": ...} on success and {"error": "..."} with the matching HTTP status on failure.

MethodPathAuthPurpose
GET/api/observation-sessionsloginList own sessions (newest date first) + stats
GET/api/observation-sessions/<session_id>loginOne session with nights + entries (bare object; 404 if not owned)
POST/api/observation-sessionsuserCreate a session manually (201). Body’s date/start_time/end_time/sqm/seeing/transparency seed its first night
PUT/api/observation-sessions/<session_id>userUpdate trip-level fields only (location, equipment, notes) - a night’s own fields go through the routes below
DELETE/api/observation-sessions/<session_id>userDelete session + all nights/entries (Astrodex untouched)
POST/api/observation-sessions/from-planuserSeed/merge a session from a Plan My Night plan; reuses an existing night of the same date or appends a new one
POST/api/observation-sessions/<session_id>/nightsuserAdd another night (201). date required; moon_illumination_percent always server-computed
PUT/api/observation-sessions/<session_id>/nights/<night_id>userUpdate one night’s conditions/notes
DELETE/api/observation-sessions/<session_id>/nights/<night_id>userRefused (400) while it’s the session’s last remaining night, or while any entry still points at it
POST/api/observation-sessions/<session_id>/entriesuserAdd one entry. Side effect: auto-links the Astrodex item when frame_count > 0. night_id optional - defaults to the most recently added night
PUT/api/observation-sessions/<session_id>/entries/<entry_id>userUpdate one entry. Side effect: same auto-link whenever frame_count newly becomes > 0
DELETE/api/observation-sessions/<session_id>/entries/<entry_id>userRemove one entry (linked Astrodex item/picture kept)
POST/api/observation-sessions/<session_id>/entries/<entry_id>/astrodex-pictureuserManual attach-picture action; body {filename} plus optional metadata overrides
POST/api/observation-sessions/<session_id>/attachmentsuserUpload + record a session attachment in one step (201); multipart file field
GET/api/observation-sessions/attachments/<filename>loginServe an attachment file - ownership-checked, not just login-checked
DELETE/api/observation-sessions/<session_id>/attachments/<attachment_id>userRemove the attachment record and its file

GET /api/observation-sessions also returns a deliberately minimal stats block - {total_sessions, total_entries, total_integration_minutes, average_rating} (average_rating is None until at least one entry has been rated). Full analytics is v1.5 Session Analytics’ job, not this feature’s.


Out of scope for v1.3

  • Per-filter sub-exposure breakdown (Ha 20×300s + OIII 15×300s within one entry). v1 keeps one aggregate frame_count / integration_minutes per entry.
  • Session sharing / multi-user visibility. Sessions are always private; there is no private_mode toggle and no merged view.
  • Full analytics (integration hours over time, equipment usage breakdowns, sky-coverage maps) - shipped in v1.5 Session Analytics, built on this module; see SESSION_ANALYTICS.md.

Last updated 2026-11-13

Astrodex

Astrodex is your personal astrophotography logbook - a Pokédex-style catalogue of every deep-sky object you have imaged.


Where to find it

Astrodex tab -> Astrodex sub-tab


Concepts

Items and pictures

Each item represents one target object (a galaxy, nebula, cluster…). An item can have multiple pictures attached - one per session or per processing attempt.

Items are per-user: each account has its own private collection (database tables astrodex_items and astrodex_pictures, one row per item and per picture). Items from different users sharing the same target are merged into a combined view when browsing.

Catalogue integration

When you add an item by name, Astrodex looks up the name in the SkyTonight catalogue alias table (observation/catalogue_aliases.py) to find the canonical identifier. This allows adding “M31”, “Andromeda Galaxy”, “NGC 224”, or “Andromeda” - and they all resolve to the same record, preventing duplicates.

The check duplicate endpoint (GET /api/astrodex/check/<name>) lets the UI warn before adding a name that already exists.

For names the local SkyTonight dataset doesn’t know (mostly stars, e.g. “Vega”, “HIP 91262”), GET /api/astrodex/catalogue-lookup falls back to a SIMBAD TAP query (CDS first, then its CfA Harvard and IDIA mirrors - object_info.SIMBAD_TAP_ENDPOINTS - each backed off for a few minutes after a failure). The resolved name prefers SIMBAD’s NAME ... proper name when the object has one (surfaced as CommonName, e.g. “Vega” rather than the obscure Flamsteed designation “* 3 Lyr”), then a Bayer-style designation (“* alf Lyr”) over a bare Flamsteed number, then catalogue numbers (HD/HIP/SAO/TYC). The full set of resolved alternate names is captured client-side and submitted as external_aliases when the item is created, so the item detail popup’s “Catalogue names” section - otherwise sourced live from the DSO dataset via catalogue_aliases and empty for any star - has something to show (see enrich_item_with_catalogue_aliases()).


Features

Browsing

The Astrodex grid shows all your objects with cover picture thumbnails. Items without pictures use a default placeholder image. Sort by name, type, date added or photo count, ascending or descending. Name and type sorts are numeric-aware, so catalogue identifiers order the way they read (M 3 before M 31 before M 100, not as plain strings).

Constellation filter: Objects are grouped by constellation, from the IAU list of the constellation-enum package (observation/astrodex.get_constellations_list()); abbreviations are expanded to full names by utils/constellation_names.py. You can filter to show only objects in a specific constellation.

Type filter: Filter by object type (Galaxy, Nebula, Cluster…).

Adding objects

Two ways to add an object:

  1. From SkyTonight: Every target in the SkyTonight report tables has an “Add to Astrodex” button. The name, catalogue, and type are pre-filled.
  2. From Plan My Night: A “Add to Astrodex” button is available for each target in the plan.
  3. Manually: Use the “+” button in the Astrodex tab to add any object by name.

Editing objects

The object editor allows updating:

  • Name and catalogue reference
  • Object type and constellation
  • Notes

Managing pictures

  • Upload pictures with drag-and-drop or file picker.
  • On upload, EXIF/XMP/IPTC metadata (GPS position, camera serial numbers…) is stripped without re-encoding the pixels; the file must be a real JPEG, PNG, WebP or GIF, otherwise the upload is rejected with 400. See Privacy & GDPR.
  • Reorder pictures; set one as the main (cover) picture.
  • Delete individual pictures.
  • Images larger than the display size are served at original resolution for download.
  • Rate your own photos 0-5 stars (half-star steps) - lets you see which equipment combination produces your best results (average rating shown on the combination’s card in the Equipment tab).
  • Add/edit picture modal is organized into four sections: File, Date & Location, Equipment, and Photo information.

Re-processing with MyAstroShine

When the MyAstroShine integration is configured and enabled, each of your own photos gets a Send to MyAstroShine action. It opens MyAstroShine in a new tab with an edit session pre-loaded from the photo and its metadata; when you send the result back, a new picture is added to the same object (never a replacement), carrying the source photo’s metadata snapshot plus the immutable enhanced_* provenance fields. The Edit Photo modal of an enhanced picture shows a discreet “Re-processed with MyAstroShine” note with a view settings disclosure.

Catalogue lookup

GET /api/astrodex/catalogue-lookup?name=<name> queries the SkyTonight target catalogue for coordinates, type, magnitude, and angular size for a given object name. This auto-fills metadata when adding a new item.

Constellation list

GET /api/astrodex/constellations returns all constellations represented in the current user’s collection, useful for building the constellation filter dropdown.

Photo Map

Astrodex tab -> Photo Map sub-tab

Shows every geotagged picture (one with latitude/longitude set via a location preset or manually-typed custom coordinates in the Add/Edit Picture modal) on a world map, with marker clustering: bubbles show a photo count and separate into individual pins as you zoom the map in, mirroring the Apple/Google Photos map view. Clicking a bubble opens the existing photo slideshow directly for every photo in that cluster; clicking a single pin opens the slideshow for that one photo.

This does not perform any EXIF GPS extraction - it only visualizes coordinates a user already entered manually. It is powered by a dedicated, lightweight endpoint:

GET /api/astrodex/map returns a flat list of geotagged pictures across all visible users, gated by its own config['astrodex']['map_private'] flag (Configuration -> Astrodex -> “Photo map private”; on by default for new installs, off for installs whose configuration predates the setting) - deliberately independent from the general config['astrodex']['private'] sharing flag used by /api/astrodex. When map_private is off, every shared user’s pictures appear on the map with their real coordinates, even though /api/astrodex itself always strips other users’ exact coordinates for privacy in its own merged view. This app targets small trusted deployments (family/astro-club), so the map keeps its own, separately-labeled switch rather than being tied to the general sharing toggle; since real-world photo locations are often the observer’s home, new installs start with it private.


Catalogue Collection

Astrodex tab -> Catalogue Collection sub-tab

The Pokédex view. Pick a catalogue and see every object in it as a small card, whether you have imaged it or not - captured objects in full colour with your own cover photo, everything else greyed out with a sky-survey preview. Cards are display-only: nothing here adds, edits or deletes anything, it only reflects state already owned by the Astrodex and the SkyTonight catalogue.

What you can browse

Every deep-sky catalogue of the SkyTonight dataset, plus a Solar System pseudo-catalogue:

GroupContents
Solar SystemThe 8 planets, the Moon and the Sun. The Sun is a synthetic entry - SkyTonight tracks it as the source of twilight, never as a target - and the whole group is unrelated to the bodies results table.
Human-scale cataloguesMessier, Caldwell, Famous objects (CommonName), Herschel 400, Pensack 500, Gary Imm, Arp, LBN, Sharpless, Barnard, vdB, Abell PNe
Reference cataloguesAbell galaxy clusters (~2 700), NGC (~13 300), IC (~5 500)

Comets are deliberately absent: they are transient visitors, not a fixed set to complete.

The picker shows a captured count per catalogue (Messier (12/109)), and a progress bar sits above the grid. An object present in several catalogues - M 31 is also NGC 224 - counts as captured in each of them.

Each card shows its catalogue identifier, name, type, constellation, difficulty badge and magnitude.

Card images

SituationImage
Captured, has picturesThe item’s main (cover) picture, full colour
Captured, no pictures yetThe DSS2 preview, full colour
Not captured, deep-sky objectThe DSS2 preview, greyscaled and dimmed
Not captured, solar-system bodyBundled artwork under static/img/bodies/ - planets have no fixed coordinates, so no sky survey can supply their picture

DSS2 previews reuse the existing GET /api/object-image/<filename> proxy, which fetches from CDS hips2fits once (or, when CDS is down, renders it from a mirror of the DSS2 HiPS - see SKYTONIGHT.md) and caches the JPEG in data/cache/object_images/ (trimmed to 200 MiB, oldest first; a trimmed image is fetched again when next asked for). Thumbnails load through an IntersectionObserver as their card nears the viewport, so browsing a 13 000-object catalogue never fires more cold CDS fetches than the objects actually looked at.

Captured detection

An object counts as captured when one of its catalogue identifiers or aliases matches an Astrodex item of the current user. Matching runs through the same _extract_name_candidates() extractor is_item_in_astrodex() uses, so an item saved as "M31 - Andromeda Galaxy" is recognised, and the collection always agrees with the “in Astrodex” badges shown elsewhere in the app.

Filtering, sorting, paging

All three happen server-side (backend/observation/catalogue_collection.py) - the NGC catalogue is far too large to hand to the browser at once. Pages hold 60 cards by default, 120 at most.

  • Filters: free-text search (identifier and name), captured / not captured, difficulty, object type, constellation.
  • Sorts: catalogue identifier (default, ordered naturally so M 9 precedes M 10), name, captured, difficulty, magnitude, constellation, type - each reversible.
  • Objects with no value for the sorted field (no published magnitude, no difficulty) stay grouped at the end in both directions, rather than jumping to the top when the order flips.
  • The type and constellation dropdowns are built over the whole catalogue, not the current page, so they do not shrink as you filter or page. They are re-sorted client-side on the translated label, so the list reads alphabetically in the user’s own language.

Difficulty

Cards carry the same beginner / intermediate / advanced badge SkyTonight and the Astrodex grid use, via the shared createDifficultyBadgeNode().

The label comes from skytonight_calculator.compute_difficulty_score(), called directly on the catalogue record. That scorer derives the label from magnitude and apparent size alone and is explicitly independent of location and sky quality, so it can be computed for the whole catalogue rather than only for the targets a given night’s run happened to evaluate - unlike GET /api/astrodex, which cross-references dso_results.json and therefore leaves items unrated when they were not observable. Solar-system bodies have neither magnitude nor size on record, so they get no badge instead of the scorer’s neutral “intermediate” default, which would be a guess.

The difficulty/difficulty_score fields stored in targets.json are not used here: the offline dataset builder writes a placeholder (0 / intermediate) for every object.

Naming and translation

  • Constellations are stored in the dataset as IAU abbreviations (And, plus PyOngc’s Se1/Se2 for the two halves of Serpens). The endpoint expands them to full names (Andromeda, Serpens Caput) so the frontend’s existing constellations.* keys resolve; all 91 names in the dataset have a translation.
  • Object types go through skytonight.type_*, as everywhere else.
  • Object names are catalogue proper nouns and are shown as-is - SkyTonight does not translate them either. The one exception is the Solar System group, whose members have real translations and so go through the shared planets.* dictionary (Moon -> Lune). planets.sun was added for this view; the namespace previously only covered planets, since its only other consumer is the events aggregator.

Shared items

When multiple users on the same instance image the same object, items are merged in the Astrodex view:

  • Each user’s pictures are listed under their own section.
  • The owner_username field shows who added which picture.
  • Users can only edit or delete their own items and pictures.
  • The is_owned_by_current_user flag drives UI permissions.

Item data model

Each item of a user’s Astrodex has the following fields:

FieldTypeDescription
idUUID stringUnique item identifier
namestringDisplay name (e.g. “M31 - Andromeda Galaxy”)
typestringObject type: Galaxy, Nebula, Cluster, Supernova Remnant, Unknown, …
cataloguestringSource catalogue: NGC, IC, Messier, Sharpless, Barnard, vdB, …
constellationstringConstellation abbreviation (e.g. And, Ori)
notesstringFree-text notes, processing ideas, next steps
picturesarrayList of picture objects (see below)
location_idUUID string or nullv1.2: the location preset active when the item was created - best-effort link, only trusted while the preset still exists
location_namestring or nullv1.2: frozen plain-text snapshot of the location name at creation. The UI always trusts this field for display; it survives preset renames and deletions (Astrodex items are never deleted with a location - see LOCATIONS.md)
external_aliasesobject or absent{catalogue_key: name} alternate names captured at add-time for objects the local DSO dataset doesn’t cover (mainly stars resolved via the SIMBAD fallback, e.g. {"CommonName": "Vega", "HD": "HD 172167", "HIP": "HIP 91262"}). Used as a fallback source for the “Catalogue names” popup section - see Catalogue integration above
created_atISO 8601When the item was added to the collection
updated_atISO 8601Last modification time

Additionally, catalogue_aliases is attached to every item at read time only (never persisted) - either from the live SkyTonight DSO catalogue lookup, or, when that finds nothing, from the item’s own external_aliases. It powers the item popup’s “Catalogue names” section and the “use this name” switch action.

Picture data model

Each picture attached to an item:

FieldTypeDescription
idUUID stringUnique picture identifier
filenamestringStored filename under data/astrodex/images/
is_mainbooleanWhether this is the primary/cover picture
captionstringOptional caption or session note
owner_user_idUUIDThe user who uploaded this picture
created_atISO 8601Upload timestamp
combination_idUUID string or nullLinked equipment combination, resolved and access-checked server-side. Mutually exclusive with the free-text device/filters fields (“Other equipment” path) - never both.
combination_used_componentsobject or nullFrozen snapshot of which parts of the combination were actually used for this photo (e.g. {"telescope": true, "camera": true, "filter_ids": [...]}) - purely informational, set once at save time and never recomputed even if the combination is edited later.
ratingnumber or nullUser’s own 0.0-5.0 rating in 0.5 steps, or null if not yet rated. Feeds a combination’s average-rating badge in the Equipment tab (see EQUIPMENT.md).
exposition_timeinteger (seconds) or nullSub-exposure length. v1.3+: a validated whole number of seconds, entered through a dedicated number input - a picture created before v1.3 may still carry a free-text legacy value (e.g. "1h (10sec)"), shown as-is but never reinterpreted or accepted back from the edit form.
framesinteger or nullNumber of sub-exposures. Validated server-side (v1.3+).
integration_minutesnumber or nullTotal integration time in minutes (v1.3+). Any two of frames/exposition_time/integration_minutes determine the third - the Add/Edit Picture modal computes the one you leave blank, mirroring the Observation Log entry model’s own frame_count/sub_exposure_seconds/integration_minutes trio.
enhanced_bystring or absent"myastroshine" when this picture was produced by re-processing another one through MyAstroShine. Absent otherwise.
enhanced_atISO 8601 or absentWhen the MyAstroShine round-trip completed.
enhanced_from_picture_idUUID or absentThe source picture this one was derived from.
enhanced_parametersobject or absentThe MyAstroShine parameter dict, verbatim. Shown (pretty-printed) behind a disclosure in the Edit Photo modal.
enhanced_source_versionstring or absentMyAstroShine version that produced it.

The five enhanced_* fields are immutable - set once when the duplicate is created and absent from update_picture()’s allowed fields.

Images are stored in data/astrodex/images/ and served by GET /api/astrodex/images/<filename>.

Equipment on a picture

The Add/Edit Picture modal’s Equipment section is a single select: your enabled combinations (own + shared) plus an “Other equipment” option.

  • Picking a combination saves combination_id and reveals a checklist of that combination’s actual components (telescope / camera / guide camera / each filter) so you can mark which ones were used for this specific photo - purely optional, stored as combination_used_components.
  • Picking “Other equipment” falls back to free-text device/filters fields (with autocomplete drawn from your equipment names and previously-typed values).
  • If a picture’s linked combination is later disabled, it still appears in that picture’s edit dropdown (and stays selected) - the same “stays visible if already selected” rule the Equipment tab itself uses.
  • A combination can’t be deleted while any picture (yours or another user’s) still references it.

Storage and backup

  • Per-user collection: database tables astrodex_items and astrodex_pictures (one row per item and per picture; astrodex/<user_id>_astrodex.json inside a backup ZIP)
  • Images directory: data/astrodex/images/
  • Both are included in the Backup / Restore ZIP (see CONFIGURATION.md).

Write safety

save_user_astrodex() uses an atomic write pattern:

  1. Write to a temporary file (.json.tmp)
  2. Validate the temporary file’s JSON
  3. os.replace() - atomic rename on POSIX filesystems
  4. Delete the backup on success; restore it on failure

Per-user write locks (threading.Lock per user_id) prevent concurrent save race conditions across Gunicorn workers.


API endpoints

MethodPathAuthDescription
GET/api/astrodexloginList all items (merged across users sharing same objects)
GET/api/astrodex/maploginFlat list of geotagged pictures for the Photo Map sub-tab, gated by its own map_private flag (never strips coordinates when off)
POST/api/astrodex/itemsloginCreate a new item
GET/api/astrodex/items/<item_id>loginGet a single item
PUT/api/astrodex/items/<item_id>loginUpdate item fields
DELETE/api/astrodex/items/<item_id>loginDelete item and its pictures
POST/api/astrodex/items/<item_id>/picturesloginUpload a picture
PUT/api/astrodex/items/<item_id>/pictures/<picture_id>loginUpdate picture caption
DELETE/api/astrodex/items/<item_id>/pictures/<picture_id>loginDelete a picture
POST/api/astrodex/items/<item_id>/pictures/<picture_id>/mainloginSet picture as main
GET/api/astrodex/images/<filename>loginServe a picture file
GET/api/astrodex/check/<item_name>loginCheck if name already exists
GET/api/astrodex/collection/cataloguesloginList browsable catalogues for the Catalogue Collection sub-tab, with per-catalogue object and captured counts
GET/api/astrodex/collectionloginOne filtered/sorted/paginated page of a catalogue’s collection cards
GET/api/astrodex/constellationsloginList constellations in collection
GET/api/astrodex/catalogue-lookuploginLook up object in SkyTonight catalogue
POST/api/astrodex/items/<item_id>/catalogue-nameloginSet canonical catalogue name
POST/api/astrodex/uploadloginUpload image (alternative endpoint)

Last updated 2026-11-13

Session Analytics and Wishlist

Session Analytics (v1.5) answers two questions from data MyAstroBoard already stores: “how am I progressing?” and “how am I optimizing?”. The Wishlist that ships with it answers a third: “what do I still want to capture?”

They live as two sub-tabs of the Astrodex main tab, alongside Astrodex, Plan My Night, the Observation Log, Photo Map and Catalogue Collection - so the whole Plan -> Observe -> Log -> Astrodex loop and everything derived from it stays under one navigational roof.


Table of Contents

  1. The four analytics sections
  2. Wishlist
  3. What “best months” does and does not say
  4. What is computed, and from what
  5. Why the Observation Log is the primary source
  6. Resolving coordinates
  7. Scale directions
  8. Performance and caching
  9. API endpoints
  10. Out of scope
  11. Related documentation

The four analytics sections

1. Headline totals

Integration lifetime / this year / this month, sessions and nights, distinct objects captured, constellations, average rating, and the Astrodex collection size.

“This month” is the observer’s month: the date is resolved in the active location’s timezone, not the server’s, so a session logged at 01:30 local on the 1st lands where the user expects it.

2. Where your time goes

Integration over time (a dense monthly series - a month with no activity renders as a zero bar rather than closing up the axis), object types, constellation spread, and equipment usage.

1b. How the headline figures are rendered

Through the shared .stat-plate component (DOMUtils.buildStatPlate()), not a grid of cards - see the Headline Figures Standard in .github/instructions/copilot.instructions.md. The Astrodex and Observation Log tabs render theirs the same way.

A zero duration still carries its unit: at the hero’s size, a bare “0” under “Light collected” is the first thing a new user reads, and it does not say zero of what.

2b. Colours under the red night-vision theme

Both palettes go through _saPalette() / _saQualityPalette(), which return a single-hue red ramp when html[data-theme="red"] is in force - the categories are then separated by lightness rather than by hue. A blue or green dot is exactly the light that theme exists to keep out of a dark-adapted eye, and this page puts a large chart in front of one.

3. Sky coverage

Every captured object placed on an RA/Dec grid: right ascension on the x axis, reversed as on any sky chart, declination on the y axis. Colour by object type or by capture year.

It is drawn as a sky chart rather than as a scatter plot, so the dots have something to sit against:

  • the Milky Way is filled behind the data, from the galactic equator transformed into equatorial coordinates (J2000 pole at RA 192.85948 deg, Dec 27.12825 deg). Several faint layers at widening galactic latitudes are filled over one another, so the glow falls off instead of stepping;
  • the ecliptic is drawn dashed - the path the Sun, Moon and planets follow;
  • the celestial equator is marked at Dec 0;
  • a dot grows with the integration time behind its object, on a square root so one very long project does not flatten everything else;
  • the top axis reads the same right ascension as a month: an object culminates near local midnight when the Sun sits half a sky away from it. The reading is anchored on the equinoxes and solstices rather than on one straight line through the year, which would name the wrong month for an object near a boundary.

A shaded band marks the declinations that never rise at the active location (dec < -(90 - lat) in the northern hemisphere, mirrored in the southern), so “where have I been” is read against where the observer can actually go.

Objects whose coordinates cannot be resolved at all are counted and named, not silently dropped - the same honest accounting the Photo Map does with its ungeotagged pictures.

Everything painted on the canvas takes its colour from custom properties declared in static/css/bs_session_analytics.css (--sky-chart-*), read back with getComputedStyle at draw time: a canvas cannot carry a class, and the red night-vision theme redefines all of them. The plot area stays dark under the light theme on purpose - a sky chart reads as a window onto the sky, and inverting it would only make the objects harder to place.

4. Conditions and results

Every rated entry joined to its night’s seeing, transparency, SQM and Moon illumination, plotted as four scatters with bucket averages underneath.

This is descriptive, not predictive. Each bucket reports its own sample count next to its average, and a bucket with fewer than five samples is shown as “not enough data yet” rather than plotted. There is no trend line and no correlation coefficient: over a handful of nights, one would be noise dressed as insight.


Wishlist

A wishlist item records intent: an object you want to capture. It is the only new persistent state v1.5 introduces - everything else is derived.

Storage is the wishlist_items database table (one row per item), validated before every write like the Observation Log. Wishlists are permanently private, and they are included in the admin backup ZIP.

Two rules

  1. “Captured” is never stored. It is recomputed on every read from the Observation Log entries and the Astrodex items. A stored boolean would go stale the moment an entry is edited or a session deleted, and silently-wrong progress is worse than no progress bar.
  2. Coordinates are resolved and frozen at add time, through the resolver (see Resolving coordinates).

Behaviour

  • Deduplication is cross-catalogue: adding “NGC 224” when “M 31” is already on the list is reported as a duplicate, not added twice. Aliases count too.
  • Captured items stay on the list and are shown as done, so “12 of 20” keeps its denominator. POST /api/wishlist/archive-captured is the explicit opt-in for removing them.
  • Moving targets (planets, comets) are accepted as wishes but never get a frozen position: a fixed RA/Dec would be wrong within weeks, so they simply report no window, the same distinction the v1.4 visibility calendar draws.
  • Priorities are high / normal / low; the list can also be sorted by visibility (the default) or name.
  • Cap: MAX_WISHLIST_ITEMS = 500 per user.

Where objects are added from

SurfaceNotes
SkyTonight result tablesThrough each row’s More popup, which covers deep-sky objects, bodies and comets in one place
Catalogue Collection cardsBrowse a whole catalogue and queue what you want
Beginner Catalog cardsThe v1.1 tie-in the roadmap asks for

All three payloads carry an in_wishlist flag, computed from a preloaded index so annotating a thousand SkyTonight rows stays one file read.

The SkyTonight result tables themselves are built as HTML strings - the legacy path the project style guide says not to extend - which is why the table’s entry point is its per-row More popup rather than a new column.

Next visibility

GET /api/wishlist attaches, per item, the observable hours on the nearest sampled night and the best of the next three sampled months. That is the cheap ranking signal the default sort uses; the precise twelve-month answer stays one click away behind the v1.4 per-object visibility calendar, which every placed item links to.

?visibility=0 skips the ephemeris pass entirely for a fast render.


What “best months” does and does not say

The chart plots two clearly separate series:

  • Available - mean dark hours and moonless dark hours per night for that calendar month at the active location, computed live from the Sun and Moon. This is the site’s own nightly budget.
  • Logged - the month total the user actually recorded, across all years, plus the nights logged and mean rating.

The two are different units - a nightly average against a cumulative total - so they get separate y axes, each labelled. On one shared axis a well-used month climbs above the available darkness and reads as an impossibility.

It is not a weather statistic. MyAstroBoard stores no historical weather: every weather cache is a short-TTL forecast snapshot that is overwritten in place, and nothing accumulates a climatology. The chart therefore answers “when is it worth planning to be out here, and when have I actually been out” - never “when is it clear here”.

A weather climatology (multi-year cloud cover from an archive API, behind its own scheduler cache job) was specified and deliberately deferred; see feature.md for the full design if it is ever picked up.

If the ephemeris cannot be computed for a location, the personal half still renders and the chart says the sky figures are missing, rather than showing a half-empty chart that looks complete.


What is computed, and from what

SectionSource
Totals, monthly series, object/constellation/equipment breakdownsObservation Log sessions, nights and entries
Sky coverage mapObservation Log entries + Astrodex items, placed via the SkyTonight dataset
Conditions and resultsObservation Log entries (rating) joined to their night (seeing, transparency, SQM, Moon)
Best monthsEphemeris at the active location + the user’s own logged months
WishlistIts own per-user store, plus derived captured state

Everything is strictly self-scoped: a user only ever sees aggregates of their own log and their own Astrodex. Observation Log sessions are permanently private, so the analytics built on them are too. Read-only accounts may read every analytics route; only the wishlist’s write routes require the user role.

All the aggregation is pure: backend/observation/session_analytics.py takes already-loaded sessions and returns a payload. Loading files, resolving the request’s active location and reaching across feature packages is the blueprint layer’s job (backend/blueprints/session_analytics.py), the same split the Observation Log uses.


Why the Observation Log is the primary source

Attaching a photo to an entry is a manual, optional step, so Astrodex picture metadata covers an unknown subset of what was actually captured. Every logged entry, by contrast, carries its own frame_count / sub_exposure_seconds / integration_minutes.

So: every “hours” and “objects captured” figure comes from the Observation Log. The Astrodex collection size is reported as its own, separately-labelled figure - never folded into the logged totals - and Astrodex items are used on the sky coverage map only to cover objects that were added straight to the gallery without a session entry.

This was decided when v1.3 shipped; see docs/OBSERVATION_LOG.md.

Which figures come from where

  • integration_minutes is the one guaranteed-summable field. When it is absent but both frame_count and sub_exposure_seconds were recorded, their product is used instead.
  • An entry counts as captured when it has frames or integration time - the same threshold the Observation Log itself uses to auto-register a target in Astrodex. A planned-but-clouded-out entry is logged but is not a capture.
  • Entries are attributed to their night’s date, never to created_at: a session written up three weeks later still lands in the month it happened.
  • Distinct objects are counted on the dataset’s cross-catalogue catalogue_group_id when it resolves, so “M 31” and “NGC 224” are one object, not two.
  • Equipment hours follow the effective combination of each entry: its own override if it has one, else the session’s. Both are frozen snapshots on the records themselves, so renaming or deleting a combination never rewrites history. Entries with no equipment recorded get an explicit bucket rather than being dropped, so the hours always add up to the headline total.

Resolving coordinates

The sky coverage map and the wishlist both need numeric RA/Dec, and neither can take the stored ra/dec at face value. Plan My Night and the Observation Log copy those fields straight from whatever the client sent, and the two SkyTonight frontend paths disagree: the target cards send coordinates.ra_hours (a decimal-hours float) while the result tables send the formatted ra_hms string ("21h 31m 48.32s"). Astrodex items are worse - ra/dec are stripped on every write.

backend/observation/target_coordinates.py is the single resolver, applying one order everywhere (first hit wins):

  1. The SkyTonight dataset lookup, which carries canonical numeric ra_deg/dec_deg plus the object’s cross-catalogue group_id. Display labels such as "M31 - Andromeda Galaxy" are reduced to their identifier before the lookup.
  2. The record’s own stored values, parsed tolerantly: a number (RA in decimal hours, Dec in degrees) or a sexagesimal string in h m s, d m s, deg ' " or colon form. A negative-zero degrees field (-00 30 00) keeps its sign.
  3. Give up - and the caller counts the miss.

The wishlist stamps the resolved values onto the item at add time, so the visibility pass is a pure numeric loop that never re-learns this.


Scale directions

Three of the recorded conditions run in different directions. Every bucket the API emits is ordered best first and carries its own numeric bounds, so no consumer has to re-derive which way a scale goes:

FieldDirectionBest band
seeing1 = best .. 8 = worst (7Timer ASTRO)1-3
transparency1 = worst .. 8 = best (7Timer ASTRO)6-8
sqmhigher is darker, therefore better>= 20.5
moon_illumination_percentlower is better0-33
rating0-5 in 0.5 steps, higher is better-

A reversed seeing axis is a correctness bug, not a cosmetic one, and has its own test.


Performance and caching

No scheduler cache job is registered. The analytics are per-user aggregations over that user’s own JSON files - a few milliseconds of dict work even for a heavy log.

The only expensive part is the ephemeris, and it is cached the way v1.4 already caches this class of work, with bounded in-process LRUs:

CacheKeyWhy
Monthly dark hours(location_id, year)Changes once a year
Night contexts(location_id, date)A night’s Sun/Moon grid is identical for every target, so repeated wishlist loads do no fresh ephemeris work

That is also why the wishlist’s visibility pass scales: the grid is built once per sampled night and every target is folded through it at O(1) trig per time step, so a 500-item wishlist costs the same few grids as a single object would.


API endpoints

All routes require login and are self-scoped. Write routes additionally require the user role.

MethodRouteNotes
GET/api/session-analytics/summaryTotals, monthly series, type/constellation/equipment breakdowns, top targets. ?year= selects the “this year” bucket
GET/api/session-analytics/sky-coveragePlaced points, unplaced count and names, never-visible declination for the active location
GET/api/session-analytics/conditionsPer-entry samples plus bucketed averages with sample counts
GET/api/session-analytics/best-monthsAstronomical availability and the user’s own logged months. ?year=
GET/api/wishlistItems with derived captured state, progress counters and next visibility. ?sort=visibility|priority|name, ?visibility=0
POST/api/wishlistBody {"targets": [...]} - always a list. Returns added items plus skip counts
PATCH/api/wishlist/<item_id>priority, notes
DELETE/api/wishlist/<item_id>Remove one item
POST/api/wishlist/archive-capturedRemove every item currently derived as captured

The four analytics routes are fetched in parallel when the sub-tab opens, so the ephemeris-backed one never holds up the three that are instant.


Out of scope

  • Weather climatology - see above.
  • Per-filter breakdown of integration time. The Observation Log keeps one aggregate integration_minutes per entry (already out of scope in v1.3).
  • Cross-user or club leaderboards. Sessions are private; analytics stay self-scoped. Public profiles are a v2.1 concern.
  • A true all-sky projection for the coverage map. v1.5 ships the rectangular RA/Dec grid with the Milky Way and the ecliptic drawn on it; the projected, imagery-backed view arrives with the v2.0 sky chart rather than being built twice.
  • Named stars and constellation figures on the coverage map. The landmarks it carries are computed, not catalogued - drawing real stars means shipping a star catalogue to the browser, which belongs with the v2.0 sky chart.
  • Guiding-quality trends per equipment combination - that needs the v2.2 PHD2 import.
  • PDF export of the dashboard.

Last updated 2026-11-13

Share Cards

A share card is a ready-to-post image made from data MyAstroBoard already holds: a picture with its acquisition details, a logged session, tonight’s sky, an upcoming event. It answers the question every posted astrophoto gets - “what gear, how many subs?” - without retyping anything.

Where to find them

The share button (a share icon) opens the card:

CardWhereWhat it shows
Astrodex pictureAstrodex -> an object -> the share button under one of your picturesThe picture, uncropped, with the object name, type and constellation, date, equipment, exposures (frames x sub-exposure), total integration, and filters when they were entered as free text
Observing sessionAstrodex -> Observation Log -> a session -> share button in the toolbarDate, equipment combination, total integration, number of targets and nights, average SQM and Moon illumination, the list of targets, and the session’s first linked picture when there is one
My sky tonightSkyTonight -> “Tonight for you” card headerNight score, best imaging window and its duration, Moon phase and illumination, the three recommended targets
EventCalendar -> an event in the timelineThe event title, when it happens (in the location’s time zone), whether it is happening now, and its description

The framing simulator has its own export, see SKYTONIGHT.md.

Options

  • Format: the shapes the main image networks show without cropping. 4:5 is the default.

    FormatSizeFits
    4:51080 x 1350Portrait feed post: Instagram, Facebook, Threads
    1:11080 x 1080Square, accepted everywhere
    16:91920 x 1080Landscape: X, Facebook, forums. Picture and text sit side by side
    9:161080 x 1920Full screen: stories and reels. Not a feed format: a feed crops it
  • Theme: dark or light. The card has its own palette and looks the same whatever the application theme, the red night-vision theme included.

  • Show on the card: every fact can be unchecked before export. A fact with no value is not offered at all - a card never shows an empty or invented figure.

  • MyAstroBoard signature: a footer with the project name and address, on by default.

  • Download saves a PNG. Share appears when the browser can hand a file to the system share sheet (mostly phones) and opens it with the image.

A picture is never cropped: it is drawn whole, and the text block takes the room it needs beside or below it. Where the picture does not fill its area (a tall photo in a wide card), the bands show a blurred, darkened copy of the picture on browsers whose canvas supports filters, plain black otherwise.

Privacy

  • The card is drawn on a canvas in the browser. Nothing is uploaded; it leaves the device only when the user downloads or shares it.
  • A card never contains a location: no coordinates, no location name, no map. The card model has no field for them, and the Astrodex and Observation Log callers do not pass the ones they hold. Sky quality (SQM) and Moon illumination are the only conditions shown.
  • Pictures were already stripped of their EXIF metadata when they were uploaded, and the exported PNG is a new image with no metadata of its own.

Implementation

static/js/share_card.js holds the engine and the four card builders:

  • openShareCard(model) - the modal: preview, options, download and share. A model is a plain object: kicker, heading, subheading, an optional same-origin imageUrl, an optional hero figure, facts (each with an id so it can be unchecked), an optional list and paragraph.
  • drawShareCard(canvas, model, options, image) - the drawing. The text block is planned first (fonts shrink to fit, long lines are clamped), then the picture gets the remaining height.
  • shareAstrodexPicture, shareObservationSession, shareTonight, shareEvent - build a model from the data their caller already has. shareTonight fetches what the dashboard shows (/api/sky-widget, /api/moon/report, /api/tonight/best-window, /api/skytonight/recommendations); a part that is unavailable is left out.
  • createShareButton(onClick) - the icon button used at each entry point.

There is no backend part and no new route. Adding a card is one builder function and one button.

Last updated 2026-11-10

Equipment Profiles

The Equipment tab lets you catalogue all your astronomy gear. Profiles pre-fill fields across Astrodex, the Exposure Calculator, and the SkyTonight target scorer.

Module: backend/equipment/equipment_profiles.py


Gear types

TypeSub-tabWhat to record
TelescopeTelescopesOTA specs: focal length, aperture, type, reducer/barlow
CameraCamerasSensor specs: pixel size, resolution, QE, read noise
MountMountsPayload capacity, mount type
FilterFiltersType (LRGB, narrowband…), central wavelength, bandwidth
AccessoryAccessoriesField flattener, focuser, filter wheel, etc.
CombinationCombinationsNamed set of telescope + camera + mount + filters + accessories

Data is stored in the database: equipment_items (one row per telescope, camera, mount, filter or accessory), equipment_combinations and combination_equipment (the filters and accessories of each combination); equipments/<user_id>_<type>.json inside a backup ZIP.


Equipment combinations

A combination is a named configuration that groups a telescope, camera, mount, filters, and accessories. It is the unit used by:

  • The FOV Calculator (computes field of view for that telescope + camera pair)
  • SkyTonight (“Best Equipment For This Target” rates all your enabled and valid combinations)
  • Astrodex picture metadata (which setup was used for an image session)
  • Plan My Night combination selector (see PLAN_MY_NIGHT.md)

At least one of telescope or camera is required; both are single-value fields (max one telescope, max one imaging camera per combination). Two additional fields cover setups the base telescope+camera model doesn’t:

FieldDescription
guide_camera_idOptional second camera used purely for guiding (OAG or guide scope). Informational only - never factored into FOV or scoring math, since a guide camera never determines framing.
lens_focal_length_mm / lens_focal_ratioOptional focal length/ratio of a lens mounted directly on the camera, used only when no telescope is selected (e.g. a DSLR/mirrorless camera + lens on a star tracker for wide-field imaging). Lets a camera-only combination still be scored like a telescope+camera one.

A combination is flagged Shared only when every component in it is individually shared. If one component is later made private, the combination shows a ⚠ warning.

Photo badges

Each combination card carries two badges sourced from Astrodex:

  • Photo count - number of Astrodex pictures linking to this combination. Click it to open the same slideshow used elsewhere in Astrodex, showing every one of those pictures.
  • Average rating - mean of the 0-5 star ratings across those pictures (unrated pictures don’t count toward the average, though they do count toward the photo count).

Both respect the same visibility as the rest of Astrodex: with config['astrodex']['private'] off (default), they aggregate every visible user’s pictures for that combination; with it on, only your own.

Deletion

A combination can’t be deleted while it’s still referenced by any Astrodex picture (any user), pinned to any Plan My Night plan (any user), or referenced by any Observation Log session (any user) - the delete-guard mirrors the one on individual equipment items. This is checked in addition to the component-level guard: deleting the equipment inside a combination is blocked separately (see Disabling equipment) while deleting the combination itself is blocked by its pictures, plans or sessions (see OBSERVATION_LOG.md).


Field of View calculator

Sub-tab: Equipment -> FOV

Endpoint: POST /api/equipment/fov-calculator

Formulas

Image scale (arcsec/pixel)

The constant 206 265 arcsec ≈ 1 radian.

Field of view (degrees)

The constant 57.3° ≈ 1 radian (small-angle approximation valid for typical FOV values).

Sampling classification (Nyquist criterion)

The optimal sampling range is 2-3 pixels per FWHM of the seeing disk:

ResultCondition
Optimaloptimal_min ≤ image_scale ≤ optimal_max
Undersampledimage_scale > optimal_max - fewer than 2 px per FWHM; detail is lost
Oversampledimage_scale < optimal_min - more than 3 px per FWHM; SNR per pixel is low

Default seeing used for the classification: 2.0 arcsec (typical suburban sky). You can override this in the calculator UI.


Equipment sharing

Any telescope, camera, mount, filter, or accessory can be marked “Share with all users” in its edit form. Shared items are immediately visible to every user on the instance:

  • Shared items appear under a “Shared by Others” section labelled with the owner’s name.
  • Non-owners can read but not edit or delete.
  • Your own shared items show a Shared badge.

Shared equipment flows through the whole app:

FeatureEffect
FOV CalculatorShared telescopes & cameras appear in dropdowns
SkyTonight - Combination recommendationsRecommendations include shared combinations (own listed first)
Astrodex - Add PictureShared combinations and filters available in selectors
Plan My NightShared combinations appear in the combination selector
Exposure CalculatorShared cameras appear in the camera selector

Disabling equipment

Every gear type (Telescope, Camera, Mount, Filter, Accessory) and every Combination has an is_disabled flag, set from its own edit form.

  • Deletion is blocked while an item is still referenced by a combination (your own, or another user’s if the item is shared) - the delete request returns the combination name(s) so you know what to unlink first. Disabling is the alternative when you don’t want to fully delete something that’s still tied to a combination.
  • Disabled items are excluded from every combination editor’s dropdowns/checkboxes, unless they are already selected on the combination currently being edited - an existing combination never silently loses a component from the editor UI just because that component got disabled later.
  • Disabled items are hidden from their list tab by default. A “Show hidden (N)” toggle at the top of each tab (Telescopes, Cameras, Mounts, Filters, Accessories, Combinations) reveals them, tagged with a “Hidden” badge.
  • A combination that references a disabled (or no longer resolvable) piece of equipment is automatically flagged invalid - a computed status, independent of the combination’s own is_disabled toggle - and shown with a warning badge in the Equipment tab. Invalid/disabled combinations are not hidden or blocked from further use here; other features (SkyTonight, Plan My Night, Astrodex) are expected to start filtering on this status as each becomes combination-aware.

SkyTonight integration

For each DSO, POST /api/skytonight/combination-recommendations rates every enabled and valid equipment combination (own + shared) on a 1-5 scale:

  1. Resolves each combination’s effective optics - a telescope’s aperture/focal length/f-ratio, or for a camera-only combination (no telescope), its lens_focal_length_mm/lens_focal_ratio.
  2. Scores focal length against an ideal range derived from the target’s apparent size/type, and aperture against the target’s magnitude.
  3. When the combination’s camera has sensor dimensions, blends in a real field-of-view match (via the FOV Calculator’s formulas) against the target’s apparent size, instead of relying on the focal-length heuristic alone.

Disabled or invalid combinations (see Disabling equipment) never appear in recommendations. See SKYTONIGHT.md for the full scoring model.


Presets

File: static/data/equipment_presets.json (static asset, no backend involvement - fetched directly by the frontend)

A curated list of real-world gear (telescopes, cameras, mounts, filters, accessories) that lets users skip manual data entry for common models. Each entry has an id, a label, a manufacturer, a source_url for provenance, a suggests_experience (beginner / intermediate / advanced) used to sort the closest match to the user’s own experience level to the top, and type-specific spec fields matching the corresponding gear type table above one-to-one (e.g. a telescope preset’s type maps to telescope_type, a filter preset’s filter_type must be one of the enum values listed under Filters, etc.).

Consumed in two places:

  • The guided setup wizard’s equipment step (static/js/first_run.js) - offered alongside a manual-entry option.
  • The Equipment tab’s “New Telescope / Camera / Mount / Filter / Accessory” modals (static/js/equipment.js) - a “Start from a preset” dropdown prefills the form, which remains fully editable afterward. Only shown when adding a new item, not when editing an existing one.

To add a preset, append an entry to the relevant array in the JSON file - no code change or restart needed (it’s fetched fresh by the browser). Keep enum-like fields (type, sensor_type, mount_type, filter_type) consistent with the values each form’s <select> accepts, or the preset will silently fail to prefill that field.

Bundles (smart telescopes)

A telescope preset can declare a bundle: an array of sibling equipment items that ship built into the same physical unit - most commonly an all-in-one “smart telescope” (Seestar, Dwarf, Vespera, …) whose fixed built-in camera sensor has no other way to be represented, since a plain telescope preset only carries optical specs (aperture/focal length), not sensor data.

{
  "id": "seestar_s50",
  "...": "... normal telescope fields ...",
  "auto_combination": true,
  "bundle": [
    { "kind": "camera", "label": "Seestar S50 built-in camera (Sony IMX462)", "sensor_width_mm": 5.6, "...": "..." },
    { "kind": "mount", "label": "Seestar S50 built-in AZ mount/tripod", "mount_type": "Alt-Azimuth", "...": "..." },
    { "kind": "filter", "label": "Seestar S50 Solar Filter", "filter_type": "Solar", "...": "..." }
  ]
}
  • kind is one of camera / mount / filter / accessory; the rest of each bundle entry’s fields match that kind’s normal preset shape one-to-one (same as a standalone entry in the cameras/mounts/ filters/accessories arrays above).
  • Set weight_kg: 0 on every bundle entry - the parent telescope’s own weight_kg already represents the whole fused unit’s mass; giving bundle items their own non-zero weight would multiply-count it in a combination card’s total payload calculation.
  • auto_combination: true on the parent tells the guided setup wizard (static/js/first_run.js) to create every bundle item alongside the telescope and link them all into one auto-created combination - this is what makes “every time the wizard creates equipment, a combination is created” true even for a single-preset pick. When a bundling preset is selected, the wizard’s separate camera picker is hidden (a combination can only have one imaging camera, and the bundle already supplies it).
  • Bundle items are never independently selectable elsewhere (no separate preset dropdown entry) - they only ever get created as part of their parent telescope’s wizard flow.

Telescopes

FieldDescription
nameDisplay name (e.g. “Celestron C8”)
manufacturerBrand
telescope_typeRefractor, Apochromatic Refractor (APO), Reflector, Schmidt-Cassegrain (SCT), EdgeHD, Rowe Ackerman Schmidt Astrograph (RASA), Ritchey-Chrétien (RC), Newtonian, Maksutov-Cassegrain, Cassegrain, Dobsonian
aperture_mmClear aperture in mm
focal_length_mmNative focal length in mm
reducer_barlow_factorFocal reducer (< 1) or Barlow (> 1) factor; default 1.0
native_focal_ratioAuto-calculated: focal_length / aperture
effective_focal_lengthAuto-calculated: focal_length × reducer_barlow_factor
effective_focal_ratioAuto-calculated: effective_focal_length / aperture
weight_kgOTA weight for payload checks
is_sharedShare with all users on this instance

Cameras

FieldDescription
nameDisplay name (e.g. “ZWO ASI294MC Pro”)
sensor_typeCMOS Color, CMOS Mono, CCD Color, CCD Mono
sensor_width_mm / sensor_height_mmPhysical sensor dimensions
resolution_width_px / resolution_height_pxFull-frame pixel count
pixel_size_umPhysical pixel pitch in micrometres
sensor_diagonal_mmAuto-calculated: √(width² + height²)
read_noise_eRead noise in electrons (used by the Exposure Calculator)
quantum_efficiencyQE percentage (used by the Exposure Calculator)
cooling_supported / min_temperature_cSensor cooling information
is_sharedShare with all users

Mounts

FieldDescription
mount_typeEquatorial, Alt-Azimuth, Dobsonian, Fork Mount
payload_capacity_kgMaximum stated payload
recommended_payload_kgAuto-calculated: 75 % of max payload (safe imaging load)
tracking_accuracy_arcsecPeriodic error in arcseconds
guiding_supportedWhether the mount accepts autoguider input
meridian_flip_requiredWhether the mount flips at the meridian. Left as “Auto”, it is derived from mount_type (Equatorial -> yes; alt-az / Dobsonian / fork -> no); set it explicitly for the exceptions (a fork mount on a wedge does flip).
meridian_flip_delay_minMinutes the mount keeps tracking past the meridian before a flip is needed (0-120).
meridian_flip_duration_minDead time for the flip plus re-centre and re-guide (0-60, default 5).

The last three feed the Plan My Night meridian flip indicator - see PLAN_MY_NIGHT.md. Absent on older mount files, they are backfilled to these defaults on every read (equipment_profiles.normalize_mount_flip_fields, applied by get_mount, the mounts list endpoint and plan_my_night._resolve_plan_mount); the stored file is not rewritten.


Filters

FieldDescription
filter_typeLRGB, Narrowband, Broadband, Luminance, H-Alpha, OIII, SII, UHC, Light Pollution Reduction, Solar, Other
central_wavelength_nmCentre of the passband in nm
bandwidth_nmFull-width at half-maximum in nm
intended_usee.g. “emission nebulae”, “broadband imaging”

Accessories

Generic category for any optical or mechanical accessory not covered above: field flatteners, focusers, filter wheels, OAGs, etc.

FieldDescription
accessory_typeFree text (e.g. “Field Flattener”, “Electronic Focuser”)
weight_kgWeight for payload computation

API endpoints

MethodPathDescription
GET/api/equipment/telescopesList all telescopes (own + shared)
POST/api/equipment/telescopesCreate a telescope
GET/PUT/DELETE/api/equipment/telescopes/<id>Read / update / delete
GET/api/equipment/camerasList cameras
POST/api/equipment/camerasCreate camera
GET/PUT/DELETE/api/equipment/cameras/<id>Read / update / delete
GET/api/equipment/mountsList mounts
POST/api/equipment/mountsCreate mount
GET/PUT/DELETE/api/equipment/mounts/<id>Read / update / delete
GET/api/equipment/filtersList filters
POST/api/equipment/filtersCreate filter
GET/PUT/DELETE/api/equipment/filters/<id>Read / update / delete
GET/api/equipment/accessoriesList accessories
POST/api/equipment/accessoriesCreate accessory
GET/PUT/DELETE/api/equipment/accessories/<id>Read / update / delete
GET/api/equipment/combinationsList combinations with full analysis
POST/api/equipment/combinationsCreate combination
GET/PUT/DELETE/api/equipment/combinations/<id>Read / update / delete
POST/api/equipment/fov-calculatorCompute FOV for given telescope + camera
GET/api/equipment/summaryCount of each equipment type owned and shared

Last updated 2026-11-13

Exposure Calculator

The Exposure Calculator is a tool inside the Equipment tab that helps astrophotographers determine the optimal sub-exposure length and total number of frames for a given night.

Where to find it

Equipment -> Exposure Calc

Inputs

FieldDescriptionDefault
TelescopeSelect from your saved telescopes-
CameraSelect from your saved cameras-
Read noise (e⁻)Auto-filled from camera profile if available4 e⁻
Quantum efficiency (%)Sensor QE; 60-75% covers most modern CMOS cameras65%
Total integration (h)Planned session length3 h
Sky qualityBortle class 1 (pristine) to 9 (inner city)Bortle 5

Outputs

OutputMeaning
Plate scaleAngular size of one pixel on the sky (arcsec/px)
Sky backgroundEstimated sky photon rate hitting each pixel (e⁻/px/s)
Recommended sub-exposureMinimum exposure for sky-limited imaging
Number of subsSubs needed to fill the requested total integration time

Limitations

  • QE is assumed flat across the spectral range. In practice, QE varies with wavelength. The formula uses V-band sky flux (matching the SQM meter passband), which gives the best absolute accuracy for broadband RGB imaging.
  • Narrowband filters require different treatment: the sky background is much lower (fewer e⁻/px/s) through Ha/OIII/SII filters, so sub-exposures can and should be much longer. The calculator is primarily intended for broadband (L/RGB/OSC) imaging.
  • Dark current is not included. For cooled cameras (−10 °C or colder), dark current is negligible. For uncooled or mildly cooled cameras add dark current to B_sky if known.
  • Atmospheric extinction, light pollution gradients, and vignetting are not modelled.

Estimated minimum integration time

The Exposure Calculator sizes subs from a total integration the user supplies. The SkyTonight advanced DSO filter “max estimated integration time” needs the opposite: a rough estimate of the total integration a target needs. This is implemented in backend/equipment/exposure_math.py (estimate_min_integration_hours) and is a deliberately rough planning heuristic, not a promise.

How the estimate is computed

Working in the sky-limited regime (which the sub-exposure length from Method guarantees), the per-pixel signal-to-noise ratio after a total integration time is

where and are the per-pixel photo-electron rates (e⁻/px/s) from the target’s mean surface brightness and from the sky, both computed with the same form used for in Method. Solving for to reach a chosen per-pixel SNR target:

  • (per pixel, on the target’s mean surface brightness). This is a “detectable and processable” threshold, not “publication quality” - deep, clean images of faint targets need far more.
  • The target’s mean surface brightness comes from the catalogue integrated magnitude spread over the catalogue angular size (skytonight_calculator._surface_brightness, then converted from mag/arcmin² to mag/arcsec²). This over-estimates the required time for objects with a bright core and faint outer halo (e.g. M31, M42) - the mean over the full catalogue extent is much fainter than the part you would actually expose for.
  • Sky brightness comes from the active location’s measured SQM if set, otherwise the Bortle -> SQM table in Method.
  • Because and share the factor, aperture largely cancels: the estimate is driven by f-ratio, pixel size, sky brightness and target surface brightness - the well-known result that per-pixel SNR on extended sources depends on f-ratio, not aperture.

Limitations

  • Broadband (L / RGB / OSC) only, exactly like the sub-exposure calculator - narrowband sky rates are far lower and the estimate does not model them.
  • No QE-curve, atmospheric extinction, filter transmission, gradient or vignetting modelling.
  • The mean-surface-brightness input skews the estimate long for centrally-concentrated targets.
  • Treat the number as an order-of-magnitude planning aid (“minutes”, “a couple of hours”, “many nights”), not a target to hit.

Method

Plate scale

Sky background rate

The sky photon rate per pixel per second is computed from the observed sky surface brightness (SQM value derived from the Bortle class), the telescope aperture, and the plate scale:

Where:

  • photons/m²/s/arcsec² - Vega zero-point flux, V-band
  • - aperture in metres
  • - in arcsec/px (so is arcsec²/px)

Bortle -> SQM mapping

BortleSQM (mag/arcsec²)
122.0
221.5
321.2
420.8
520.3
619.5
718.8
818.3
917.5

Optimal sub-exposure (5× criterion)

The recommended sub-exposure is chosen so that the sky background contributes 5 times more noise variance than the read noise:

This is the standard “sky-limited” criterion used in amateur astrophotography. Exposures shorter than this threshold are read-noise dominated; longer exposures don’t improve SNR per unit time.

Number of subs

Calibration

The formula was validated against published empirical measurements:

  • ASI294MC Pro, f/7, 150 mm aperture, pixel = 4.63 µm, QE ≈ 75%, Bortle 5 (SQM 20.3)
  • Expected sky background from real data: ~0.83 e⁻/px/s
  • Formula result: ~1.0 e⁻/px/s - within the measurement uncertainty

Last updated 2026-11-13

Observatory

The Observatory tab consolidates live data from all enabled connectors into a single dashboard. The tab is hidden in the navigation bar until at least one connector is enabled in App settings -> Connectors.


The Observatory nav item is hidden by default. It appears automatically when a connector is enabled and saved. Disabling all connectors hides it again.


Layout

The panel grid adapts to which modules are active:

RowLeftRight
1Live image (full width without sensor data, 8/12 with it)Sensor data (4/12) - if enabled
2StartrailsDaily timelapse
3Keogram (full width)

Each panel only appears when its module is enabled, so a row with no enabled module is skipped entirely.


AllSky section

When the AllSky connector is enabled, the Observatory renders panels for each active module:

Live image

Auto-refreshing all-sky camera image, reloaded every 30 seconds via the MyAstroBoard proxy. If the image fails to load (AllSky offline or path misconfigured), an “unavailable” placeholder is shown and a reload is retried after 5 minutes.

When sensor data is also enabled, a day/night badge is displayed alongside the image title (sourced from AS_DAY_OR_NIGHT in the AllSky sensor data).

Sensor data

Camera and environment readings, received over MQTT from AllSky’s Publish Data to Redis/MQTT/REST/influxDB module (allsky_publishdata) and polled by the card every 60 seconds. Setup: CONNECTORS.md - Sensor data module.

Displayed fields (when present in the message):

FieldAllSky variable
Dome temperatureAS_TEMP (the environment sensor, AllSky v2026), else AS_DEWCONTROLAMBIENT (Dew Heater), else the first fan’s AS_FANS_TEMPERATURE1 (v2024: OTH_TEMPERATURE)
Camera sensor temperature (hidden when AllSky sends its 0 placeholder)AS_TEMPERATURE_C
HumidityAS_HUMIDITY, else AS_DEWCONTROLHUMIDITY
Dew pointAS_DEW, else AS_DEWCONTROLDEW
Dew heaterAS_DEWCONTROLHEATER
GainAS_GAIN
ExposureAS_EXPOSURE_US, formatted as µs / ms / s
BrightnessAS_MEAN
Fan (state, PWM duty %, threshold)AS_FANS_FAN_STATE1 / 2, AS_FANS_PWM_DUTY_PERCENT1 / 2, AS_FANS_TEMP_LIMIT1 / 2 (v2024: OTH_FANS, OTH_FANT)
Fan control temperature, only when it is not already the dome temperature (same variable or same reading)AS_FANS_TEMPERATURE1 / 2
AllSky version (v2024.12 only: v2026 cannot publish it)ALLSKY_VERSION
Updated (when the board received the readings)the message receive time

AllSky v2026 declares one environment sensor (Environment group: AS_TEMP, AS_HUMIDITY, AS_DEW, AS_TEMPSENSOR) that the Dew Heater and Fans modules reuse, so their readings repeat it: it is read first, so each quantity shows once. v2024 has a sensor per module, read as the fallback.

Keogram

Daily keogram - a horizontal timeline strip showing sky evolution throughout the night. Generated by AllSky at end of night. Displays “not yet generated” until the file exists on the AllSky server.

Startrails

Stacked startrails image for the current night. Generated by AllSky at end of night.

Daily timelapse

Full-night timelapse video (MP4) generated by AllSky at end of night, embedded as an HTML5 <video> element.


API endpoints

See API_ENDPOINTS.md - Connectors for the full list of connector endpoints used by the Observatory.

Last updated 2026-11-13

Home Assistant integration (MQTT publisher)

MyAstroBoard can publish its state to an MQTT broker, with Home Assistant MQTT Discovery: enable the connector, point it at the broker Home Assistant already uses, and a “MyAstroBoard” device appears in Home Assistant together with one device per location - sky period, night score, sun and moon times, tonight’s best window and top target, current weather-for-astro, upcoming events - and, for users who opted in, one device per user with their Astrodex counters, tonight’s plan and its equipment, observation log totals and the latest Astrodex picture.

It is publish-only: MyAstroBoard never reads commands from the broker. The only thing it subscribes to is Home Assistant’s own birth message, so entities come back by themselves after a Home Assistant restart.

Ready-made Lovelace cards live in a separate repository: lovelace-myastroboard-card.

MyAstroBoard itself can also run inside Home Assistant OS, as an app - see Installation, option 2. The MQTT integration below works the same whichever way MyAstroBoard is installed (broker host core-mosquitto when both run in Home Assistant).


Requirements

MQTT brokerAny MQTT 3.1.1 broker: the Home Assistant Mosquitto add-on, a standalone Mosquitto, EMQX… reachable from the MyAstroBoard container
Home Assistant2024.11 or newer (device-based MQTT discovery) with the MQTT integration configured and discovery enabled (the default)
MyAstroBoard1.6.0 or newer, an admin account

Setup

  1. Broker. Create a user for MyAstroBoard on the broker (Mosquitto add-on: Settings -> Add-ons -> Mosquitto -> Configuration, or a Home Assistant user - the add-on accepts those). Note the broker’s IP address and port (1883, or 8883 for TLS).
  2. MQTT connection. App settings -> Configuration -> MQTT connections -> Add a connection (see MQTT connections):
    • Display name: any name, shown in the connector’s picker.
    • Broker URL: mqtt://192.168.1.10:1883 (or mqtts://host:8883 for TLS). Use the IP address rather than a .local name - mDNS does not resolve inside the container.
    • Username / Password: the broker user. Leave both blank for an anonymous broker.
    • Press the test button (wifi icon): it opens one real connection to the broker with the settings as typed and reports Reachable or the reason it is not (refused, timeout, not authorized, TLS…). Save.
  3. Connector. App settings -> Connectors -> MQTT / Home Assistant -> Configure:
    • MQTT connection: pick the connection created above (the wifi button tests it).
    • Tick the modules you want (see Modules), switch Enable connector on, Save.
  4. Home Assistant. Settings -> Devices & services -> MQTT: within a minute a MyAstroBoard device and one MyAstroBoard - <location> device per location preset appear. The card’s status line shows Connected, the last publish time and the number of devices and entities.

Nothing has to be added to configuration.yaml.

Per-user activity

Location and board data are install-level and published as soon as the connector is on. A user’s data (Astrodex, plan, observation log, pictures) is private, so it is published only when that user asks for it: My settings -> Customize -> Publish my activity to Home Assistant (MQTT). The switch only shows once an admin has enabled the connector, and it only ever covers the user’s own data - an admin cannot publish another user’s activity. The admin-side User activity and Latest Astrodex picture modules must be on as well.


Modules

ModuleDefaultDeviceWhat it publishes
Sky conditionsonper locationsky period, night score, sun and moon times, dark window, best window, top target, SkyTonight night window
Weather nowonper locationcurrent weather for astrophotography: temperature, humidity, dew point, clouds by layer, wind, precipitation, pressure, visibility, plus seeing, transparency, limiting magnitude, dew risk, tracking impact, weather alert
Upcoming eventsoffper locationnext event, next ISS / CSS pass, aurora Kp and probability, next solar / lunar eclipse
User activityoffper opted-in userAstrodex counters, plan state and progress, current and next target, active equipment, observation log totals
Latest Astrodex pictureoffper opted-in userthe newest Astrodex picture as an image entity
Board diagnosticsonboardversion, update entity, cache readiness, SkyTonight scheduler state, last publish, published counts

Every location the cache scheduler keeps warm gets a device (in practice every preset). Disable the entities you do not need from the device page in Home Assistant.

Settings

FieldDefaultNotes
MQTT connection-One of the MQTT connections: the broker URL, username, password and self-signed certificate switch belong to it
Home Assistant MQTT DiscoveryonOff = only the plain state topics are published (for another consumer than Home Assistant)
Base topic (advanced)myastroboardRoot of every topic; two boards on one broker use two base topics
Discovery prefix (advanced)homeassistantMust match the MQTT integration’s discovery prefix
Publish interval (advanced)60 sMinimum 15 s. States are only sent when they changed; everything is resent every 30 min and whenever Home Assistant restarts
Client ID (advanced)generatedmyastroboard-<8 hex>, generated once and reused across restarts. Must differ from any other connector on the same connection

MQTT connections

A broker is declared once, in App settings -> Configuration -> MQTT connections, and each MQTT connector picks one. Several connectors can share a connection: each one opens its own client, which is why two connectors on one connection cannot be saved with the same client ID (a broker keeps one session per client ID and would disconnect them in turn).

FieldDefaultNotes
Display name-Unique; shown in the connectors’ picker
Broker URL-mqtt://host[:1883] or mqtts://host[:8883]
Username / PasswordblankThe password is stored in the connector secrets store, outside the configuration, and is never part of a backup or config export: re-enter it after restoring a backup on another machine. Clearing the username drops the password
Accept self-signed certificatesoffmqtts:// only - skips certificate verification for a LAN broker with its own CA

The list shows, for each connection, whether the broker answers (checked when the page opens and with the wifi button) and which connectors use it. A connection still picked by a connector cannot be deleted: pick another one in that connector first. Editing a connection makes every connector using it reconnect.

Upgrading from 1.7.1 or earlier: a configured connector’s broker becomes a connection named Home Assistant, already picked by the connector, with its password - nothing to re-enter.

Buttons

  • Publish now - full republish of discovery and every state on the next tick (a few seconds).
  • Remove from Home Assistant - switches the connector off and deletes every retained MyAstroBoard topic from the broker; Home Assistant drops the devices (their history is lost). Broker settings and modules are kept, so enabling the connector again recreates everything. Simply switching the connector off, by contrast, leaves the devices in place as unavailable. See Long-term statistics for the one thing a removal leaves behind.

Long-term statistics

Home Assistant records long-term statistics for every sensor that declares a state_class, and keeps them after the entity disappears. Only the six counters that track your own progress declare one (state_class: total): Astrodex objects, objects with pictures, pictures, constellations, observation sessions and total integration - the numbers a Statistics graph card can show growing month after month. Everything else (sky, Moon, best window, weather, aurora, plan progress) is an ephemeris or a forecast that MyAstroBoard recomputes at any time, so it deliberately records no long-term statistics; the recorder’s short-term history (10 days by default) still draws them in a history graph.

After Remove from Home Assistant (or a user opting out), those six series per user stay in the recorder as orphans. Home Assistant lists them under Developer tools -> Statistics with a Fix issue action that deletes them; Spook reports the same list. Nothing else is left behind.


Automation ideas

# Red observatory light at astronomical dusk
trigger:
  - platform: state
    entity_id: sensor.myastroboard_backyard_sky_period
    to: astronomical_night
action:
  - service: light.turn_on
    target: { entity_id: light.observatory }
    data: { rgb_color: [255, 0, 0], brightness: 40 }

# Tonight looks good
trigger:
  - platform: numeric_state
    entity_id: sensor.myastroboard_backyard_observation_score
    above: 7
condition:
  - condition: state
    entity_id: binary_sensor.myastroboard_backyard_astronomical_night
    state: "on"
action:
  - service: notify.mobile_app_phone
    data:
      message: "Night score {{ states('sensor.myastroboard_backyard_observation_score') }} - top target {{ states('sensor.myastroboard_backyard_top_target_tonight') }}"

Privacy and security

  • Outbound only. No command topic exists; nothing Home Assistant publishes is acted upon.
  • The password lives in the connector secrets store, never in the configuration, backups, the config export or any API response (the connection list only sees a mask).
  • The connection test never pairs the stored password with a URL that differs from the saved one, and the MQTT and MQTT connection routes are admin-only.
  • mqtts:// gives TLS; Accept self-signed certificates is meant for a LAN broker with its own CA, nothing else.
  • A user’s data is published only while that user’s own switch is on. Location devices carry the preset name and timezone, not its coordinates; pictures are published without location data.

Troubleshooting

SymptomLikely causeFix
Test says connection refusedwrong IP / port, or the broker only listens on localhostuse the LAN IP, check the broker’s listener (0.0.0.0:1883)
Test says not authorizedwrong user / password, or the broker requires onecreate a broker user, re-enter the password
Test says TLS handshake failedself-signed certificatemqtts:// + Accept self-signed certificates, or use mqtt:// on the LAN
Status shows Connected but no device in Home Assistantdiscovery off, or the discovery prefix differs from the MQTT integration’stick Home Assistant MQTT Discovery, align the prefix (homeassistant by default)
Devices are unavailablethe connector is off or the container is downenable the connector / check the card’s status line and data/myastroboard.log
No user devicethe user has not opted in, or the User activity module is offMy settings -> Customize -> Publish my activity…; module on
No picture entityLatest Astrodex picture module off, no picture yet, or the picture exceeds 1 MB even at 640 pxsee the log line; the entity appears after the next picture is added
Values show unknownthe cache behind them is not warm yet (fresh install, new location)wait for the cache scheduler’s next cycle
Two boards on one broker fight over devicessame base topicgive each board its own base topic
Stale devices after renaming the base topicold retained topicsRemove from Home Assistant before renaming, or delete the topics with mosquitto_sub/your broker’s tools

Log lines are prefixed MQTT publisher: in data/myastroboard.log.

Tested against Home Assistant 2024.11+ discovery format; re-check the release notes of the Home Assistant version you run if entities fail to appear.


Entities

Home Assistant builds each entity id from the device name and the entity name (never from the JSON key): the Top target tonight sensor of the “MyAstroBoard - Backyard” device is sensor.myastroboard_backyard_top_target_tonight, the Astronomical night binary sensor is binary_sensor.myastroboard_backyard_astronomical_night, the picture is image.myastroboard_alice_latest_astrodex_picture. unique_id is <base>_loc_<location_id>_<key>, so renaming a preset later keeps the entity history. The tables below are generated from backend/connectors/mqtt_payloads.py.

Board device (Board diagnostics)

KeyEntity nameKindNotes
versionVersionsensordiagnostic
updateUpdateupdatediagnostic - installed/latest version, links to the changelog; no Install button (no remote self-update)
caches_readyCaches readybinary sensordiagnostic
skytonight_runningSkyTonight calculatingbinary sensor (running)diagnostic
skytonight_last_runSkyTonight last runsensor (timestamp)diagnostic
skytonight_next_runSkyTonight next runsensor (timestamp)diagnostic
last_publishLast publishsensor (timestamp)updated every publish cycle - a heartbeat; diagnostic
locations_publishedLocations publishedsensordiagnostic
users_publishedUsers publishedsensordiagnostic

Location device - Sky conditions

KeyEntity nameKindNotes
sky_periodSky periodsensor (enum)day, civil_twilight, nautical_twilight, astronomical_twilight, astronomical_night, unknown
next_periodNext sky periodsensor (enum)
next_period_atNext sky period atsensor (timestamp)
is_astronomical_nightAstronomical nightbinary sensor
observation_scoreObservation scoresensorthe app-wide night score (same value as the sky widget)
sunriseSunrisesensor (timestamp)
sunsetSunsetsensor (timestamp)
civil_duskCivil dusksensor (timestamp)
civil_dawnCivil dawnsensor (timestamp)
nautical_duskNautical dusksensor (timestamp)
nautical_dawnNautical dawnsensor (timestamp)
astronomical_duskAstronomical dusksensor (timestamp)
astronomical_dawnAstronomical dawnsensor (timestamp)
true_night_hoursTrue night durationsensor (duration, h)
moon_phaseMoon phasesensor
moon_illuminationMoon illuminationsensor (%)
moon_altitudeMoon altitudesensor (°)
moon_azimuthMoon azimuthsensor (°)
moon_distanceMoon distancesensor (distance, km)
next_moonriseNext moonrisesensor (timestamp)
next_moonsetNext moonsetsensor (timestamp)
next_full_moonNext full moonsensor (timestamp)
next_new_moonNext new moonsensor (timestamp)
dark_window_startDark window startsensor (timestamp)next moonless astronomical night
dark_window_endDark window endsensor (timestamp)
best_window_startBest window startsensor (timestamp)the practical best window
best_window_endBest window endsensor (timestamp)
best_window_durationBest window durationsensor (duration, h)
best_window_scoreBest window scoresensor
best_window_moonBest window moon conditionsensor
top_targetTop target tonightsensorattributes: type, constellation, magnitude, max altitude, observable hours, top_targets (top 5)
top_target_scoreTop target AstroScoresensor
night_startSkyTonight night startsensor (timestamp)
night_endSkyTonight night endsensor (timestamp)
skytonight_calculated_atSkyTonight calculated atsensor (timestamp)diagnostic

Location device - Weather now

Nearest hourly forecast row plus the astro analysis.

KeyEntity nameKindNotes
temperatureTemperaturesensor (temperature, °C)
dew_pointDew pointsensor (temperature, °C)
humidityHumiditysensor (humidity, %)
cloud_coverCloud coversensor (%)
cloud_cover_lowLow cloudssensor (%)
cloud_cover_midMid cloudssensor (%)
cloud_cover_highHigh cloudssensor (%)
wind_speedWind speedsensor (wind_speed, km/h)
wind_directionWind directionsensor (°)
precipitation_probabilityPrecipitation probabilitysensor (%)
precipitationPrecipitationsensor (precipitation, mm)
pressurePressuresensor (atmospheric_pressure, hPa)
visibilityVisibilitysensor (distance, m)
weather_codeWeather codesensorWMO weather code
seeingSeeingsensorPickering scale
transparencyTransparencysensor
limiting_magnitudeLimiting magnitudesensor
dew_riskDew risksensor (enum)LOW, MODERATE, HIGH, CRITICAL, UNKNOWN
dew_point_spreadDew point spreadsensor (temperature, °C)
wind_tracking_impactWind tracking impactsensor (enum)NONE, LOW, MODERATE, HIGH, SEVERE, UNKNOWN
tracking_stabilityTracking stabilitysensor
weather_alertWeather alertsensorfirst alert; attributes: all alerts
forecast_updated_atForecast updated atsensor (timestamp)diagnostic

Location device - Upcoming events

KeyEntity nameKindNotes
next_eventNext eventsensorsame “next event” as the dashboard banner; attributes: event_type, description, start / peak / end, importance, events_count, plus the event’s raw (untranslated) variable piece where applicable - eclipse_type (solar/lunar eclipses), planet/planet2 (planetary conjunction/opposition/elongation/retrograde, moon conjunction), shower_name (meteor showers), comet_name (comet appearances); state and description are English-only by design (MQTT is a background publish loop with no session language to read) - clients that want a localized display should translate client-side off event_type and these attributes, the same way top_target_attributes.object_type already needs to be
next_event_atNext event atsensor (timestamp)
next_iss_pass_atNext ISS passsensor (timestamp)
next_iss_pass_peak_altitudeNext ISS pass peak altitudesensor (°)
next_iss_pass_durationNext ISS pass durationsensor (duration, min)
next_iss_pass_scoreNext ISS pass visibility scoresensor
next_css_pass_atNext CSS passsensor (timestamp)
next_css_pass_peak_altitudeNext CSS pass peak altitudesensor (°)
next_css_pass_durationNext CSS pass durationsensor (duration, min)
next_css_pass_scoreNext CSS pass visibility scoresensor
aurora_kpAurora Kp indexsensor
aurora_probabilityAurora probabilitysensor (%)
aurora_visibilityAurora visibilitysensor
next_solar_eclipse_atNext solar eclipsesensor (timestamp)attributes: type, obscuration, start, end, visible
next_lunar_eclipse_atNext lunar eclipsesensor (timestamp)attributes: type, start, end, visible

User device - User activity

KeyEntity nameKindNotes
astrodex_objectsAstrodex objectssensor
astrodex_objects_with_picturesAstrodex objects with picturessensor
astrodex_picturesAstrodex picturessensor
astrodex_constellationsAstrodex constellationssensor
astrodex_last_picture_atLast Astrodex picturesensor (timestamp)
astrodex_last_picture_objectLast Astrodex picture objectsensorattributes: catalogue, type, constellation, date, equipment, rating
plan_statePlan statesensor (enum)none, current, previous
plan_activePlan in progressbinary sensor (running)the plan’s night is in progress
plan_progressPlan progresssensor (%)
plan_current_targetPlan current targetsensorattributes: catalogue, type, planned minutes, slot start / end, visibility, meridian flip
plan_next_targetPlan next targetsensor
plan_night_startPlan night startsensor (timestamp)
plan_night_endPlan night endsensor (timestamp)
plan_targets_totalPlan targetssensor
plan_targets_donePlan targets donesensor
plan_locationPlan locationsensor
active_equipmentActive equipmentsensorthe plan’s combination; attributes: telescope, camera, guide camera, mount, filters, accessories, focal length, aperture, disabled
sessions_totalObservation sessionssensor
integration_hours_totalTotal integrationsensor (duration, h)observation log
last_session_dateLast observation sessionsensor (date)

User device - Latest Astrodex picture

KeyEntity nameKindNotes
latest_pictureLatest Astrodex pictureimageraw JPEG resized to 1280 px / 1 MB max; attributes: object, catalogue, date, equipment, rating, picture id. Picture coordinates are never published

Topics

<base> is the base topic (myastroboard by default), <prefix> the discovery prefix.

TopicPayloadRetained
<base>/statusonline / offline (last will) - the availability of every entityyes
<base>/board/stateJSON, board diagnosticsyes
<base>/location/<location_id>/stateJSON, one object with every enabled location module’s keysyes
<base>/user/<user_id>/stateJSON, user activity keysyes
<base>/user/<user_id>/astrodex/latest_imageraw JPEG (max 1280 px, max 1 MB)yes
<prefix>/device/<base>_board/configdevice discovery messageyes
<prefix>/device/<base>_loc_<location_id>/configdevice discovery messageyes
<prefix>/device/<base>_user_<user_id>/configdevice discovery messageyes

Every timestamp is ISO 8601 with a UTC offset; an unknown value is JSON null (the entity shows unknown). A location device carries location_id, location_name and timezone next to the entity keys; a user device carries user_id and username.

The MQTT integration’s own homeassistant/status topic is the connector’s only subscription: online triggers a full republish.

Last updated 2026-11-13

Astrodex Stream connector

A personal, auto-refreshing photo slideshow of your Astrodex pictures - cropped to the format you choose, with a discreet object-name-and-date banner - that shows up as a live camera in Home Assistant (or any still-image viewer) without exposing your photos to anyone who doesn’t have your URL.


Where to find it

  • Setup: App settings -> Connectors -> Astrodex Stream card
  • Your URL: Astrodex -> the small icon next to the page title, once the connector is enabled

Setup

App settings -> Connectors -> Astrodex Stream -> Configure:

FieldNotes
Display duration per photoSeconds each photo stays “current”. Keep it generous (tens of seconds) - see the note below about Home Assistant’s own refresh rate.
FormatCenter-crop applied to every photo before it is served: 16:9 / 9:16 / 4:3 / 3:4 / 1:1.
Enable connectorTurns the feature on for every user - there is nothing else to install (no URL, no credentials).

There is nothing to type beyond these two fields: no URL, no username, no password. The per-user signing key that protects each stream is generated automatically the first time it is needed (see Security).

Shuffling

Photos are not shown in a fixed, repeating order. Which photo is “current” in a given slot is a pseudo-random - but fully reproducible - function of the slot’s position in time: every viewer polling at the same moment sees the same photo, without the server keeping track of “where we are” for anyone. The same photo never repeats twice back-to-back. There is nothing to configure here; it is always on.


Using the URLs

Astrodex -> the icon next to the page title opens a modal with:

  • Your personal stream URL - always shown once the connector is enabled. Shows only your own photos. Treat it like a password: anyone who has the URL can view your stream, so it is not shown anywhere else in the interface.
  • The shared stream URL - shown only when Astrodex is not set to private (App settings -> Configuration). Shows every user’s photos merged, GPS coordinates stripped, with an owner-name in the banner so a shared viewer (e.g. an astronomy club’s screen) can tell whose photo is on display.

Both are plain JPEG URLs (.../current.jpg) - paste either into a browser tab to see it work before wiring up Home Assistant.


Using it in Home Assistant

No custom Lovelace card is needed - Home Assistant’s built-in Generic Camera integration already does this:

  1. Settings -> Devices & services -> Add integration -> Generic Camera.
  2. Still Image URL: paste the URL from the Astrodex modal, exactly as copied. The signing token is already embedded in it - no username or password field to fill in, unlike a typical camera setup.
  3. Leave Stream URL empty - this connector does not serve RTSP (see Why not a real video stream).
  4. Add the resulting camera entity to a dashboard (picture-entity, picture-glance, glance…).

Home Assistant - not this connector - decides how often it re-fetches the still image. Set Display duration per photo generously (tens of seconds, not two or three) so a typical refresh cadence reliably lands on each photo instead of mostly skipping between them.


Troubleshooting

SymptomLikely cause
The icon does not appear on the Astrodex pageThe connector is not enabled - App settings -> Connectors -> Astrodex Stream.
The modal has no shared URLAstrodex is set to private (App settings -> Configuration) - the personal URL still works.
The image shows a dark placeholder instead of a photoThe user has no eligible pictures yet (own photos for the personal URL, any visible photo for the shared one).
Home Assistant’s camera stopped updating after a “Rotate keys”Expected - re-add the camera in Home Assistant with the new URL from the Astrodex modal.
Photos change less often than expected in Home AssistantHome Assistant polls a still image on its own schedule; it is not pushed. Increase Display duration per photo rather than expecting frame-accurate sync.

Security

  • One token per user, nothing stored per user. A stream URL embeds HMAC-SHA256(user_id, signing_secret). The signing secret is 32 random bytes generated once on first use and kept in the connector secrets store (excluded from backups, like the MQTT password and the MyAstroShine token) - never shown to or entered by an admin. Verifying a request just recomputes the token from the user id in the URL, so nothing is stored per user and a lost/rotated token needs no cleanup.
  • A club’s members cannot read each other’s streams. The token is specific to one user_id; swapping the id in a URL without the matching token is rejected (404, not 403 - an invalid URL must not confirm that a given user id even exists).
  • The shared URL respects “private” live, not at mint time. Whether .../shared/... serves anything is re-checked against Astrodex’s private-mode setting on every request. Turning private mode on immediately 404s the shared URL for anyone who still has it - no key rotation needed for that case.
  • Rotating keys. If a personal URL leaks, the card’s Rotate keys action (POST /api/connectors/astrodex_stream/rotate) issues a new signing secret, which invalidates every stream URL at once (personal and shared) - there is no way to rotate a single user’s key without affecting everyone, since nothing per-user is stored to make that possible. Anyone with a camera already configured (Home Assistant included) needs the new URL afterwards.
  • The token does not expire, by design - it is meant to be pasted into Home Assistant once and keep working indefinitely, unlike MyAstroShine’s 12-hour, single-use handoff token. That makes the URL a long-lived bearer secret: treat it exactly like a password, never paste it anywhere other than the camera integration’s own field. This matters most for an install reachable from the internet (a reverse-proxied public domain, not just a LAN): the URL is not brute-forceable (128 bits of entropy in the token), but it will appear in plaintext in any access log along the request path - this app’s own log, a reverse proxy’s, or a CDN/WAF’s in front of it, exactly like a Stripe checkout link or an AWS presigned URL would. If a URL is ever visible somewhere it shouldn’t be (a shared log, a screen share, a pasted browser history), rotate keys.
  • No EXIF/GPS leak in the served image. A photo’s original file may carry embedded EXIF GPS coordinates (common for phone/camera photos) - the JSON-level latitude/longitude fields are already stripped for the shared/merged Astrodex view, and the same applies here: the rendering pipeline re-encodes every frame through Pillow without forwarding the source file’s EXIF block, so the served JPEG carries no metadata beyond what the banner already shows on its face (object name, date, and - shared feed only - the owner’s username).

Why not a real video stream

The original idea was an RTSP stream. That does not fit this project’s zero-manual-Docker-config promise: RTSP needs a real media server (ffmpeg + something like MediaMTX) listening on a new port, and Docker port mappings are fixed at container creation - nothing running inside the container can publish a new port to the host afterwards, no matter how that server is started or stopped. The only way to reconcile RTSP with “nothing to configure” would be to reserve that port in the official docker-compose.yml for every install, whether the feature is used or not - rejected as a permanent cost for an optional feature.

A continuously pushed MJPEG stream avoids the new-port problem (it can ride the existing HTTP port), but the app runs gunicorn with synchronous workers and no thread pool: a long-lived streaming connection would pin a worker for as long as it stays open, and two simultaneous viewers (a browser tab plus one Home Assistant card is a very ordinary case) would starve the whole app for everyone else.

So instead the server renders whichever photo is “current” as a pure function of wall-clock time, and a client just polls the same URL periodically, exactly like AllSky’s live_image module already does for an external camera. No open connection, no new port, no background thread, no ffmpeg. Home Assistant’s built-in Generic Camera integration does exactly this kind of polling natively - see Using it in Home Assistant.

No crossfade between photos, on purpose. A blended transition was considered and dropped: a client that happens to poll mid-transition would receive one half-blended frame and then keep displaying it, unchanged, until its own next poll - which can be much later than the transition itself. The result would be a broken-looking static image sitting on the dashboard for a long stretch, not a smooth fade. Every served frame is always one full, clean photo; only which photo changes between slots (see Shuffling above).

Last updated 2026-11-13

MyAstroShine integration

Send a photo from your Astrodex to MyAstroShine for re-processing, then bring the enhanced result back as a new picture on the same object - the original is never touched.


Where to find it

  • Setup: App settings -> Connectors -> MyAstroShine card
  • Use: Astrodex -> open an object -> the photo action row shows a Send to MyAstroShine button next to Edit / Delete, once the integration is configured and enabled.

Setup

Minimum version: MyAstroShine v0.4.0 - older releases do not implement the pull + webhook round-trip (see The round trip). The connector card shows this requirement as a Requires v0.4.0 line; it is informational, not enforced, since MyAstroShine only reports its own version once a handoff completes (stored per picture as enhanced_source_version).

That version, and everything else the card shows, is declared on MyAstroShineConnector (backend/connectors/myastroshine_connector.py) - a BaseConnector in the REGISTRY, listed by GET /api/connectors and saved through POST /api/connectors/myastroshine/config like any other connector. See CONNECTORS.md.

The token and signing secret are declared as SECRET_FIELDS, so the listing masks them and a blank submission keeps the stored value: they are never sent to the browser.

1. Create a token in MyAstroShine

MyAstroShine -> Settings -> Tokens -> New. You get a token (mas_...) and a signing secret (64 hex chars), shown once.

2. Fill in the connector card

FieldNotes
Display labelOptional, defaults to “MyAstroShine”
MyAstroShine base URLWhat the browser opens, e.g. http://192.168.1.42:8002. Use a static LAN IP, not a .local name. If you open the board from outside your LAN the MyAstroShine tab will not load - that is expected, MyAstroShine is LAN-only.
Token / Signing secretPaste both from step 1. Stored in the connector secrets store (never in the configuration or a backup), masked in every API response, “blank = keep current” on save.
Callback URL override (advanced)Only if the MyAstroShine container cannot reach this dashboard’s public URL on its own (no NAT hair-pinning). e.g. http://192.168.1.42:5000, or the board’s service name on a shared Docker network. When set, it wins over the URL derived from the reverse-proxy headers.
Copy the source photo’s ratingOff by default - a re-processed image is a new artifact to re-judge.
Enable connectorThe button and endpoints activate only when enabled and URL + token + signing secret are all set.

3. Allow the callback on the MyAstroShine side

The board builds the callback URL from X-Forwarded-Proto / X-Forwarded-Host (so App settings -> Configuration -> Reverse proxy must be on when behind a proxy), or from the override field. That URL - the public one and the override if you use one - must be in MyAstroShine’s astrodex_callback_urls allowlist.

Test button

The button probes <url>/api/health from the backend, best effort. “Unreachable” is normal when the board is not on the same network as MyAstroShine - the button is only a convenience for the all-in-one-LAN case. Loopback / link-local / unspecified / multicast targets are refused (SSRF hardening).


The round trip

Browser  --POST /api/astrodex/integration/handoff-->  Board        mints a signed token
Browser  --window.open(<shine>/#/?handoff=<token>)-->  MyAstroShine resumes an edit session
Shine    --GET  /source?handoff=...              -->   Board        picture metadata
Shine    --GET  /source/image?handoff=...        -->   Board        source image bytes
   ... user edits in MyAstroShine ...
Shine    --POST /enhanced  (multipart + signature)-->  Board        new duplicated picture (201)

Handoff token

base64url(payload) + "." + base64url(HMAC_SHA256(base64url(payload), signing_secret))

  • base64url is unpadded; the HMAC is taken over the base64url payload string, and the signature is the raw digest base64url-encoded (not hex).
  • Payload: { kid, callback_base, item_id, picture_id, user_id, iat, exp, jti }. kid = the first 12 chars of the token. callback_base is set by the board only (never user input) - it is still re-checked against MyAstroShine’s allowlist before any callback.
  • TTL 12 h (MyAstroShineConnector.HANDOFF_TTL_SECONDS) - long enough for a full evening editing session. Single use: the jti is marked spent when /enhanced succeeds, so a replay is rejected with 409. The spent-jti set is a database setting, checked and updated in one transaction, so a replay is refused by every worker and across restarts.

Enhanced upload signature

multipart/form-data with handoff, payload (JSON: { parameters, myastroshine_version, note? }) and image (JPEG). Header:

X-Webhook-Signature: sha256=<hex>
signing_input = canonical_json(payload) + "\n" + sha256_hex(image_bytes)

canonical_json = json.dumps(obj, separators=(",", ":"), sort_keys=True) - identical to MyAstroShine’s app/services/astrodex_integration.py:canonical_json. The image hash is bound into the signature instead of the (large) blob itself.

The handoff signature is verified first, in constant time, before any disk access. The three cookieless endpoints are rate-limited per client IP.


The duplicated picture

The board copies the frozen snapshot of the source picture’s metadata (date, exposition_time, frames, integration_minutes, iso, device, filters, notes, location fields, combination_id, combination_used_components) onto the new picture, swaps in the enhanced image file, and stamps immutable provenance fields:

FieldValue
enhanced_by"myastroshine"
enhanced_atISO 8601 timestamp of the round-trip
enhanced_from_picture_idthe source picture’s id
enhanced_parametersthe MyAstroShine parameter dict, verbatim
enhanced_source_versionMyAstroShine version string

These are not editable - they are absent from update_picture()’s allowed fields.

  • is_main is always False - the item already has the source picture, and promotion stays a manual choice.
  • rating is copied only when Copy the source photo’s rating is on.
  • The Edit Photo modal shows a discreet “Re-processed with MyAstroShine on <date>” line with a view settings disclosure that pretty-prints enhanced_parameters.
  • The board gets no push when the enhanced picture arrives (MyAstroShine calls back server-to-server). When the Astrodex browser tab regains focus after a send, it re-fetches and re-renders the open item, and toasts “Enhanced photo received from MyAstroShine” - no manual reload needed.

Security summary

  • source / source/image / enhanced: no session cookie; handoff signature checked first, in constant time; rate-limited; upload size capped at MyAstroShineConnector.MAX_IMAGE_BYTES (50 MB).
  • callback_base is board-set and re-verified against MyAstroShine’s allowlist.
  • The handoff pins user_id + item_id + picture_id, so a return can only ever write into that user’s Astrodex, on that item. All three are re-checked to a strict uuid shape at every entry point (defence in depth on top of the signature) before the user’s Astrodex is read.
  • Enhanced image is confined to data/astrodex/images/ with the same realpath barrier as the normal upload path.
  • token / signing_secret live in the connector secrets store (outside the configuration and outside the backup ZIP - re-enter them after restoring a backup on another machine), are masked in API responses, and “blank = keep”.
  • No new CSP connect-src entry and no CORS: the browser never makes a cross-origin request.

Why “pull + webhook” and not “push”

MyAstroShine always runs on the local network. MyAstroBoard might not - it can sit on a VPS behind a reverse proxy. The only server-to-server direction that is always reachable is MyAstroShine -> MyAstroBoard (the board’s public URL, or its LAN IP). So:

StepCarried byDirection
Open MyAstroShine with the source photothe browser (window.open)browser -> shine
Read the source image + metadataMyAstroShine containershine -> board
Send the enhanced image homeMyAstroShine containershine -> board

The browser makes no cross-origin XHR, so there is nothing to add to the board’s CSP and no CORS to open on MyAstroShine.


A connector, with two particularities

This is a BaseConnector like AllSky - in the REGISTRY, listed by GET /api/connectors, stored under config.connectors.myastroshine, and rendered by the same card. Two things set it apart, both declared rather than special-cased:

  • It is bidirectional. The browser hands a photo to MyAstroShine and the MyAstroShine container posts the enhanced result back, so it authenticates - hence SECRET_FIELDS and the cookieless callback routes in backend/blueprints/connectors_myastroshine.py (logic in backend/observation/myastroshine_integration.py).
  • It surfaces in the Astrodex, not the Observatory - target_modules = ["astrodex"], and it has no Observatory panel.

Last updated 2026-11-13

📖 Installation Guide

This guide covers all methods of installing and deploying MyAstroBoard.

Prerequisites

Required

  • Docker
  • Docker Compose
  • Internet connection (for downloading images and target catalogues)

Running Home Assistant OS? You can skip Docker entirely and install MyAstroBoard as a Home Assistant app - see Option 2.

System requirements

ResourceMinimumRecommended
Memory available to the container1 GB2 GB, or more when other services share the host
CPU1 core (vCPU)2 cores
Diskabout 1 GB for the image, plus your data (pictures, attachments)
Architectureamd64 (x86-64, no x86-64-v2 needed) or arm64

Where the memory figure comes from: on a fresh install of the 1.8.2 image (two gunicorn workers), memory grows to about 720 MB once the first SkyTonight calculation has run, and stays there. Under a 512 MB limit without swap, a worker was killed for lack of memory during that calculation. The browser then sees failed requests: an action reports an error although it went through on the server, or the offline page appears. The 2 GB and 2-core recommendations are margins, not measurements.

The CPU mostly sets how long the calculations take. Requests stay fast on small CPUs (a quarter of a core still created an account in under half a second), but the first SkyTonight run after startup takes longer.

Keep the data directory on a local disk. SQLite cannot use its WAL mode on some network shares (NFS, SMB), which makes writes slow; the application log says so at startup.

At startup each worker writes a Host: line to the application log with the CPUs, memory (and the container limits, if any), swap and the file system of the data directory, and a warning when the memory is below 1 GB. Include it when you report an issue. The same memory warning is shown to administrators in App settings -> Metrics.

Installation

  1. Create and move in the directory you want install this image
# Example
mkdir /opt/myastroboard
cd /opt/myastroboard
  1. Download the docker-compose file:
curl -o docker-compose.yml https://raw.githubusercontent.com/myastroboard/myastroboard/main/docker-compose.yml
  1. Start the application:
docker compose up -d
  1. Access the dashboard: Open your browser and navigate to http://localhost:5000 or IP of your machine.

The system will automatically:

  • Start the web dashboard
  • Initialize the scheduler
  • Run SkyTonight and cache schedulers automatically
  1. Update to latest version - Not necessary at first install:
docker compose pull
docker compose up -d

Option 2: Home Assistant App

For Home Assistant OS or Supervised installations (not Home Assistant Container or Core), on a 64-bit system: amd64 or aarch64 (Raspberry Pi 4/5, Home Assistant Green/Yellow, any x86 PC). No Docker knowledge needed.

  1. Add the repository: click the button below, or in Home Assistant go to Settings -> Apps -> App store, open the menu (top right) -> Repositories and add https://github.com/myastroboard/home-assistant-apps.

    Add repository to Home Assistant

  2. Install and start: pick MyAstroBoard in the store, click Install, then Start.

  3. Open the dashboard: click Open web UI, or turn on Show in sidebar to get a MyAstroBoard entry in the Home Assistant sidebar. The direct address (port 5000 on your Home Assistant host) stays available for what the sidebar panel cannot do: installable app, push notifications, Astrodex stream.

Updates are offered in Home Assistant like any other app, and the app’s data is included in Home Assistant backups. See the app’s documentation for the details and current limitations. The Home Assistant MQTT integration and the Lovelace card are separate and optional - see HOME_ASSISTANT.md.

Option 3: Build from Source (For Developers)

  1. Clone the repository:
git clone https://github.com/myastroboard/myastroboard.git
cd myastroboard
  1. Edit docker-compose.yml file (if necessary):
nano docker-compose.yml
  1. Build the application:
docker compose build
  1. Start the application:
docker compose up -d
  1. Access the dashboard: Open your browser and navigate to http://localhost:5000

The system will automatically:

  • Start the web dashboard
  • Initialize the scheduler
  • Run SkyTonight and cache schedulers automatically
  1. Debug & venv
# Create venv
python -m venv venv

# Activate venv
# Windows
.\venv\Scripts\activate
# Linux/Mac
source venv/bin/activate

# Install requirements
pip install -r requirements.txt

# Install dev tools
pip install -r requirements-dev.txt
# Debug Launch
docker compose -f docker-compose-dev.yml -f docker-compose.debug.yml up --build
  1. Tests
# Execute tests with covering
pytest --cov=backend tests/

# Execute specific test
pytest tests/skytonight/test_skytonight_targets.py -v

Advanced configuration

Security, Notifications & Reverse Proxy

These settings are configured entirely through the admin UI - no manual docker-compose editing required:

SettingWhere to configure
Secret key (session security)Auto-generated on first startup, stored in the database
VAPID contact email (push notifications)App settings -> Configuration -> Notifications
Trust proxy headers (reverse proxy / HTTPS)App settings -> Configuration -> Reverse proxy
HTTPS-only session cookieApp settings -> Configuration -> Reverse proxy

All settings persist in the data/ volume and survive container rebuilds and updates.

Push Notifications - VAPID Contact Email

After first login, go to App settings -> Configuration -> Notifications and enter a real email address. This is sent to push services (Apple APNs, Google FCM) as a contact address - it is never shown to users.

Without this setting, the app falls back to admin@localhost, which Apple APNs rejects, silently dropping all iOS push notifications.

iOS - Push Notifications Requirement

Push notifications on iOS require the app to be installed as a PWA (Progressive Web App):

  1. Open the app in Safari on your iPhone or iPad.
  2. Tap the Share button (square with arrow).
  3. Select “Add to Home Screen”.
  4. Launch the app from the Home Screen icon.

Push notifications will not work when opening the app directly in a Safari browser tab - they only work from the installed Home Screen shortcut. This is an Apple platform restriction.

The Test button (My settings -> Notifications) sends a real push through the server; on iOS, background or lock the app right after tapping it to see the notification. See MOBILE.md.

Remember Me Feature

The login page includes a “Remember me” checkbox (only visible when the HTTPS-only session cookie is enabled via App settings -> Configuration -> Reverse proxy):

  • When checked: Keeps users logged in for 30 days
  • When unchecked: Session expires when browser closes
  • Availability: Only shown when using HTTPS for security
  • HTTP mode: Feature is hidden; sessions always expire when browser closes
  • Logout always clears all cookies and sessions

Default Credentials

On first run, a default admin account is created:

  • Username: admin
  • Password: admin

🔒 IMPORTANT: Change the default password immediately after first login! The application will display a warning banner until you do.

Environment Variables

The docker-compose file requires only these variables:

# Timezone for container logs (UTC recommended; set your observation timezone in App settings -> Configuration)
- TZ=UTC

# Flask debug mode
- FLASK_DEBUG=0

# App data directory (keep default)
- DATA_DIR=/app/data

Security, reverse proxy, and push notification settings are managed in the admin UI (App settings -> Configuration) and stored in the database.

Data Persistence

All configuration and outputs are stored in Docker volumes:

  • ./data/ - All persistent data (survives container rebuilds)
  • ./data/myastroboard.db - SQLite database: accounts, configuration, settings, Astrodex, equipment, plans, observation log, wishlist (details)
  • ./data/astrodex/images/ - Astrodex pictures
  • ./data/observation_sessions/attachments/ - Observation log attachments
  • ./data/backups/ - Archive of the 1.6 data files, written once by the 1.7 upgrade
  • ./data/cache/ - Main cache files (persisted across restarts; only transient lock files are removed)
  • ./data/skytonight/ - SkyTonight main folder
    • ./data/skytonight/calculations/ - SkyTonight calculation outputs
    • ./data/skytonight/catalogues/ - Generated SkyTonight catalogues
    • ./data/skytonight/logs/ - SkyTonight logs (persisted for diagnostics)
    • ./data/skytonight/outputs/ - Graph outputs
    • ./data/skytonight/runtime/ - Runtime files for scheduler (status is persisted; transient lock/trigger files are cleaned on startup)

Important: These directories persist across Docker container rebuilds, ensuring your settings and data are never lost.

Getting started

When everything is installed, you can configure the interface in the web UI - see Quick Start.

Last updated 2026-11-13

Update

How to know a new version is out, update, and go back if needed.

Knowing a new version is out

When a newer release exists, the footer shows vX.Y.Z is available. Its See what’s new link opens a window with the changelog entries of every version since the installed one: features, fixes, and the breaking changes gathered on top under Read before updating. Administrators also see the update command there. The entries are read from the project’s CHANGELOG.md on GitHub; when GitHub cannot be reached, the window links to the release page instead.

Read the breaking changes before updating: they say what to do by hand, if anything.

Update the published image (Docker)

docker compose pull
docker compose up -d
docker image prune -f

The running version is in the footer, or:

docker exec myastroboard cat /app/VERSION

Images are tagged latest, X.Y.Z, X.Y and X. To stay on a minor line and only receive its fixes, set for example image: myastroboard/myastroboard:1.8 in docker-compose.yml.

Update the Home Assistant app

Home Assistant shows the update in Settings -> Updates once the app repository has picked up the new image. See 1.INSTALLATION.md.

Back up before updating

Data lives in data/. Either download a backup ZIP from App settings -> Backup / Restore (CONFIGURATION.md), or copy the directory with the container stopped - a copy of a SQLite database taken while it is being written can be unusable:

docker compose stop
cp -r data data.backup
docker compose start

Upgrading to 1.7 (database)

1.7 moves the data from JSON files to a SQLite database. The first start does the migration on its own: it archives the 1.6 files into data/backups/pre-1.7-<date>.zip, imports them, checks them, and only then removes them. If anything goes wrong, nothing is removed and the instance shows a maintenance page until the cause is fixed - see DATABASE.md.

The migration is one-way: to go back to 1.6 afterwards, unzip the pre-1.7 archive into data/ before starting the 1.6 image (changes made since the upgrade are not in it).

Roll back to a previous version

Pin the version in docker-compose.yml, then restart:

    image: myastroboard/myastroboard:1.8.2
docker compose up -d

The database schema is upgraded at startup and never downgraded. Going back to a version older than the last schema change needs the backup taken before the update. Each schema change is one file in backend/db/migrations/versions/.

Development build

git pull
docker compose -f docker-compose-dev.yml up -d --build

Contributors: releasing a version is described in 4.RELEASE.md, rebuilding the catalogue dataset in SKYTONIGHT.md.

Last updated 2026-11-13

🔒 Reverse Proxy & HTTPS Setup

This guide explains how to deploy MyAstroBoard behind a reverse proxy (like NGINX Proxy Manager, Traefik, or Apache) with HTTPS termination.

Overview

In a typical production deployment:

  • Reverse Proxy (NGINX Proxy Manager, Traefik, etc.) handles HTTPS/SSL on port 443
  • MyAstroBoard runs in Docker on HTTP (e.g., port 5000)
  • The reverse proxy forwards requests to MyAstroBoard via HTTP internally

The Challenge

When the HTTPS-only session cookie setting is on, Flask sets the Secure flag on session cookies, which instructs browsers to only send the cookie over HTTPS connections. However, if Flask doesn’t know the original request was HTTPS (because it only sees HTTP from the proxy), cookies and sessions may not work correctly.

The Solution: ProxyFix Middleware

MyAstroBoard includes support for the ProxyFix middleware, which allows Flask to trust proxy headers sent by the reverse proxy. This is required for proper HTTPS operation.


Configuration

Step 1: Enable Proxy Settings in the Admin UI

After deploying, log in as admin and go to App settings -> Configuration -> Reverse proxy:

  • ✅ Trust proxy headers (X-Forwarded-*) - required for Flask to see the real HTTPS scheme
  • ✅ HTTPS-only session cookie (Secure flag) - prevents cookie transmission over HTTP

Click Save, then Restart container when prompted.

These settings are stored in the database (data/myastroboard.db) and persist across container rebuilds.

Step 2: Configure Your Reverse Proxy

Your reverse proxy must send the following headers to MyAstroBoard:

Required Headers:

  • X-Forwarded-Proto: https (tells Flask the original request was HTTPS)
  • X-Forwarded-For: <client-ip> (preserves client IP for logging)
  • X-Forwarded-Host: <your-domain> (original hostname)
  • X-Forwarded-Port: 443 (original port)

Reverse Proxy Examples

NGINX Proxy Manager (NPM)

NPM Configuration

  1. Create Proxy Host in NGINX Proxy Manager:
    • Domain Names: myastroboard.yourdomain.com
    • Scheme: http
    • Forward Hostname/IP: Your Docker host IP or container name
    • Forward Port: 5000
  • Cache Assets: ✅ Enabled (recommended)
  • Block Common Exploits: ✅ Enabled
  • Websockets Support: ✅ Enabled (if using any websocket features)
  1. SSL Tab:
    • SSL Certificate: Select or create Let’s Encrypt certificate
  • Force SSL: ✅ Enabled
  • HTTP/2 Support: ✅ Enabled
  • HSTS Enabled: ✅ Enabled (recommended)
  1. Advanced Tab (add this):
# Proxy headers for Flask ProxyFix
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Prefix /;

# Standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

# Timeouts (adjust if needed)
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;

Docker Compose for NPM + MyAstroBoard

If running NPM and MyAstroBoard on the same Docker host:

version: '3.8'

services:
  nginx-proxy-manager:
    image: jc21/nginx-proxy-manager:latest
    container_name: nginx-proxy-manager
    ports:
      - "80:80"
      - "443:443"
      - "81:81"  # NPM Admin UI
    volumes:
      - ./npm/data:/data
      - ./npm/letsencrypt:/etc/letsencrypt
    restart: unless-stopped
    networks:
      - proxy-network

  myastroboard:
    image: myastroboard/myastroboard:latest
    container_name: myastroboard
    environment:
      - TZ=UTC
      - DATA_DIR=/app/data
    # Don't expose port 5000 externally - NPM will proxy to it
    # After first login: App settings -> Configuration -> Reverse proxy -> enable both options -> Restart
    expose:
      - "5000"
    volumes:
      - ./myastroboard/data:/app/data
    restart: unless-stopped
    networks:
      - proxy-network

networks:
  proxy-network:
    driver: bridge

In NPM, use myastroboard as the Forward Hostname (Docker service name) and 5000 as the port.


Traefik

docker-compose.yml with Traefik

version: '3.8'

services:
  myastroboard:
    image: myastroboard/myastroboard:latest
    container_name: myastroboard
    environment:
      - TZ=UTC
      - DATA_DIR=/app/data
    # After first login: App settings -> Configuration -> Reverse proxy -> enable both options -> Restart
    expose:
      - "5000"
    volumes:
      - ./data:/app/data
    restart: unless-stopped
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.myastroboard.rule=Host(`myastroboard.yourdomain.com`)"
      - "traefik.http.routers.myastroboard.entrypoints=websecure"
      - "traefik.http.routers.myastroboard.tls=true"
      - "traefik.http.routers.myastroboard.tls.certresolver=letsencrypt"
      - "traefik.http.services.myastroboard.loadbalancer.server.port=5000"
    networks:
      - traefik-network

networks:
  traefik-network:
    external: true

Traefik automatically adds the required X-Forwarded-* headers.


Standard NGINX

If using standalone NGINX (not NPM):

server {
    listen 443 ssl http2;
    server_name myastroboard.yourdomain.com;

    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    location / {
        proxy_pass http://localhost:5000;
        
        # Required headers for ProxyFix
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Port $server_port;
        
        # Standard headers
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        
        # WebSocket support (if needed)
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name myastroboard.yourdomain.com;
    return 301 https://$server_name$request_uri;
}

Apache

<VirtualHost *:443>
    ServerName myastroboard.yourdomain.com
    
    SSLEngine on
    SSLCertificateFile /path/to/cert.pem
    SSLCertificateKeyFile /path/to/key.pem
    
    ProxyPreserveHost On
    RequestHeader set X-Forwarded-Proto "https"
    RequestHeader set X-Forwarded-Port "443"
    
    ProxyPass / http://localhost:5000/
    ProxyPassReverse / http://localhost:5000/
</VirtualHost>

<VirtualHost *:80>
    ServerName myastroboard.yourdomain.com
    Redirect permanent / https://myastroboard.yourdomain.com/
</VirtualHost>

Serving under a sub-path

MyAstroBoard can also live under a path, e.g. https://example.org/myastroboard/. The proxy strips the path before forwarding and sends it in X-Forwarded-Prefix; with Trust proxy headers enabled, every page, API call and asset URL is then built under that prefix.

location /myastroboard/ {
    proxy_pass http://myastroboard:5000/;
    proxy_set_header X-Forwarded-Prefix /myastroboard;
    proxy_set_header X-Forwarded-Proto  $scheme;
    proxy_set_header X-Forwarded-Host   $host;
    proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
}

Limitations under a sub-path: no installable app (PWA), no offline page and no notifications - the service worker is root-scoped (see NOTIFICATIONS.md). Serve MyAstroBoard from its own host name if you need them.

The Home Assistant app uses the same mechanism for its sidebar panel (ingress, via the MYASTROBOARD_HA_INGRESS variable set by the app - never set it on a plain Docker install).

External base URL

Some addresses are opened by other software rather than by your browser: the Astrodex stream (Home Assistant camera, Lovelace card) and the address MyAstroShine sends photos back to. By default they are built from the address you are browsing on. Set App settings -> Configuration -> Reverse proxy -> External base URL when that address is not reachable by that software - it is required when you use MyAstroBoard from the Home Assistant sidebar.


Settings Reference

These used to be environment variables; since they moved to the admin UI, the variables TRUST_PROXY_HEADERS, SESSION_COOKIE_SECURE and SECRET_KEY are ignored and can be removed from docker-compose.yml.

Setting (App settings -> Configuration -> Reverse proxy)DefaultDescription
Trust proxy headers (X-Forwarded-*)offEnable the ProxyFix middleware so Flask sees the client’s address and the HTTPS scheme. Turn on only behind a trusted reverse proxy. Needs a restart
HTTPS-only session cookieoffSet the Secure flag on the session cookie. Refused from a plain-HTTP page
External base URLemptyAddress other software reaches this instance at (see above)

The session signing key is generated on first start and kept in the database: sessions survive restarts and updates without any setting. The session cookie is always HttpOnly and SameSite=Lax.


Testing Your Setup

1. Verify HTTPS is Working

curl -I https://myastroboard.yourdomain.com

Should return HTTP/2 200 or HTTP/1.1 200 OK

2. Check Secure Cookies

In browser DevTools (Network tab), inspect the response headers for /api/auth/login:

  • Look for Set-Cookie header
  • Should include Secure; HttpOnly; SameSite=Lax

3. Test Remember Me Feature

  1. Login with “Remember me” checked
  2. Close browser completely
  3. Reopen and navigate to your MyAstroBoard URL
  4. Should automatically be logged in

4. Check Logs

In MyAstroBoard logs:

docker logs myastroboard

You should see:

ProxyFix middleware enabled - trusting X-Forwarded-* headers from reverse proxy
Successful login for user admin (remember_me: True, permanent_session: True)
Session restored from cookie for user admin (permanent: True, endpoint: index)

Troubleshooting

❌ “Authentication required” after login with HTTPS

Cause: ProxyFix not enabled or proxy not sending X-Forwarded-Proto: https

Solution:

  1. Turn on Trust proxy headers in App settings -> Configuration -> Reverse proxy
  2. Verify proxy is sending X-Forwarded-Proto: https header
  3. Restart MyAstroBoard (button in the same section, or restart the container)

❌ Cookies not persisting after browser restart

Cause: the HTTPS-only session cookie setting is on but the site is accessed via HTTP

Solution: Always access via HTTPS when that setting is on

❌ Cannot sign in, or sign out does nothing, over HTTP

Cause: the HTTPS-only session cookie setting (App settings -> Configuration -> Reverse proxy) is on, and the page is opened over plain HTTP (for example http://192.168.1.10:5000 on the local network). A browser neither stores nor deletes a Secure cookie on an HTTP page: a fresh sign-in is lost at once, and a session that existed before the setting was turned on can no longer be signed out.

Solution: turn the setting off from a page that still works: the browser that is still signed in, the HTTPS address, or http://localhost (browsers accept Secure cookies there), for example through an SSH tunnel: ssh -L 5000:localhost:5000 <host>. Since 1.8.2 the setting refuses to turn on from a plain-HTTP page.

❌ Sessions invalidated after container restart

Cause: the data/ directory is not persisted (no volume mapped), so the database holding the session key is recreated at each start.

Solution: map ./data:/app/data as in the provided docker-compose.yml.

❌ IP addresses in logs show proxy IP, not client IP

Cause: ProxyFix not enabled

Solution: Turn on Trust proxy headers (App settings -> Configuration -> Reverse proxy)


Security Best Practices

  1. ✅ Always use HTTPS in production with valid SSL certificates
  2. ✅ Enable Trust proxy headers only when behind a trusted reverse proxy
  3. ✅ Enable the HTTPS-only session cookie for HTTPS deployments
  4. ✅ Turn on two-factor authentication for accounts reachable from the internet (AUTHENTICATION.md)
  5. ✅ Keep Docker images updated (docker compose pull)
  6. ✅ Enable firewall rules to restrict direct access to port 5000
  7. ⚠️ Never expose port 5000 directly to the internet when using a reverse proxy

Quick Reference

DeploymentTrust proxy headersHTTPS-only session cookie
Local network, plain HTTPoffoff
Behind a reverse proxy with HTTPSonon

Additional Resources

Last updated 2026-11-13

Troubleshooting

Common problems, what causes them, and how to fix them. Each entry links to the page that covers the subsystem in detail.

Before you report an issue

  1. Open App settings -> Logs, set the log file level to DEBUG, reproduce the problem.
  2. Use Export logs in the same sub-tab: it downloads one ZIP with the log files and a summary of the instance (CONFIGURATION.md).
  3. Attach it to a GitHub issue, with the version (footer) and how you run MyAstroBoard (Docker, Home Assistant app).

The first lines written at each start include a Host: line with the CPUs, memory, container limits and file system of the data directory: it answers most resource questions.

Installation and resources

Requests fail at random, or the offline page appears, during the first SkyTonight run

Cause: not enough memory. A worker is killed during the SkyTonight calculation; the browser then sees failed requests, sometimes for actions that went through on the server.

Fix: give the container at least 1 GB, 2 GB when other services share the host. App settings -> Metrics warns below 1 GB. See system requirements.

Everything is slow to save

Cause: the data/ directory is on a network share (NFS, SMB), where SQLite cannot use its write-ahead log. The log says so at startup.

Fix: keep data/ on a local disk (DATABASE.md).

The container does not start on an old CPU or a Proxmox VM

The image targets plain x86-64 (no x86-64-v2 needed) and arm64. Report the startup output in an issue: a dependency that needs newer CPU instructions is a bug.

Signing in

Lost admin password, or lost authenticator app

Use the recovery command line inside the container:

docker exec -u appuser myastroboard python backend/db/manage.py reset-password admin
docker exec -u appuser myastroboard python backend/db/manage.py disable-2fa admin

See DATABASE.md.

Cannot sign in, or sign out does nothing, over plain HTTP

Cause: the HTTPS-only session cookie setting is on and the page is opened over HTTP.

Fix: turn the setting off from the HTTPS address, from http://localhost (an SSH tunnel works), or from a browser that is still signed in. See 6.REVERSE_PROXY.md.

“Too many failed sign-in attempts”

Five failed sign-ins on one account, or twenty from one address, within 15 minutes block further attempts for the rest of that window (AUTHENTICATION.md).

A local-only account cannot sign in

An account whose scope is local only signs in from a trusted network. Check the trusted networks in App settings -> Users, and Trust proxy headers when a reverse proxy sits in front (AUTHENTICATION.md).

SkyTonight

No targets, or the SkyTonight tables never appear

  1. The first run takes a few minutes after startup; the tab shows its progress.
  2. App settings -> Metrics -> SkyTonight Scheduler shows the last run and its error, if any.
  3. SkyTonight -> Logs shows the last run’s log.
  4. Constraints that are too strict hide everything: check minimum altitude, size limits and the observable fraction in App settings -> Configuration (SKYTONIGHT.md).
  5. Run SkyTonight Now (App settings -> Configuration) starts a run at once.

Object pictures are grey placeholders

The DSS2 sky images come from CDS hips2fits, then from mirrors of the survey. When none answers, a placeholder is shown and the image is fetched again later (SKYTONIGHT.md).

Forecasts and caches

A card says the data is not ready yet

Forecasts are computed in the background, every 25 minutes for the jobs whose time-to-live ran out. After a restart or a location change, the first values take a few minutes. App settings -> Metrics -> Cache Jobs shows each job’s state and last run (CACHE_SYSTEM.md).

ISS / CSS passes stop updating

Celestrak may block a server that asked too often. A banner explains it; an admin can clear the block from it (SPACEFLIGHT.md).

Notifications

Push notifications never arrive on iPhone or iPad

  1. The app must be installed on the Home Screen and opened from there (MOBILE.md).
  2. The contact email must be set in App settings -> Configuration -> Notifications: Apple rejects pushes without a real address (NOTIFICATIONS.md).

The Test button in My settings -> Notifications sends a real push through the server. When it reports that delivery failed, the server log says why. On iOS, background or lock the app right after tapping it to see the notification.

Connectors and Home Assistant

From the Home Assistant sidebar, stream addresses and the MyAstroShine return address need an External base URL (App settings -> Configuration -> Reverse proxy).

Last updated 2026-11-13

Configuration

MyAstroBoard stores its runtime configuration in its database (data/myastroboard.db, see DATABASE.md); /api/config/export downloads it as config.json. All settings are managed through the App settings tab (admin only) in the UI or via the /api/config endpoint.


App settings tab layout

Sub-tabContent
LocationsLocation presets: coordinates, timezone, sky quality, horizon profile, user attribution (see LOCATIONS.md)
ConfigurationAstrodex privacy, SkyTonight constraints, push notification contact email, privacy & search engines, reverse proxy and external base URL, MQTT connections, Run SkyTonight now
ConnectorsExternal tool connectors (AllSky, etc.)
LogsLive log viewer, log levels and retention, log export
UsersUser management, trusted networks and two-factor authentication policy (admin only)
MetricsScheduler and cache performance dashboard
Backup / RestoreDownload and restore backup archives, 1.7 upgrade archive

Locations (multi-location profiles)

Since v1.2, the configuration holds an admin-managed locations list (up to MAX_LOCATIONS = 5 presets) instead of a single location object. At least one preset is required before most calculations can run; editing a preset’s coordinates resets only that preset’s caches.

Full reference - data model, attribution, per-user default/active location, rate-limit analysis behind the cap: LOCATIONS.md.

Each preset carries:

FieldDescriptionDefault
idServer-generated uuid4, immutable-
nameHuman-readable site name"Paris"
latitudeDecimal degrees, −90 to +9048.866669
longitudeDecimal degrees, −180 to +1802.33333
elevationAltitude above sea level in metres35
timezoneIANA timezone string (e.g. Europe/Paris)"Europe/Paris"
bortle / sqmSky quality (see below)null
horizon_profilePer-preset horizon mask (see below)[]
is_install_defaultExactly one preset carries truefirst preset

The timezone field determines how all local times (moonrise, sunset, event times…) are displayed throughout the app for users observing from that preset. GET /api/timezones returns all valid IANA timezone strings for the dropdown.

A coordinate converter is available at POST /api/convert-coordinates to translate between decimal degrees and DMS (degrees/minutes/seconds).

Legacy single-location configs are migrated automatically on first load after upgrade (the old location key and the old global horizon profile move onto the first preset - zero manual action required).

Sky quality (light pollution)

Set per location preset:

FieldDescription
bortleBortle class 1-9 (optional). Determines the SQM midpoint estimate.
sqmSky Quality Meter reading in mag/arcsec² (optional, overrides Bortle midpoint).

See SKYTONIGHT.md - Light Pollution Integration for how these values affect AstroScore.


SkyTonight constraints

These values live under skytonight.constraints in the configuration. They define which objects are considered observable. All constraints are always active.

ParameterDefaultDescription
altitude_constraint_min30°Minimum target altitude above the horizon
altitude_constraint_max80°Maximum target altitude (avoids zenith blind spot for some mounts)
airmass_constraint2.0Maximum airmass (≈ 30°+ elevation)
size_constraint_min0 arcminMinimum angular size for DSOs (0 = no lower limit)
size_constraint_max300 arcminMaximum angular size
moon_separation_min45°Minimum angular distance from the Moon
moon_separation_use_illuminationtrueWhen true, overrides moon_separation_min with Moon illumination % -> degrees (e.g. 60 % full Moon -> 60° separation required)
fraction_of_time_observable_threshold0.50DSOs must be within all constraints for ≥ 50 % of the night to appear in results
north_to_east_ccwfalseAzimuth convention: false = clockwise from North (standard); true = counter-clockwise

Custom horizon profile

Since v1.2 the horizon profile is a per-location preset field (locations[].horizon_profile, edited in the location’s edit modal) instead of the former global skytonight.constraints.horizon_profile. It stores an array of [azimuth, altitude] pairs that describe the site’s local horizon mask - mountains, buildings, or trees that block the view at specific azimuths.

"horizon_profile": [
  [0, 5],
  [45, 15],
  [90, 8],
  [180, 2],
  [270, 6],
  [315, 10]
]

SkyTonight evaluates each target against the horizon profile: if the object’s altitude at a given azimuth is below the profile altitude for that direction, it counts as blocked. Targets are scored only during the fraction of time they clear both the global altitude_constraint_min and the horizon profile.

The profile is set in App settings -> Locations -> (edit a preset) -> Custom Horizon Profile. Enter points manually per location.


Scheduler configuration

Configuration key: skytonight.scheduler

FieldDescription
mode"astro" (runs at dawn+1h and dusk−1h) or "fallback-6h" (runs every 6 hours when clock is unreliable)
server_time_validtrue when the system clock is considered accurate (NTP synced)

See SKYTONIGHT.md - Scheduler for scheduling logic.


Dataset configuration

Configuration key: skytonight.datasets

FieldDefaultDescription
datasets.catalogues.deep_skytrueEnable DSO catalogue processing
datasets.catalogues.bodiestrueEnable solar-system body calculations
datasets.catalogues.cometstrueEnable comet processing
datasets.comets.source"mpc+jpl"Comet orbital data source: "mpc", "jpl", or "mpc+jpl"
datasets.comets.auto_updatetrueAutomatically refresh comet elements before each calculation

Application settings (admin)

Stored separately (database setting app_settings, see AUTHENTICATION.md):

SettingDefaultDescription
vapid_contact_email""Contact email for Web Push VAPID tokens (required for iOS push)
trust_proxy_headersfalseEnable X-Forwarded-For / X-Forwarded-Proto forwarding (reverse proxy deployments)
session_cookie_securefalseRequire HTTPS for session cookie
external_base_url""http(s):// address other software reaches this instance at: Astrodex stream URLs and the MyAstroShine return address (whose own callback_url_override still wins). Empty = the address of the current request. Required under the Home Assistant sidebar panel (ingress), whose URL needs an HA login. Set in App settings -> Configuration -> Reverse proxy
search_engine_indexingfalseAllow search engines to crawl and index the login page. Off by default for privacy: /robots.txt returns Disallow: / and the login page is served with noindex, nofollow. When enabled, /robots.txt allows /login and the login page adds Open Graph/JSON-LD metadata pointing to myastroboard.org
log_retention_days90Days a line stays in myastroboard.log and its rotated backups (0-3650). Log lines hold usernames and IP addresses, so older lines are removed once a day per process, and right away when the value is lowered. 0 disables it: only the size-based rotation (10 MB x 6 files) applies. Set in App settings -> Logs
log_level"INFO"Level written to myastroboard.log (DEBUG, INFO, WARNING, ERROR, CRITICAL). Applies without a restart. The LOG_LEVEL environment variable, when set, overrides it. Set in App settings -> Logs
console_log_level"WARNING"Level of the container output (docker logs, the Log tab of the Home Assistant app). The CONSOLE_LOG_LEVEL environment variable, when set, overrides it. Set in App settings -> Logs

MQTT connections

Sub-tab: App settings -> Configuration

The MQTT brokers the MQTT connectors talk to, stored under mqtt_connections; each connector picks one by id (connectors.mqtt.mqtt_connection_id). The password is kept in the connector secrets store, never in the configuration. See HOME_ASSISTANT.md and CONNECTORS.md.

"mqtt_connections": [
  {
    "id": "home-assistant",
    "name": "Home Assistant",
    "url": "mqtt://192.168.1.10:1883",
    "username": "myastroboard",
    "tls_insecure": false
  }
]

Connectors

Sub-tab: App settings -> Connectors

Connector configuration is stored in the configuration under connectors.<name>. See CONNECTORS.md for the full reference.

"connectors": {
  "allsky": {
    "url": "http://allsky.local",
    "label": "My AllSky",
    "enabled": true,
    "image_path": "current",
    "image_filename": "image.jpg",
    "mqtt_connection_id": "home-assistant",
    "mqtt_topic": "allsky",
    "client_id": "",
    "modules": {
      "live_image":      { "enabled": true },
      "sensor_data":     { "enabled": false },
      "keogram":         { "enabled": true },
      "startrails":      { "enabled": false },
      "daily_timelapse": { "enabled": false }
    }
  }
}

Backup and restore

Sub-tab: App settings -> Backup / Restore

Download backup

GET /api/backup/download produces a ZIP archive, built from the database in the same JSON layout as 1.6 backups:

File / folderContent
config.jsonAll site configuration
app_settings.jsonApp settings (App settings -> Configuration)
users.jsonAll user accounts (with password hashes)
astrodex/All Astrodex collections (one JSON per user + images/ subdirectory)
equipments/All equipment profiles (one JSON per user per equipment type)
observation_sessions/All observation logs (one JSON per user + attachments/ subdirectory)
wishlist/All wishlists (one JSON per user)

The archive is named myastroboard_backup_<timestamp>.zip.

Not included: the security settings (trusted networks, instance-wide 2FA switch) are deliberately excluded from both the backup ZIP and /api/config/export, for the same reason as trust_proxy_headers: a LAN CIDR block from one install is meaningless - or actively wrong - on a restored host. Re-configure trusted networks by hand after a restore on a different machine. See also docs/AUTHENTICATION.md.

Restore backup

POST /api/backup/restore (multipart form, field file) accepts a backup ZIP (from 1.7 or 1.6) and restores its contents. Every entry is validated before anything is written, then the database is updated in one transaction: each folder present in the archive replaces that kind of data, and users.json replaces the account list (accounts absent from it are deleted with their data). Documents of accounts that are not in the database are skipped.

After a restore: every gunicorn worker picks the restored data up on its next request. A page reload is required to reflect restored user accounts.


Logs

Sub-tab: App settings -> Logs

Live log viewer

GET /api/logs returns the tail of data/logs/myastroboard.log for live viewing in the browser. The active levels can be checked with GET /api/logs/level ({"level": <log file>, "console_level": <console>}).

Log levels

Set in App settings -> Logs: one level for the log file (default INFO) and one for the console output (default WARNING) - see log_level / console_log_level in Application settings. To troubleshoot, set the log file level to DEBUG, reproduce the issue, then export the logs. A change applies right away, without a restart (the other gunicorn worker follows within a few seconds). The LOG_LEVEL / CONSOLE_LOG_LEVEL environment variables still work and, when set, take precedence: the matching setting is then shown disabled in the UI.

Log lines are JSONL-formatted (one JSON object per line): {"level": "INFO", "time": "...", "msg": "...", "module": "..."}.

Export logs

GET /api/logs/export downloads a ZIP archive containing:

  • myastroboard.log - main application log
  • skytonight/logs/last_calculation.log - last SkyTonight run log

Attach this file to GitHub issues to help maintainers diagnose problems.

Clear logs

POST /api/logs/clear truncates the main log file (admin only).

Retention

Besides the size-based rotation, lines older than log_retention_days (default 90, see Application settings) are removed from the main log and its backups. See Privacy & GDPR.


Metrics

Sub-tab: App settings -> Metrics (admin only)

GET /api/metrics returns system metrics including:

SectionContent
Cache jobsPer-job TTL, last run time, duration, success/failure
SchedulerSkyTonight scheduler status and last run
SystemUptime, Python version, worker count

The metrics tab is the first place to check if a cache job is repeatedly failing or taking unexpectedly long.


App restart

POST /api/admin/restart (admin only) triggers a graceful application restart. This is needed after changes to the app settings that affect Flask configuration (e.g. enabling session_cookie_secure).


Configuration API

MethodPathAuthDescription
GET/api/configloginRead full config; also exposes the caller’s active location under location (compat shim)
POST/api/configadminSave global config; a legacy location payload updates the install default preset (its caches only are reset)
-/api/locations*admin/loginLocation preset CRUD, attribution, switcher - see LOCATIONS.md and API_ENDPOINTS.md
GET/api/config/exportadminDownload the configuration as config.json
GET/api/skyqualityloginCurrent sky quality parameters and computed LP factor
GET/api/admin/app-settingsadminRead the app settings
POST/api/admin/app-settingsadminSave the app settings
POST/api/admin/restartadminRestart the app
GET/api/backup/downloadadminDownload backup ZIP
POST/api/backup/restoreadminRestore from backup ZIP
GET/api/logsadminTail of main log file
GET/api/logs/leveladminCurrent log level
POST/api/logs/clearadminTruncate log file
GET/api/logs/exportadminDownload log ZIP
GET/api/metricsadminSystem and cache metrics
GET/api/versionloginCurrent app version from VERSION file
GET/api/version/check-updatesloginCompare against latest GitHub release
GET/api/timezonesloginAll valid IANA timezone strings
POST/api/convert-coordinatesloginConvert DMS <-> decimal degrees
GET/api/healthPublicHealth check (returns {"status": "ok"})
GET/api/cataloguesloginList all loaded SkyTonight catalogue counts

Last updated 2026-11-13

Multi-location Profiles

MyAstroBoard supports up to 5 observing location presets per install. Locations are created and managed by an administrator, attributed to users, and each user picks which attributed location is active - the active location drives every location-dependent calculation (weather, astro forecasts, aurora, ISS/CSS passes, seeing, events, sky widget).


Table of contents

  1. Concepts
  2. Deletion workflow
  3. SkyTonight per location
  4. Astrodex & Plan My Night
  5. Notifications
  6. Migration from single-location installs
  7. Active-location resolution
  8. Rate-limit analysis & the MAX_LOCATIONS cap
  9. Cache architecture
  10. Data model
  11. API endpoints

Concepts

TermMeaning
Location presetAdmin-created record: name, lat/lon, elevation, timezone, Bortle/SQM, per-location horizon profile. Stored in the configuration under locations[].
Install defaultExactly one preset carries is_install_default: true at all times. It is the fallback for users with no attributed location, the cadence anchor for the nightly SkyTonight batch, and the anchor for legacy compatibility.
AttributionAdmin-controlled many-to-many: a preset can be attributed to several users, a user can hold several presets. Admins implicitly have access to every preset.
Default location (per user)The durable preference “what should be shown when I connect” (preferences.location.default_location_id).
Active location (per user)What actually drives calculations right now, this session (preferences.location.active_location_id). Changed via the sky-widget switcher; reset to the default on every fresh login.

The sky-widget switcher panel opens on click/tap only (never hover - a hover-open panel is unreachable by pointer once the location list needs to be clicked). Each row shows the location’s current observation score (0-10, read from that location’s warm weather_forecast cache - never a live fetch). When the active location’s timezone differs from the viewer’s browser timezone, the widget shows a small offset badge and an explanatory note: every time displayed anywhere in the app (sun/moon times, forecasts, passes) is the location’s own local time, not the viewer’s.


Deletion workflow

  1. Deleting the preset flagged is_install_default is refused until another preset is promoted (there must always be exactly one install default).
  2. User pointers (attributed_location_ids, default_location_id, active_location_id, order) are cleaned eagerly at delete time; default/active pointers are reset to the install default.
  3. Astrodex is never touched - pictures referencing the deleted preset keep their frozen location_name snapshot (location_id just stops resolving live).
  4. Plan My Night plans pinned to the preset are cascade-deleted by default (?plans=orphan keeps them; the UI then shows the stale-location banner).
  5. Observation Log sessions are never touched either - like Astrodex pictures, a session is a historical record and keeps its frozen location_name snapshot. The pre-delete report counts them under observation_sessions for information only (see OBSERVATION_LOG.md).
  6. The preset’s cache slots and tracked signature are dropped (cache_store.drop_location_caches).

SkyTonight per location

The nightly SkyTonight calculation runs once per scheduler location (install default + attributed/active presets, bounded by MAX_LOCATIONS). Two opposite locations therefore get genuinely different night tables, and the alt-time graph overlays the right site’s horizon.

  • Each run builds the targets dataset once (catalogue ingestion is location-independent), then executes run_calculations(config, location=preset) for every scheduler location sequentially.
  • Result files are stored per preset id: data/skytonight/calculations/<location_id>/{calculation_results,dso_results,bodies_results,comets_results,skymap_data}.json and data/skytonight/outputs/<location_id>/<target>_alttime.json. Pre-v1.2 flat files are deleted at container startup (entrypoint.sh); a location with no results yet is simply picked up by the scheduler’s next run.
  • Every /api/skytonight/* endpoint resolves the requesting user’s active location (get_active_location) and serves that preset’s results - including horizon_profile overlays (alt-time, skymap) and target-debug diagnostics.
  • The scheduler’s dawn/dusk cadence anchors stay on the install default preset (one nightly batch window); the loop also fires whenever any scheduler location has no results yet (new preset, or results dropped after a coordinate change).
  • Creating/attributing a preset makes it part of the next batch automatically; updating a preset’s coordinates or deleting it drops its result files (skytonight_storage.drop_location_results).
  • The live conditions fetch (get_skytonight_conditions) uses the requester’s active location, debounced server-side per location.

The nightly pass is a minutes-long Astropy computation, so the batch cost scales with the number of scheduler locations - this is pure local CPU (no external API quota) and bounded by MAX_LOCATIONS = 5.


Astrodex & Plan My Night

  • Astrodex pictures (not items) carry the location - location_id (best-effort live link, or null for a free-text “somewhere else” label the uploader typed instead of picking a preset) and location_name (frozen snapshot, resolved from the preset or typed by the uploader). Coordinates (latitude/longitude/elevation) are resolved server-side from the preset and are private to the picture’s owner - the shared/merged Astrodex view strips them from every other user’s pictures, keeping only location_name visible. An item itself has no location field: the same object is commonly re-photographed from different sites across sessions, so the UI derives an “observed at” summary from the item’s own pictures instead of storing one redundant, potentially-stale value on the item. Unlike items, a picture’s location is editable after the fact (via the same add/edit picture form) - a photo is often uploaded well after the session, and older pictures predate this field entirely. Astrodex is never cascade-deleted with a preset.
  • Plan My Night plans are pinned at creation to the creator’s current active location (plan.location_id + plan.location_name). The plan is never silently recomputed against different coordinates; the UI shows a warning banner when the viewer’s active location differs from the plan’s pinned location. Plans pinned to a deleted preset are cascade-deleted by default.

Notifications

Location-scoped triggers (N3 ISS, N4/N5 eclipses, N6 darkness, N7 aurora, N8 CSS) run once per (user, attributed location) pair, reading that location’s caches. Cooldown keys include the location id so two watched locations don’t suppress each other.

  • The location name is appended to the push body only for users with more than one attributed location (settings.push_location_suffix i18n key) - single-location installs keep messages exactly as before.
  • Plan triggers (N1/N2) stay per-plan and mention the plan’s pinned location name.
  • Users can mute individual locations via preferences.notifications.disabled_location_ids (My settings -> Notifications -> Locations; the block only appears with >1 attributed location).

Migration from single-location installs

Automatic, one-time, on the first load_config() after upgrade (repo_config._ensure_locations):

  1. The legacy location dict is wrapped into locations: [{..., id: <uuid4>, is_install_default: true}] and the singular key removed.
  2. A legacy global skytonight.constraints.horizon_profile is moved onto that preset.
  3. The migrated config is saved immediately so the preset id stays stable across workers/restarts.
  4. On the first scheduler cycle, cache_updater.check_and_handle_config_changes() renames the pre-v1.2 plain cache keys ("sun_report" -> "sun_report:<id>") so the warm cache survives the upgrade with no recompute/refetch storm, and transfers the legacy location-change signature onto the preset id.
  5. Users need no migration: the resolver’s fallback chain sends users without a preferences.location block to the install default, which matches pre-v1.2 behavior exactly.

Rollback note: the migration is one-way (the singular key is removed). Downgrading to a pre-v1.2 image requires restoring config.json from a backup (and 1.6 or older, see DATABASE.md).


Active-location resolution

Every backend consumer calls repo_config.get_active_location(config, user) instead of reading config["location"]. Fallback chain:

user's active -> user's default -> install default -> first attributed

Only ids that still exist in config["locations"] are trusted. Admins bypass attribution entirely (every preset is implicitly theirs).

The set of locations the cache scheduler keeps warm is repo_config.get_scheduler_locations(config): the install default + every preset attributed to at least one user + any preset currently selected as someone’s active/default. An unattributed, unused preset costs nothing until someone selects it.


Rate-limit analysis & the MAX_LOCATIONS cap

MAX_LOCATIONS = 5 (backend/utils/constants.py). Not admin-configurable: every self-hosted MyAstroBoard instance shares the same free, keyless Open-Meteo/7Timer capacity pool. This is a collective-good constraint (avoid provider-side blacklisting for everyone), not a per-install tuning knob.

Which APIs scale with location count?

Only two external services take lat/lon as request parameters:

APIScales with N locations?
Open-Meteo (weather + astro analysis + live conditions)Yes
7Timer (seeing)Yes
NOAA SWPC (Kp), Celestrak/TLE mirrors, Launch Library 2, IERSNo - global data fetched once

Recompute this if a provider publishes a quota

Neither provider publishes a hard numeric quota today; the documented Open-Meteo protection is concurrency-based (single-flight + 90 s shared cooldown in weather/weather_utils.py / weather/weather_openmeteo.py).

calls_per_hour_per_location(job) = 3600 / TTL_seconds(job)

Open-Meteo, per location, per hour:
  scheduled hourly-forecast job     (TTL 3600s)  -> 1.0 call/h
  astro-analysis job                (TTL 1800s)  -> 2.0 call/h  [only while viewed]
  live-conditions fetch             (debounced)  -> ≤ 12 call/h worst case
      (SKYTONIGHT_LIVE_CONDITIONS_DEBOUNCE_SECONDS = 300, per-location server-side)
  ⇒ steady state ≈ 3 calls/h/location; bounded worst case ≈ 15 calls/h/location

7Timer, per location, per hour:
  seeing-forecast job               (TTL 21600s) -> 0.167 call/h (negligible)

total_calls_per_hour(N) ≈ N × 15   [Open-Meteo, pathological worst case]
                        ≈ N × 3    [Open-Meteo, realistic steady state]

If a provider publishes an explicit quota Q (calls/hour):
  N_max     = floor(Q / calls_per_hour_per_location)
  final cap = min(N_max, concurrency-safety cap)

At N=5, Open-Meteo sees ≈15-75 calls/hour - trivial volume. The real risk is N locations’ jobs firing concurrently in the same scheduler tick, which is bounded by the fixed worker pool and the global single-flight/cooldown gate (see Rate-limit analysis).


Cache architecture

See also docs/CACHE_SYSTEM.md. Caches are split into two buckets:

  • Location-scoped (one slot per preset id, on-disk key "<name>:<location_id>" in astro_cache.json): moon_report, dark_window, moon_planner, sun_report, best_window_*, solar_eclipse, lunar_eclipse, horizon_graph, aurora, iss_passes, css_passes, planetary_events, special_phenomena, solar_system_events, sidereal_time, seeing_forecast, weather_forecast.
  • Global (single slot, external data does not vary by observer): spaceflight_*, iers, AllSky connector, version check. The ISS/CSS TLE fetch is also global (satellite-specific, has its own on-disk cache); only the local pass-visibility computation runs per location.

The scheduler expands every location-scoped job into one work unit per scheduler location, each gated by its own (job, location) TTL. Job × location units feed the same fixed-size ThreadPoolExecutor(max_workers≤6) pool, so total in-flight requests never exceed the pre-v1.2 ceiling regardless of location count.

Editing one preset’s coordinates invalidates only that preset’s caches (cache_store.reset_caches_for_location(id)), not the whole install. Metrics rows for non-default locations are recorded as "<job>@<location-slug>".

The Open-Meteo single-flight lock, failure cooldown and shared concurrency gate stay global (not per-location) so multi-location installs respect the provider’s concurrency limit as one shared budget.

weather/weather_astro.py’s in-memory analysis cache is keyed by (hours, language, location_id) - pre-v1.2 it lacked the location dimension, which would have served one location’s analysis to another location’s tab.


Data model

Configuration - presets (admin-managed, global)

{
  "locations": [
    {
      "id": "3f1c9e2a-...-uuid4",        // server-generated, immutable, never client-supplied
      "name": "Paris",
      "latitude": 48.866669,
      "longitude": 2.33333,
      "elevation": 35,
      "timezone": "Europe/Paris",
      "bortle": null,
      "sqm": null,
      "horizon_profile": [],            // per-preset custom horizon (moved from skytonight.constraints)
      "is_install_default": true,
      "created_at": "2026-07-15T00:00:00Z",
      "updated_at": "2026-07-15T00:00:00Z"
    }
  ]
}

The legacy singular location key is migrated to locations[] on first load after upgrade (see Migration). id is the foreign key used by user preferences, cache slots, Astrodex items and Plan My Night plans - it survives renames.

Account preferences - per-user selection (preferences.location)

"location": {
  "attributed_location_ids": ["<id>"],  // admin-assigned; empty = install default only
  "default_location_id": "<id>",         // shown when the user connects
  "active_location_id": "<id>",           // drives calculations right now
  "order": ["<id>"]                        // display order in the quick switcher
}

On each successful login, active_location_id is reset to default_location_id - a mid-session switch never outlives the session.


API endpoints

MethodPathAuthPurpose
GET/api/locationsadminList all presets with attribution info + max_locations
POST/api/locationsadminCreate preset (enforces MAX_LOCATIONS)
PUT/api/locations/<id>adminEdit preset; coordinate/timezone change resets only that preset’s caches. is_install_default: true promotes atomically.
GET/api/locations/<id>/referencesadminPre-delete check: attributed users, Astrodex item count, Plan My Night plan count, Observation Log session count (observation_sessions, informational only)
DELETE/api/locations/<id>?plans=cascade|orphanadminDelete (blocked while install default). cascade (default) deletes pinned plans; orphan keeps them with a stale-location banner.
POST/api/locations/<id>/attributeadmin{user_ids: [...]} - attach to exactly these users
GET/api/locations/mineany userCaller’s attributed locations (id/name/bortle/sqm only - no live sky data), active + default ids
POST/api/locations/activeany user{location_id} - set the caller’s active location

Compatibility: GET /api/config still exposes the caller’s active location under the legacy location key; POST /api/config with a legacy location payload updates the install default preset (used by the setup wizard).

Last updated 2026-11-13

Authentication & User Management

MyAstroBoard uses a simple session-based multi-user system with three roles.

Module: backend/utils/auth.py


Default credentials

On first startup, a single admin account is created automatically:

UsernamePassword
adminadmin

Change the default password immediately after first login via My settings -> Security -> Change Password, or via the admin user management panel.


Roles

RoleDescriptionPermissions
adminFull administratorAll features + user management, system config, admin API endpoints
userStandard userAll observatory features (observe, plan, record, edit own data)
read-onlyView-only accessBrowse Astrodex, SkyTonight, and weather; cannot write any data

Role comparison

Actionadminuserread-only
View dashboard, weather, SkyTonight✅✅✅
View Astrodex✅✅✅
Add/edit Astrodex items✅✅❌
Plan My Night (read)✅✅✅
Plan My Night (create/edit)✅✅❌
Manage own equipment✅✅❌
Change own password/preferences✅✅✅
Manage all users✅❌❌
Change system configuration✅❌❌
View metrics and logs✅❌❌
Trigger SkyTonight recalculation✅❌❌

User management (admin)

The App settings -> Users panel (admin only) allows:

  • Create user: username, password, role, and optionally the account scope (see Local vs. global accounts).
  • Edit user: change username, password, role, or account scope.
  • Usernames ignore case: “Emeric” and “emeric” are the same name, so the second cannot be created, and either spelling signs in.
  • Delete user: removes the account and every per-user file (Astrodex and pictures, observation sessions and attachments, equipment, plans, wishlist) - see Privacy & GDPR.
  • Export data: downloads the same personal-data ZIP the user gets from My settings -> Security -> Your data, to answer an access or portability request.
  • Reset 2FA: shown only when the user has two-factor authentication active. Clears it unconditionally, no password check - see Lost-authenticator recovery.

API endpoints (admin only):

MethodPathDescription
GET/api/usersList all users (without password hashes or TOTP secrets); each entry includes account_scope and totp_enabled
POST/api/usersCreate a new user (account_scope optional, defaults to global)
PUT/api/users/<user_id>Update username, password, role, or account_scope
DELETE/api/users/<user_id>Delete a user
DELETE/api/users/<user_id>/2faClear a user’s two-factor authentication (admin only, no password check)
GET/api/users/<user_id>/exportZIP of everything stored about a user (see Privacy & GDPR)

Two-factor authentication

Optional TOTP (Time-based One-Time Password) second factor, compatible with any standard authenticator app (Google Authenticator, Authy, 1Password, etc.). Off by default; an admin must enable it instance-wide before any individual user can turn it on for their own account.

Dependency: pyotp (pure Python, no transitive dependencies) generates secrets, verifies codes, and builds the otpauth:// provisioning URI. QR codes are rendered client-side from a small vendored JS library (static/vendor/qrcode/qrcode.min.js) - the backend never renders an image, so no qrcode/Pillow-style dependency was added for this.

Two levels of control

  1. Instance switch (security settings, two_factor_enabled): an admin turns 2FA on or off for the whole install. Requires at least one trusted network (see Trusted networks). Managed in App settings -> Users.
  2. Per-user opt-in (account, totp_enabled): once the instance switch is on, each user individually enables 2FA for their own account in My settings -> Security. A user whose account was never opted in is never challenged at login, even with the instance switch on.

Trusted-network bypass

2FA is skipped entirely - no code is asked for - when the login request’s client IP falls inside a trusted network (including the always-trusted loopback ranges). This is deliberate: the trusted network already establishes who is signing in, so the second factor would be redundant from there.

Setup flow (My settings -> Security)

  1. Enable 2FA -> POST /api/auth/2fa/setup generates a TOTP secret and persists it immediately with totp_enabled: false. It is not held in server memory or the Flask session: the container runs gunicorn -w 2 (two worker processes), so an in-memory pending secret would randomly fail confirmation depending on which worker handled it.
  2. The setup modal shows:
    • A QR code (rendered client-side) - scan it from a different device than the one showing it.
    • The raw secret as selectable text - the reliable fallback when the authenticator app is on the same phone as the browser (QR scanning is then impossible).
    • An otpauth:// link/button to open directly in an authenticator app - best effort, not every app registers the URI scheme as a system handler.
  3. Confirm with a 6-digit code -> POST /api/auth/2fa/confirm verifies it against the just-generated secret (pyotp.TOTP(secret).verify(code, valid_window=1), tolerating +/-30 seconds of clock drift) and sets totp_enabled: true, totp_confirmed_at to the current timestamp.
  4. Disable (self-service) -> POST /api/auth/2fa/disable requires the current password (re-authentication) and clears totp_secret, totp_enabled, totp_confirmed_at.

Login flow

After password verification (never before - see Security notes) and after the local/global account check:

  1. If the instance switch is on, the user has totp_enabled: true, and the client IP is not trusted: POST /api/auth/login does not set the session. It stores a pending state in the session instead (pending_2fa_user_id, a 5-minute expiry) and returns {"status": "2fa_required"}, 200. The session is never permanent in this pending state, regardless of remember_me - the 30-day cookie only applies once fully authenticated.
  2. The login page shows a second step (6-digit code input). POST /api/auth/login/verify-2fa re-checks the local-account/trusted-network restriction (an admin could have changed it while this login was pending), verifies the code against the pending user’s secret and, on success, finishes the login exactly as a normal password-only login would (same session keys, same response shape).
  3. Brute-force guard: up to 5 incorrect codes per user, and the pending state expires after 5 minutes; either condition forces a fresh /api/auth/login call. The attempt count is tracked server-side, keyed by user ID - not in the session cookie - specifically so that starting a new login (which an attacker who already has the password can always do) does not reset it. A 6-digit TOTP code is a much smaller search space than a password, so this endpoint has a tighter limit than password sign-in (see Security notes).
  4. login_required needs no special handling for the pending state: it requires 'username' in session (and that account to exist), and username stays unset throughout - every existing authenticated route stays correctly locked out mid-2FA.

Admin: forced disable

An admin can clear any user’s 2FA from App settings -> Users (shown only when that user has it active), via DELETE /api/users/<user_id>/2fa. No password check - admin authority is the check. Intended for a lost or reset authenticator device.

Lost-authenticator recovery

If a user loses their authenticator device and no other admin is available to disable 2FA for them via the Users panel, anyone with shell access to the host can turn it off from the command line (DATABASE.md):

docker exec -u appuser myastroboard python backend/db/manage.py disable-2fa <username>

Every worker sees the change on its next request (no restart required) - the user can log in with just their password on the next attempt. reset-password <username> recovers a lost password the same way.


Trusted networks

Module: backend/utils/security_settings.py · Stored as: database setting security_settings

Trusted networks are the shared foundation for both two-factor authentication and local/global accounts below: an admin-managed list of CIDR blocks or bare IP addresses, matched against the client IP of each login attempt. Two independent features read the same list for opposite purposes - two-factor authentication trusts the network to skip a step, local/global accounts trust it to allow a step - but they are otherwise unrelated checks.

{
  "trusted_networks": ["192.168.1.0/24"],
  "two_factor_enabled": false
}
  • Each entry is normalized with Python’s ipaddress.ip_network(value, strict=False), so 192.168.1.5/24 and 192.168.1.0/24 are stored as the same, single entry. Both IPv4 and IPv6 are supported.
  • 127.0.0.0/8 and ::1/128 are always implicitly trusted. They are hardcoded and never written to the file, so clearing the configured list can never lock an admin out of localhost access.
  • The client IP is request.remote_addr, which already resolves the real client IP behind a reverse proxy when trust_proxy_headers is enabled (see Application settings) - no separate configuration is needed for trusted networks to work correctly behind a proxy.
  • Docker Desktop / plain docker-compose without a reverse proxy: when the browser reaches the container through Docker’s own port mapping (ports: - "5000:5000") rather than through a reverse proxy, request.remote_addr is the Docker bridge gateway (typically 172.17.0.1 or 172.18.0.1, one fixed address for every host and every LAN client alike), not the browser’s real address - Docker rewrites the packet’s source before it reaches the container, and there is no X-Forwarded-For header at that point for trust_proxy_headers to read. Trusted networks, the 2FA bypass and local-scope logins will not recognize 127.0.0.1 or a LAN CIDR in this setup. Confirm the address your instance actually sees for a given login attempt in data/myastroboard.log ("Password accepted for user ... from <ip>, awaiting 2FA code"), or docker network inspect <compose_project>_default for the bridge subnet. Adding that one address as a trusted network unblocks testing, but note it then trusts every connection reaching the mapped port, not just localhost - the real fix for a LAN or public deployment is a reverse proxy in front (nginx/Traefik) with trust_proxy_headers = true, per 6.REVERSE_PROXY.md.
  • Not included in backups: the security settings are excluded from both /api/backup/download and /api/config/export, for the same reason as trust_proxy_headers - see docs/CONFIGURATION.md.

Managed in App settings -> Users, in the “Trusted networks and two-factor authentication” panel (admin only), or via GET/POST /api/auth/security-settings.

Validation rules (enforced server-side, in POST /api/auth/security-settings)

  1. Each entry must parse as a valid IP network. The whole save is rejected with 400 and error_key: settings.invalid_trusted_network otherwise, naming the bad entry.
  2. two_factor_enabled cannot be set to true while the resulting trusted_networks list is empty. Rejected with error_key: settings.2fa_requires_trusted_network.
  3. If a save removes the last entry from trusted_networks while two_factor_enabled was previously true, it is silently forced to false as part of the same save (not an error - a documented cascade) and a warning is logged. The admin UI shows a confirmation dialog before this happens when removing the last entry, and again when adding an unusually broad entry (see below).
  4. This invariant (rule 2/3) is also enforced as a defensive backstop directly in save_security_settings() itself, not only in the API handler above - no future caller, now or later, can persist the inconsistent pair to disk even if it skips the handler’s own checks.

A network broader than a plausible home/office LAN (an IPv4 prefix narrower than /8, or an IPv6 prefix narrower than /32) is still accepted - an admin may have a genuine reason - but logs a warning and prompts for confirmation in the admin UI before being added, since it is often a typo in the prefix (/24 losing a digit to become /2, for example) rather than an intentional choice, and a too-broad entry silently makes every login on that IP version bypass 2FA and the local-account restriction alike.


Local vs. global accounts

Each account has an account_scope of global (default - can sign in from anywhere) or local (can only sign in from a trusted network). This reuses the same trusted-network list as two-factor authentication above, but for the opposite purpose: trusting the network to allow the login rather than to skip a step.

  • Managed per user from App settings -> Users (“Scope” column and action button), or via PUT /api/users/<user_id> with {"account_scope": "local"}. POST /api/users also accepts account_scope at creation time.
  • Enforced in POST /api/auth/login, after password verification and before the 2FA check: a local account signing in from outside every trusted network (including loopback) is rejected with 403 and error_key: auth.local_account_network_restricted.
  • Fallback rule: when trusted_networks is empty, the local/global check is not performed at all - a local account can sign in from anywhere in that state. Without any configured network, “local” is undefined, and blocking every non-localhost login for every local account would be a silent full lockout. App settings -> Users shows a warning banner when this fallback is active and at least one account is scoped local.

Application settings (admin)

Stored in the database (setting app_settings) via backend/utils/app_settings.py:

SettingDefaultDescription
vapid_contact_email""Contact email embedded in Web Push VAPID tokens (required for iOS push; see NOTIFICATIONS.md)
trust_proxy_headersfalseSet true when behind a reverse proxy sending X-Forwarded-For / X-Forwarded-Proto headers (see 6.REVERSE_PROXY.md)
session_cookie_securefalseSet true to restrict the session cookie to HTTPS connections only (recommended when using HTTPS)
search_engine_indexingfalseSet true to let search engines crawl and index the login page. Instances are private by default (/robots.txt disallows everything, login page is noindex, nofollow)

These are managed in App settings -> Configuration in the admin UI, or via GET/POST /api/admin/app-settings.


Security notes

  • Session cookies are HttpOnly by default (not accessible by JavaScript).
  • Enable session_cookie_secure = true + trust_proxy_headers = true when deploying with HTTPS behind a reverse proxy.
  • Password sign-in is throttled: after 5 failed attempts for the same username from the same IP address, or 20 failed attempts from one IP address across any usernames, within 15 minutes, POST /api/auth/login answers 429 with a Retry-After header until the window has passed. A successful sign-in resets that username’s counter. Counting per IP + username means a stranger cannot lock a user out from the user’s own address. The counters live in memory per worker process (gunicorn -w N multiplies the ceiling by N) and reset on restart, so a reverse proxy with its own rate limit remains a good complement for public-facing deployments. POST /api/auth/login/verify-2fa has its own, tighter 5-attempt, 5-minute limit (see Two-factor authentication).
  • There is no email-based password reset: an admin must reset passwords via the Users panel.
  • Network and 2FA checks in POST /api/auth/login run strictly after password verification. Checking them first would let an unauthenticated caller probe account existence or configuration (local scope, 2FA status) without a valid credential.
  • Two-factor secrets live directly on the User record, the same record and precedent as password_hash (already a sensitive field included in backups) - not in the separate secrets store used for connector credentials, since that pattern exists specifically to keep those out of backups.

Sessions

MyAstroBoard uses Flask server-side sessions (cookie-based, signed with a persistent SECRET_KEY).

The SECRET_KEY is generated once on first startup and stored in the database. It persists across container restarts so existing sessions remain valid; deleting the database invalidates all active sessions.

The session cookie name is session. Session expiry follows Flask defaults (browser session unless SESSION_COOKIE_SECURE is enabled).

A session is only valid while its account exists. When an admin deletes an account, or a backup restore replaces it, the next request from that browser is answered 401 and its session is cleared: the user is back on the sign-in page and reads nothing more, install-wide data included.


User preferences

Each user has a preferences object stored on their account. These are saved via PUT /api/auth/preferences. The user-facing description of each setting is in USER_SETTINGS.md.

PreferenceAllowed valuesDefaultDescription
startup_main_tabforecast-astro, forecast-weather, skytonight, spaceflight, astrodex, equipment, my-settings, parametersforecast-astroWhich tab opens on login
startup_subtab(list of valid sub-tab IDs)astro-weatherWhich sub-tab opens on login
time_formatauto, 12h, 24hautoTime display format (auto follows browser locale)
densitycomfortable, compactcomfortableUI row density
theme_modeauto, light, dark, redautoColour theme (auto follows OS preference)
first_day_of_weekmonday, sundaymondayCalendar and date picker start day
languageen, fr, plus community translationsenInterface language
experience_levelbeginner, intermediate, advancedadvancedFilters SkyTonight recommendations and the Beginner Catalog by difficulty (see BEGINNER_EXPERIENCE.md)
beginner_catalog_enabledbooleantrueShows/hides the “Beginner” SkyTonight sub-tab
recommendations_enabledbooleantrueShows the “Tonight for you” panel at the top of SkyTonight -> Deep Sky Objects
mqtt_publish_enabledbooleanfalseThe user’s consent to publish their own activity (Astrodex counters, plan, log totals) through the MQTT connector (see HOME_ASSISTANT.md)
wizardObject (completed, skipped booleans)both falseGuided setup wizard completion state (see BEGINNER_EXPERIENCE.md)
notificationsObject (see NOTIFICATIONS.md)enabled, defaults per triggerPush and in-browser notification settings; includes disabled_location_ids per-location mutes (v1.2)
locationObject (see LOCATIONS.md)empty attribution, install defaultv1.2 multi-location block: attributed_location_ids (admin-set), default_location_id (user’s “what I see when I connect”), active_location_id (session-scoped, reset to default at login), order

Users storage

All user accounts are stored in the database (data/myastroboard.db, users table - see DATABASE.md). Each account holds:

[
  {
    "user_id": "<uuid>",
    "username": "admin",
    "password_hash": "<werkzeug scrypt hash>",
    "role": "admin",
    "created_at": "2025-01-01T00:00:00+00:00",
    "last_login": "2026-06-01T20:00:00+00:00",
    "preferences": { ... },
    "push_subscriptions": [ ... ],
    "account_scope": "global",
    "totp_secret": null,
    "totp_enabled": false,
    "totp_confirmed_at": null
  }
]

Passwords are hashed using Werkzeug’s generate_password_hash (PBKDF2-SHA256 by default). Plain-text passwords are never stored.

account_scope, totp_secret, totp_enabled and totp_confirmed_at are documented in full under Two-factor authentication and Local vs. global accounts above. Entries written before these fields existed load with account_scope: "global" and the three 2FA fields falsy - no migration script is needed.


Authentication API

MethodPathAuthDescription
POST/api/auth/loginPublicSubmit {"username": ..., "password": ...} -> sets session cookie, or returns {"status": "2fa_required"} when a second step is needed (see Two-factor authentication), 403 when a local-scoped account signs in from an untrusted network or, on a demo instance, for the demo account and any account on the default password, or 429 after too many failures (see Security notes)
POST/api/auth/login/verify-2fapending sessionSecond login step: submit {"code": "123456"} to complete a login that returned 2fa_required
POST/api/auth/logoutloginClears session
GET/api/auth/statusPublicReturns {"authenticated": bool, "role": ..., "username": ..., "two_factor_available": bool, "totp_enabled": bool, "account_scope": ..., "demo_mode": bool} (demo_mode is true for a visitor of a demo instance)
POST/api/auth/change-passwordloginChange own password
GET/api/users/me/exportloginZIP of everything stored about the signed-in user (any role)
GET/api/auth/preferencesloginGet current user’s preferences
PUT/api/auth/preferencesloginUpdate preferences (partial update supported). On a demo instance the demo account’s changes go to the visitor’s session, not the account
POST/api/auth/2fa/setuploginGenerate this user’s TOTP secret, returns {"secret": ..., "otpauth_uri": ...}; 400 if 2FA is already active for this user (disable it first)
POST/api/auth/2fa/confirmloginSubmit {"code": "123456"} to activate 2FA after setup
POST/api/auth/2fa/disableloginSubmit {"password": ...} to turn 2FA off (self-service, re-authenticates)
GET/api/auth/security-settingsadminRead the trusted-network list and the instance-wide 2FA switch
POST/api/auth/security-settingsadminSave the trusted-network list and the instance-wide 2FA switch (validation rules under Trusted networks)

Last updated 2026-11-13

Connectors

Connectors integrate external astronomy tools into MyAstroBoard. Once configured and enabled, a connector’s data appears in the app tabs it declares - AllSky feeds the Observatory tab, MyAstroShine feeds the Astrodex, and a connector may feed no tab at all: the MQTT / Home Assistant connector publishes MyAstroBoard’s own state outward and is standalone.


Configuration

Sub-tab: App settings -> Connectors

Each connector card shows its current status badge (Enabled / Installed / Not installed) and a Configure button to expand the settings panel.

FieldDescription
Display labelCustom name shown in the Observatory tab header
Base URLRoot URL of the external service (e.g. http://192.168.1.42)
Enable connectorToggle to activate this connector and expose it in its target tabs
ModulesPer-feature toggles - each module can be independently enabled

Under the description each card shows an Appears in row: one badge per app tab the connector feeds, or a Standalone badge when it feeds none.

Configuration is stored in the configuration under connectors.<name>, written by POST /api/connectors/<name>/config.

Base URL - use a static IP address

Always configure the base URL with the device’s static IP address (e.g. http://192.168.1.42), not its mDNS hostname (e.g. http://allsky.local).

mDNS (.local) hostnames are resolved by the operating system’s multicast DNS resolver. Inside a Docker container this resolver is not available, causing intermittent Network is unreachable errors as the container attempts to connect over IPv6 or fails to resolve the name at all. The URL field shows a warning when a .local address is detected.

Your router’s DHCP settings or your device’s documentation will show its current IP. Assign a static lease to that device so the IP never changes.

Health check

The test button (wifi icon, next to the URL field) immediately probes the URL you have typed - no save required - and shows Reachable / Unreachable. Use it to verify the IP address and port before saving.

The health-check button (heart icon, after saving) runs a full per-module probe and reports status badges (✓ / ✗) with a detail message (200 OK, 404 + hint, timeout, etc.) for each enabled module.

A module that needs setup on the remote side can declare moduleSetup in _CONNECTOR_UI: the card then shows a collapsible list of steps under that module, each optionally with a value to copy.

The test button also sends the connector’s other fields as typed, so a connector that needs credentials to answer can probe with them before anything is saved; on a connector with a connection picker it probes the picked connection. A connector that runs something in the background can declare a statusEndpoint and actions in _CONNECTOR_UI (static/js/connectors/connectors.js): the card then shows a live status line and action buttons under the save row.


AllSky connector

AllSky is an open-source all-sky camera system. Its images are files served by its web server; its sensor data is published over MQTT by its Publish Data module.

Minimum version: v2024.12 (v2026.10 supported too)

Appears in: Observatory

AllSky versions

Both versions work without reconfiguring:

v2024.12v2026.10 onwards
Live image URL/current/tmp/image.jpg/current/image.jpg (/current/ now serves tmp/current_images)
Publish Data moduleAllSKY Redis/MQTT/REST Data PublishPublish Data to Redis/MQTT/REST/influxDB
Publish Data TLSnone (mqtt:// only)Use SSL switch, on by default
Environment sensorone sensor per module (Dew Heater, Fans)one shared sensor (AS_TEMP, AS_HUMIDITY, AS_DEW)
  • Layout detection: when image_path is current or current/tmp, the other one is tried too. The first that answers is remembered for 5 minutes (per worker), and the health check always probes again. A custom image_path is used as-is.
  • Variable names: Publish Data sends the variables under their AS_ names in both versions. A prefix-less key (TEMPERATURE_C) is still also exposed under its AS_ name, and AS_DAY_OR_NIGHT as DAY_OR_NIGHT.

Modules

SlugLabelDefaultDescription
live_imageLive imageEnabledAuto-refreshing live sky image (30 s interval)
sensor_dataSensor dataDisabledTemperature, humidity, gain, exposure, brightness - received over MQTT from AllSky’s Publish Data module
keogramKeogramEnabledDaily keogram timeline strip (generated end-of-night)
startrailsStartrailsDisabledStacked startrails image (generated end-of-night)
daily_timelapseDaily timelapseDisabledFull-night timelapse video (generated end-of-night)

Settings

FieldDefaultDescription
url-AllSky’s web interface (images)
mqtt_connection_id-The shared MQTT connection the sensor data arrives on - the same broker as Home Assistant or another one
mqtt_topicallskyMQTT Topic of the Publish Data module; an exact topic (no + / #)
image_path (advanced)currentPath to the live image directory, relative to the base URL (current/tmp before AllSky v2026.10 - both are detected automatically)
image_filename (advanced)image.jpgFilename of the live image
client_id (advanced)generatedmyastroboard-allsky-<hex> when blank; must differ from the other connectors on the same connection

Sensor data module

The sensor_data module listens over MQTT to AllSky’s Publish Data to Redis/MQTT/REST/influxDB module (allsky_publishdata, named AllSKY Redis/MQTT/REST Data Publish in v2024.12 - an extra module, installed from the Module Manager). It replaces the Export Allsky Data file earlier releases downloaded, which needed a web-served file location and broke with AllSky v2026.

The connector card shows these steps under the Sensor data module (How to set it up in AllSky), with a copy button for each value:

  1. Install the module and add it to the Day and Night pipelines: it then publishes at every image, with that image’s data (exposure, gain, brightness, day / night).

    • AllSky v2024.12 needs the pipelines: its Periodic jobs run only sees the environment (variables.sh, the Dew Heater / Fans extra files), not the image variables (DAY_OR_NIGHT, AS_GAIN…), which only exist while an image is processed.
    • AllSky v2026 can also run it in the Periodic jobs list (about once a minute, with the last image’s data): the choice when daytime capture is off, since no daytime image means no daytime message.
  2. MQTT tab: Publish to MQTT on; MQTT Host and its port, Username, Password: the broker of the MQTT connection chosen on the card. Enable HA Discovery is optional (it adds AllSky’s own sensors to Home Assistant).

  3. AllSky v2026: turn off Use SSL (Secure Connection) for a broker on mqtt:// (port 1883). v2024.12 has no TLS: its broker must accept mqtt://.

  4. MQTT Topic: the card’s mqtt_topic (allsky by default).

  5. General tab, Extra data to export: the variables to send - only listed variables are published (the default list is empty and sends only a utc timestamp). Only what the sensor card shows; a variable an install does not have is left out of the message.

    • v2026: ticked in the list of the … button (nothing can be pasted there), grouped as in that list. Environment is the shared sensor, so the Dew Heater / Fan temperatures and humidity are not needed:

      GroupVariables
      Image DataAS_DAY_OR_NIGHT, AS_EXPOSURE_US, AS_GAIN, AS_MEAN, AS_TEMPERATURE_C
      EnvironmentAS_TEMP, AS_HUMIDITY, AS_DEW
      Dew Heater (if installed)AS_DEWCONTROLHEATER
      Fan (if installed)AS_FANS_FAN_STATE1, AS_FANS_TEMP_LIMIT1, AS_FANS_PWM_DUTY_PERCENT1
    • v2024.12: a typed field - paste (no shared sensor; the day/night flag and the version are unprefixed, the Fans module publishes OTH_*): DAY_OR_NIGHT,ALLSKY_VERSION,AS_TEMPERATURE_C,AS_GAIN,AS_EXPOSURE_US,AS_MEAN,AS_DEWCONTROLAMBIENT,AS_DEWCONTROLHUMIDITY,AS_DEWCONTROLDEW,AS_DEWCONTROLHEATER,OTH_FANS,OTH_TEMPERATURE,OTH_FANT

  6. Save, then Test Module (Day or Night is only the context of the test): a first message goes out right away and the card’s health check turns green.

The message stays under 15 minutes old as long as the module runs: at every image (or every Periodic jobs run). With daytime capture off and the module in the pipelines only, the card shows no readings during the day.

How the board receives it: connectors/mqtt_subscriber.py (one background thread, one client per connector, own client id) keeps the last message in data/cache/mqtt_last_allsky.json, which GET /api/connectors/allsky/status reads. AllSky’s messages are not retained by the broker, so after a board restart the card waits for the next image (or Periodic jobs run). A message older than 15 minutes (MQTT_STALE_AFTER_SECONDS) is no longer shown. Messages larger than 64 KB, or that are not a flat JSON object, are ignored.

When sensor data is available the Observatory tab shows:

FieldAllSky variable
Dome temperatureAS_TEMP (the environment sensor, AllSky v2026), else AS_DEWCONTROLAMBIENT (Dew Heater), else the first fan’s AS_FANS_TEMPERATURE1 (v2024: OTH_TEMPERATURE)
Camera sensor temperature (hidden when AllSky sends its 0 placeholder)AS_TEMPERATURE_C
HumidityAS_HUMIDITY, else AS_DEWCONTROLHUMIDITY
Dew pointAS_DEW, else AS_DEWCONTROLDEW
Dew heaterAS_DEWCONTROLHEATER
GainAS_GAIN
ExposureAS_EXPOSURE_US, formatted as µs / ms / s
BrightnessAS_MEAN
Fan (state, PWM duty %, threshold)AS_FANS_FAN_STATE1 / 2, AS_FANS_PWM_DUTY_PERCENT1 / 2, AS_FANS_TEMP_LIMIT1 / 2 (v2024: OTH_FANS, OTH_FANT)
Fan control temperature, only when it is not already the dome temperature (same variable or same reading)AS_FANS_TEMPERATURE1 / 2
AllSky version (v2024.12 only: v2026 cannot publish it)ALLSKY_VERSION
Updated (when the board received the readings)the message receive time

AllSky v2026 declares one environment sensor (Environment group: AS_TEMP, AS_HUMIDITY, AS_DEW, AS_TEMPSENSOR) that the Dew Heater and Fans modules reuse, so their readings repeat it: it is read first, so each quantity shows once. v2024 has a sensor per module, read as the fallback.

The Day / Night badge on the live image card is populated from AS_DAY_OR_NIGHT. It is hidden when sensor data is disabled or when the variable is not in the list.

Observatory display

When modules are enabled the Observatory tab renders the following layout:

RowLeftRight
1Live image (auto-refreshes every 30 s)Sensor data (polls every 60 s) - if enabled
2StartrailsDaily timelapse
3Keogram (full width)

Rows 2 and 3 only appear for their respective enabled modules. End-of-night images (keogram, startrails, daily timelapse) show a Not yet generated placeholder until AllSky produces them at the end of the night.

Click any image to open it fullscreen in a zoom modal. The live image modal continues to auto-refresh at the same 30-second interval while open.

A last-updated timestamp is shown below the live image and updates on each refresh cycle. It requires no extra modules.

Proxy

All resource URLs are served through the MyAstroBoard backend at /api/connectors/allsky/proxy?module=<slug>. The browser never contacts the AllSky instance directly - this avoids mixed-content issues when MyAstroBoard is accessed remotely over HTTPS while AllSky runs on a local HTTP server.

Troubleshooting

SymptomLikely causeFix
Images never load, proxy errors in logs.local hostname usedReplace with static IP address
Images load locally but not remotelyBrowser trying to reach AllSky directlyEnsure proxy is not disabled; check MyAstroBoard logs
Day/Night badge not shownsensor_data module disabled, or AS_DAY_OR_NIGHT not in the Publish Data listEnable sensor_data; add AS_DAY_OR_NIGHT to Extra data to export
Keogram / startrails show Not yet generatedEnd-of-night processing not run yetNormal during the night; images appear after AllSky finishes its end-of-night run
Daily timelapse shows empty video playerNo timelapse generated yetNormal; the placeholder appears automatically once AllSky produces the file
No reading received from AllSky yetNo message on the topic: Publish Data not in the Day / Night pipelines, Publish to MQTT off, another broker or topic, or Use SSL on against a mqtt:// brokerFollow the card’s steps, then Test Module; the health check says whether any message arrived and whether the board is connected to the broker
Sensor card nearly emptyExtra data to export empty or short (only utc is sent by default)Paste the list from the card (see Sensor data module)
Readings stop updating after 15 minAllSky stopped publishing (Pi off, no image - daytime capture off -, module removed from the pipelines)The card hides readings older than 15 minutes; check AllSky, or (v2026) also add the module to the Periodic jobs
Live image stopped loading after upgrading AllSkyLive image moved from current/tmp/ to current/Detected automatically; run the health check to refresh the detection immediately

MyAstroShine integration

MyAstroShine is a BaseConnector like AllSky - in the REGISTRY, listed by GET /api/connectors, saved through POST /api/connectors/myastroshine/config, and rendered by the same card. What is specific to it is declared, not special-cased:

class MyAstroShineConnector(BaseConnector):
    min_version = "v0.4.0"
    target_modules = ["astrodex"]
    MODULES = []  # the round-trip is the whole connector
    SECRET_FIELDS = ("token", "signing_secret")
    URL_FIELDS = ("callback_url_override",)
    CONFIG_FIELDS = {"token": "", "signing_secret": "", "callback_url_override": "", "copy_rating": False}

Appears in: Astrodex - not the Observatory, so it has no Observatory panel.

is_configured() requires the two credentials on top of the URL: a URL alone signs no handoff, so the card reports Not installed until all three are set. health_check() probes <url>/api/health; MyAstroShine is LAN-only, so an unreachable result is expected and normal when the board runs on another network, and the card says so rather than showing a plain error.

Its own routes - the handoff and the two cookieless endpoints the MyAstroShine container calls back on - stay in blueprints/connectors_myastroshine.py under /api/astrodex/integration/*. Full documentation: MYASTROSHINE.md.


MQTT / Home Assistant connector

Publishes MyAstroBoard’s state to an MQTT broker with Home Assistant MQTT Discovery: one Home Assistant device for the board, one per location preset (sky conditions, weather, events) and one per opted-in user (Astrodex counters, tonight’s plan and equipment, observation log totals, latest picture). Publish-only. Full documentation, entity tables and topic reference: HOME_ASSISTANT.md.

Minimum version: Home Assistant 2024.11 (device-based discovery)

Appears in: nowhere in MyAstroBoard - Standalone (its output is Home Assistant)

class MqttConnector(BaseConnector):
    target_modules = []
    CONNECTION_FIELD = "mqtt_connection_id"
    CONFIG_FIELDS = {
        "mqtt_connection_id": "",
        "base_topic": "myastroboard",
        "discovery_enabled": True,
        "discovery_prefix": "homeassistant",
        "publish_interval_seconds": 60,
        "client_id": "",
    }

The broker (mqtt://host:1883 or mqtts://host:8883), its credentials and the self-signed certificate switch come from the picked MQTT connection; is_configured() only needs that connection’s URL (anonymous brokers exist). health_check() is one real MQTT connect.

Modules

SlugLabelDefaultDescription
sky_conditionsSky conditionsEnabledSky period, night score, sun and moon times, dark window, best window, top target
weather_nowWeather nowEnabledCurrent weather for astrophotography: clouds, wind, dew risk, seeing, transparency
upcoming_eventsUpcoming eventsDisabledNext event, next ISS / CSS pass, aurora activity, next eclipses
user_activityUser activityDisabledAstrodex counters, tonight’s plan and its equipment, observation log totals - per opted-in user
astrodex_imageLatest Astrodex pictureDisabledThe newest Astrodex picture as an image entity - per opted-in user
board_diagnosticsBoard diagnosticsEnabledVersion, update available, cache and SkyTonight scheduler state, last publish

Advanced settings

FieldDefaultDescription
base_topicmyastroboardRoot of every published topic
discovery_prefixhomeassistantHome Assistant’s discovery prefix
publish_interval_seconds60Publish cycle (minimum 15 s); states go out only when changed
client_idgeneratedMQTT client id, generated once when blank; must differ from other connectors on the same connection

How it runs

connectors/mqtt_publisher.py is a background thread started from app.py like the push scheduler (lock file under data/cache/, one worker owns it). It idles until the connector is enabled, then keeps a paho-mqtt client connected (last will = offline on <base>/status) and runs a publish cycle every interval: connectors/mqtt_payloads.py builds the devices, discovery and state messages are published only when they changed, and topics that are no longer wanted (module off, user opted out, location deleted) receive an empty retained payload so Home Assistant drops them. A trigger file carries the card’s Publish now / Remove from Home Assistant actions to the owning worker; a status file carries the publisher’s state back to GET /api/connectors/mqtt/status.

The three MQTT modules reach feature packages only through lazy imports: connectors/ is imported at module level by cache/ and observation/, so a module-level import back would be a circular import (a test guards this).

Troubleshooting

See HOME_ASSISTANT.md - Troubleshooting.


Astrodex Stream connector

A personal, auto-refreshing photo slideshow of a user’s Astrodex pictures, rendered as a single “current frame” JPEG that any still-image camera viewer (Home Assistant’s Generic Camera integration, a plain <img> tag, …) can poll. Deliberately not a real video stream (no RTSP, no pushed MJPEG) - see ASTRODEX_STREAM.md for the feasibility reasoning. The frame is a pure function of wall-clock time, so there is no background thread and nothing to start or stop when the connector is enabled or disabled. No crossfade between photos: a client polling mid-transition would receive - and keep statically displaying, until its own next poll - one half-blended frame, which would look broken rather than smooth. Which photo is “current” instead shuffles pseudo-randomly (seeded by the feed and the time slot, so every viewer polling at the same moment agrees without any shared state) and never repeats the same photo twice in a row.

Appears in: Astrodex - not the Observatory, so it has no Observatory panel.

class AstrodexStreamConnector(BaseConnector):
    target_modules = ["astrodex"]
    MODULES = []  # the slideshow is the whole connector
    SECRET_FIELDS = ()  # nothing admin-entered - see below
    CONFIG_FIELDS = {"display_seconds": 20, "aspect_ratio": "16:9"}
    ENUM_FIELDS = {"aspect_ratio": ("16:9", "9:16", "4:3", "3:4", "1:1")}

ENUM_FIELDS is a BaseConnector attribute (added for this connector, reusable by any future one): the shared POST /api/connectors/<name>/config handler keeps a submitted value only when it is one of the declared choices, falling back to the field’s CONFIG_FIELDS default otherwise; GET /api/connectors exposes the allowed values as enum_fields so the card can build a <select> from them instead of hardcoding the choices a second time. is_configured() always returns True - there is no external URL or credential, nothing to “install”.

Per-user signing key: rather than storing a secret per user, a stream URL embeds an HMAC-SHA256 token of the user id, keyed by a signing secret generated once on first use (connector secrets store, never entered by an admin - same idea as the auto-generated VAPID keys). Verifying a request just recomputes the token. Rotating the secret (the card’s Rotate keys action, POST /api/connectors/astrodex_stream/rotate) invalidates every URL at once - the only revocation mechanism.

Its own routes live in blueprints/astrodex_stream.py under /api/astrodex/stream/*. Full documentation: ASTRODEX_STREAM.md.


Architecture

Connectors are Python classes that extend BaseConnector (backend/connectors/base_connector.py) and are registered in backend/connectors/__init__.py. Each connector exposes one or more modules - discrete features that can be independently enabled or disabled.

The registry is discovered at runtime and served via GET /api/connectors.

File layout

One module per connector on each side, named after it:

SharedAllSkyMyAstroShineMQTT / Home Assistant
Connectorconnectors/base_connector.pyconnectors/allsky_connector.pyconnectors/myastroshine_connector.pyconnectors/mqtt_connector.py (+ mqtt_publisher.py, mqtt_payloads.py)
Blueprintblueprints/connectors.pyblueprints/connectors_allsky.pyblueprints/connectors_myastroshine.pyblueprints/connectors_mqtt.py
Teststests/blueprints/test_connectors.pytests/blueprints/test_connectors_allsky.pytests/blueprints/test_connectors_myastroshine.pytests/blueprints/test_connectors_mqtt.py, tests/connectors/test_mqtt_*.py

Credentials of every connector (SECRET_FIELDS) live in utils/connector_secrets.py’s store, not in the configuration - see Secrets. A connector that talks MQTT does not declare a broker of its own: it picks a shared connection - see MQTT connections.

blueprints/connectors.py serves the two routes every connector shares - the listing and the config save; a connector’s own routes go in its own blueprint module, registered in backend/app.py.

Declaring a connector

Beyond name / label / description / min_version / homepage, a connector says what it needs through class attributes, so the shared listing, save route and card can handle it without special-casing:

AttributePurpose
MODULESIndependently-toggleable features. Empty is valid - the card then shows no Modules section
target_modulesApp tabs the data lands in (see Target modules). Empty = standalone
CONFIG_FIELDSSettable config keys beyond label / url / enabled / modules, as {key: default}. POST /api/connectors/<name>/config accepts these and nothing else, coercing each value to the type of its default (str, bool or int)
SECRET_FIELDSKeys holding credentials. Stored in the secrets sidecar, masked by GET /api/connectors; a blank or still-masked submission means “keep the stored value”
URL_FIELDSKeys among CONFIG_FIELDS holding a URL, so the save strips their trailing slash
CONNECTION_FIELDConfig key holding the id of the shared MQTT connection the connector uses ("" = none). See MQTT connections

A connector’s own tuning knobs (cache TTLs, size caps, rate limits) are class attributes too, not entries in utils/constants.py - adding a connector should not mean editing a shared file.

is_configured() says what “installed” means (a base URL by default; MyAstroShine also requires its credentials). Only health_check() must be implemented - get_module_urls() and fetch_sensor_data() default to empty, for a connector that serves no browser-fetched resource and no live readings.

Secrets

Credentials never reach the browser. GET /api/connectors is readable by any signed-in user, so it replaces every SECRET_FIELDS value with **** + the last 4 characters and adds a has_<field> boolean; the card shows the masked form as an input placeholder and submits the field blank unless the admin types a new value. The merge happens server-side in POST /api/connectors/<name>/config (admin only), so a value the browser was never given cannot be echoed back and overwrite the real one.

Credentials never sit in the configuration either (v1.6). They live in a separate database setting (connectors_secrets), written by utils/connector_secrets.py in one transaction, and deliberately absent from the backup ZIP and the config export - the same rule the session secret key and the VAPID keys already follow. Consequences:

  • a backup restored on a fresh host needs each connector’s credentials re-entered once;
  • a connector is always constructed from merge_secrets(name, config_block, cls.SECRET_FIELDS), the config block overlaid with the stored secrets (blueprints/connectors.py, observation/myastroshine_integration.get_integration_config, connectors/mqtt_publisher.py);
  • an install upgraded from an earlier version is migrated at startup and on the first save: values still found in the configuration move to the secrets store and are stripped from it.

MQTT connections

A broker is declared once, as a connection (utils/mqtt_connections.py, managed in App settings -> Configuration through blueprints/mqtt_connections.py), and shared by every connector that talks MQTT - the Home Assistant publisher and the AllSky sensor reader today, a Home Assistant sensor reader tomorrow - on the same broker or on separate ones.

  • A connection is {id, name, url, username, tls_insecure} in config["mqtt_connections"]; its password is in the secrets store under mqtt_connection:<id>; API answers only carry a fixed ******** mask (no tail, unlike SECRET_FIELDS tokens).
  • A connector declares CONNECTION_FIELD (mqtt_connection_id for both). Callers build it as cls(block, connection=connection_for(cls, block, config)): the connection ({id, name, url, username, tls_insecure}) is kept apart from the block, so a connector keeps its own url next to the broker (AllSky: its web interface). The password is fetched with connection_password(id) and passed explicitly, never held by the connector.
  • A connector that reads topics lists them in mqtt_subscriptions(); connectors/mqtt_subscriber.py (one background thread, like the publisher) keeps one client per connector and stores its last message, read back with mqtt_subscriber.read_last_message(name).
  • The card shows a connection picker (connectionField in _CONNECTOR_UI): in place of the URL field (connectionReplacesUrl, Home Assistant), or inside one module (connectionModule, AllSky’s sensor_data). GET /api/connectors lists the connections’ names as connection_options.
  • BaseConnector.validate_config(block) lets a connector refuse a save the typed coercion cannot catch (AllSky: a topic with a wildcard).
  • The shared save refuses an unknown connection id, and refuses a client_id already used by another connector on the same connection: each connector opens its own client, and a broker disconnects a client when another one connects with the same id. A blank client id is generated per connector, so it never conflicts.
  • A connection picked by a connector cannot be deleted (409 with used_by).
  • Up to 1.7.1 the MQTT connector held its own url / username / tls_insecure / password. repo_config turns such a block into the fixed-id connection home-assistant on every read (old backups included), and app.py moves its password once at startup.

Target modules

A connector also declares target_modules - the list of app tabs where its data shows up:

class AllSkyConnector(BaseConnector):
    target_modules = ["observatory"]

Slugs match the navbar tab keys (observatory, astrodex, weather, skytonight, …) so the UI can reuse the existing translations; static/js/connectors/connectors.js maps them in _TARGET_MODULE_I18N_KEYS. The connector card renders one translated badge per slug under the description.

The list is purely declarative - nothing is routed or wired from it. An empty list is a valid, meaningful value: it means the connector is self-contained and adds nothing to an existing tab (the MQTT / Home Assistant publisher), and the card then shows a single Standalone badge.


Adding a new connector

  1. Create a class in backend/connectors/<name>_connector.py that extends BaseConnector
  2. Implement health_check(); override get_module_urls() / fetch_sensor_data() only if the connector actually serves browser-fetched resources or live readings
  3. Declare target_modules, and MODULES / CONFIG_FIELDS / SECRET_FIELDS / URL_FIELDS as needed (see Declaring a connector)
  4. Register it in backend/connectors/__init__.py
  5. Add its routes, if any, in backend/blueprints/connectors_<name>.py and register the blueprint in backend/app.py
  6. Add connectors.<name>_label / connectors.<name>_desc to all six static/i18n/*.json, and an entry in _CONNECTOR_UI (static/js/connectors/connectors.js) for its icon and any config inputs beyond the common ones (text, password, url or number inputs, checkboxes, an optional status line and action buttons)

The connector appears automatically in the App settings -> Connectors UI.

Want to suggest a connector? Open a discussion.

Last updated 2026-11-13

MyAstroBoard - Notifications

Browser and push notification system for time-critical astrophotography events.


Using notifications

  1. Open My settings -> Notifications and click Enable notifications; the browser asks for permission.
  2. Turn on the alerts you want, with how long before the event each one fires (and the Kp threshold for aurorae). The triggers cover your Plan My Night session, ISS and CSS transits, eclipses, nightfall, aurorae and multi-day events such as meteor showers.
  3. With several locations, alerts can be muted per location.
  4. Test sends a real push through the server.

Each browser or device subscribes on its own and keeps receiving alerts when the tab is closed. Notifications need a secure address (HTTPS or localhost) and do not work in the Home Assistant sidebar panel; see Where notifications are available, and MOBILE.md for phones and iOS.


Trigger IDs

IDEventDefault leadModule (_check_* fn)
N1Plan My Night session starts15 minplan_my_night.js / utils/push_scheduler.py
N2Plan My Night: next target5 minplan_my_night.js / utils/push_scheduler.py
N3ISS solar or lunar transit10 minorbital_stations.js / utils/push_scheduler.py
N4Lunar eclipse totality30 minevents_alerts.js / utils/push_scheduler.py
N5Solar eclipse maximum30 minevents_alerts.js / utils/push_scheduler.py
N6Astronomical darkness begins20 minsun.js / utils/push_scheduler.py
N7Aurora: Kp index ≥ thresholdimmediateaurora.js / utils/push_scheduler.py
N8CSS (China Space Station) solar or lunar transit10 minorbital_stations.js / utils/push_scheduler.py
N9Solar-system event peak (meteor shower, comet visibility window)2 daysevents_alerts.js / utils/push_scheduler.py

IDs are defined as NOTIF_TRIGGERS constants in static/js/notifications.js and referenced by string in utils/push_scheduler.py. (N8 mirrors N3 for the CSS station.)

N9 is unlike every other trigger: its source events (from solar_system_events cache) carry a start_time..end_time span of several days around peak_time, rather than being a single instant. It still follows the same “notify N before peak” shape as N1-N8, just with a day-granularity lead time (stored in the same lead_minutes preference field, as day-equivalent minutes - e.g. 2 days = 2880) instead of a minutes one. Events whose window is ≤36h (eclipses, transits - already covered by their own dedicated triggers) are skipped by _N9_MIN_WINDOW_SECONDS so N9 only fires for genuinely multi-day events.

The countdown in the body is a calendar-day difference computed in the observing site’s timezone (_n9_days_until, mirroring EventsAggregator.days_until_event), not a rounded duration - a peak 11h away that lands after local midnight reads tomorrow, not in 0 days. Days 0 and 1 use their own i18n keys (push_n9_body_today / push_n9_body_tomorrow, and notifications.n9_body_today / _tomorrow client-side); push_n9_body with {days} covers 2+, which keeps the plural correct in every language.

Multi-location behavior

  • Location-scoped triggers (N3-N8) are evaluated once per (user, attributed location) pair, reading each location’s own cache slots. Plan triggers (N1/N2) stay per-plan - a plan is pinned to its own location.
  • When a user has more than one attributed location, the message body carries the location name (i18n key push_location_suffix, e.g. “… at Dark Sky Site”). Single-location users see messages exactly as before.
  • Per-location mute: preferences.notifications.disabled_location_ids (edited in My settings -> Notifications, only visible with >1 attributed location) removes a location from all trigger evaluation for that user.
  • Deduplication keys are per location (<trigger>@<location_id>), so the same event at two sites notifies once per site.

See LOCATIONS.md for the full multi-location model.


Architecture overview

Three layers, each built on the previous one:

Phase A - Browser Notification API

  • notifications.js - NotificationManager singleton + startNotificationPoller() (5 min interval)
  • Notifications fire when the tab is open; poller runs regardless of which tab is active
  • Fallback only when push is active: if this device has a live Web Push subscription, the server-side scheduler (Phase C) already delivers every trigger, so the in-app poller and notificationManager.notify() both bail out (hasActivePushSubscription()). Without that gate each alert fired twice - once locally, once from the service-worker push handler. Phase A stays active for devices with no working push (plain browser tab, push unsupported, endpoint purged server-side).

Phase B - Settings UI

  • My settings -> Notifications sub-tab (N1-N9 toggles, lead times, Kp threshold, test button)
  • Preferences stored server-side on the account under preferences.notifications

Phase C - Web Push

  • Notifications fire even when the app tab is closed
  • VAPID key pair persisted in the database (setting vapid, generated once on first startup)
  • Push subscriptions stored per-user on the account under push_subscriptions[]
  • Background scheduler (utils/push_scheduler.py) evaluates triggers every 5 minutes

Where notifications are available

AccessIn-app (Phase A)Web Push (Phase C)
HTTPS (reverse proxy) or localhostYesYes
Plain HTTP (e.g. http://192.168.1.10:5000)NoNo
Under a sub-path, e.g. the Home Assistant sidebar panel (ingress)NoNo
  • Plain HTTP: browsers only expose notifications and service workers in a secure context. The settings page shows settings.notifications_insecure_context.
  • Sub-path / HA ingress: sw.js and its cache list are root-scoped, and the ingress panel is a frame inside Home Assistant that cannot be installed. pwa.js skips the service worker, notificationManager.isSupported is false, and the settings page shows settings.notifications_subpath. Preferences saved there still reach the server and apply to the user’s devices subscribed from the direct address. HA users get alerts through the MQTT connector plus a Home Assistant automation (see HOME_ASSISTANT.md).

Background poller (Phase A)

startNotificationPoller() starts a setInterval every 5 minutes after app init (app.js -> initializeApp()). It fetches minimal API data for each enabled trigger and calls the same _check* functions defined in each feature module.

// Called automatically - no manual invocation needed
startNotificationPoller();  // wired in app.js initializeApp()
stopNotificationPoller();   // available but not normally called

The _check* functions are defined at the bottom of their respective feature modules and are global-scope functions callable from anywhere.


Web Push (Phase C)

iOS requirements

Web Push on iOS requires Safari 16.4+ and the app must be installed as a PWA (Add to Home Screen). Push is not delivered to a regular Safari browser tab.

The VAPID contact email must be set to a real email address. Apple APNs silently rejects pushes when the VAPID sub claim contains an invalid domain (e.g. .local). Set it in App settings -> Configuration -> Notifications in the admin UI.

The in-app Test button calls POST /api/push/test, which sends a real server-side push through the full VAPID -> push service -> service worker pipeline. On iOS, background the app immediately after tapping to see the notification appear.

VAPID keys

Generated once on first startup by push_manager.load_or_generate_vapid_keys() and saved in the database (setting vapid):

{ "private_key": "<43-char base64url raw EC scalar>", "public_key": "<87-char base64url uncompressed point>" }

The private key is stored as a raw base64url-encoded 32-byte EC scalar (the format expected by py_vapid.Vapid.from_string()). PEM format is not used - older keys containing -----BEGIN ... KEY----- are automatically migrated to the correct format on startup.

Never delete or regenerate the VAPID keys - doing so invalidates all existing push subscriptions. _subscribeToPush() detects VAPID key rotation via PushSubscription.options.applicationServerKey comparison and forces a transparent re-subscribe if the key changed.

Push subscription flow

  1. User clicks “Enable notifications” -> requestPermission() granted
  2. _subscribeToPush() fetches GET /api/push/vapid-public-key
  3. Browser calls pushManager.subscribe({ applicationServerKey: publicKey })
  4. Subscription POSTed to POST /api/push/subscribe
  5. Stored under the user’s push_subscriptions[] on their account

API routes

MethodRouteAuthDescription
GET/api/push/vapid-public-keyPublicReturns base64url public key for applicationServerKey
GET/api/push/vapid-config-statusPublicReturns whether the VAPID contact email (set in admin UI) is correctly configured
POST/api/push/subscribe@login_requiredStores subscription (deduplicates by endpoint)
DELETE/api/push/unsubscribe@login_requiredRemoves subscription by endpoint
GET/api/push/subscriptions@login_requiredLists subscriptions for the current user. Returns {"subscriptions": [{index, provider, created_at, endpoint_tail}]}. provider is one of apple, google, mozilla, other. Full endpoints are not exposed - only the last 20 chars as endpoint_tail.
DELETE/api/push/subscriptions@login_requiredRemoves all server-side subscriptions for the current user. The UI also calls pushManager.getSubscription().unsubscribe() to clean the browser side. Returns {"removed": N}.
POST/api/push/test@login_requiredSends a generic test push to all subscriptions; removes dead endpoints. Returns {"delivered": N, "total": N, "cleaned": N}
POST/api/push/test/<trigger_id>@login_requiredFires a realistic test push for a specific trigger (N1-N9) with hardcoded sample data, bypassing all condition/cooldown checks. Useful for end-to-end validation. Returns {"trigger", "delivered", "total", "title", "body"}. Fire all from console: ['N1','N2','N3','N4','N5','N6','N7','N8','N9'].forEach((n,i) => setTimeout(() => fetch('/api/push/test/'+n,{method:'POST'}).then(r=>r.json()).then(d=>console.log(n,d)), i*2000))

User model

push_subscriptions is a top-level field on the user object (not inside preferences):

{
  "user_id": "...",
  "preferences": { ... },
  "push_subscriptions": [
    { "endpoint": "https://fcm.googleapis.com/...", "keys": { "p256dh": "...", "auth": "..." }, "created_at": "..." }
  ]
}

Push scheduler (utils/push_scheduler.py)

Daemon thread started at app startup. Polls every 5 minutes:

  • Loads cached data once per cycle (aurora, sun, ISS, CSS, solar/lunar eclipse, solar system events)
  • Loads per-user plan data via get_plan_with_timeline()
  • Skips users with no push subscriptions or notifications disabled
  • Notification title/body are translated using the user’s preferences.language field via i18n_utils.get_translated_message() (keys: settings.push_n*)
  • Sends push via push_manager.send_push() (pywebpush)
  • Dead subscriptions (delivery failure) are automatically removed
  • In-memory cooldowns reset on server restart (acceptable: worst case one duplicate per restart)

sw.js handlers

// push - fires when server sends a push
self.addEventListener('push', event => { ... showNotification() ... });

// notificationclick - focuses app window and navigates to data.url
self.addEventListener('notificationclick', event => { ... });

Push payload shape

{
  "title": "Plan My Night",
  "body": "Your session starts in 14 min",
  "icon": "/static/ico/android/launchericon-192x192.png",
  "badge": "/static/ico/android/launchericon-72x72.png",
  "tag": "N1-20260529",
  "data": { "url": "/#astrodex/plan-my-night" }
}

Settings UI elements

IDTypeRole
#notif-permission-bannerdiv.alertPermission state + push status (two lines when granted)
#notif-enable-btnbutton.btn-warningCalls requestPermission() + _subscribeToPush(); hidden when granted/denied
#notif-master-toggleinput[checkbox]Master enable/disable
#notif-trigger-N1 … #notif-trigger-N9input[checkbox]Per-trigger toggle
#notif-lead-N1 … #notif-lead-N6, #notif-lead-N8, #notif-lead-N9selectLead time (minutes for N1-N6/N8; day-equivalent minutes for N9)
#notif-kp-thresholdselectKp threshold (N7 only)
#notif-save-btnbutton.btn-primaryAsync save to server
#notif-test-btnbutton.btn-outline-secondaryFires a sample notification
#notif-save-messagediv.alertFeedback (auto-hides after 3 s)

NotificationManager API

notificationManager is a global singleton available after notifications.js loads.

Permission

notificationManager.isSupported       // boolean
notificationManager.permission        // 'default' | 'granted' | 'denied' | 'unsupported'
notificationManager.canNotify()       // true if granted AND master toggle enabled

const granted = await notificationManager.requestPermission();
// Also calls _subscribeToPush() automatically on grant

Preferences

Stored server-side on the account under preferences.notifications. Read via window.myastroboardUserPreferences.notifications.

const prefs = notificationManager.getPrefs();
// {
//   enabled: true,
//   permission_asked: false,
//   triggers: {
//     N1: { enabled: true, lead_minutes: 15 },
//     ...
//     N7: { enabled: true, kp_threshold: 5 },
//     N9: { enabled: true, lead_minutes: 2880 } // day-equivalent minutes (2 days)
//   }
// }

await notificationManager.savePrefs(prefs); // async - POSTs to /api/auth/preferences
notificationManager.isTriggerEnabled('N7')
notificationManager.getLeadMinutes('N1')
notificationManager.getKpThreshold()

Deduplication

In-memory, resets on page reload. The background poller uses server-side cooldowns in utils/push_scheduler.py.

if (notificationManager.wasRecentlyNotified('N7', 30 * 60 * 1000)) return;
notificationManager.markNotified('N7'); // called automatically by notify()

Firing a notification (Phase A - tab open)

await notificationManager.notify(
    'N7',
    i18n.t('notifications.n7_title'),
    i18n.t('notifications.n7_body', { kp: '6.0', visibility: 'Good' }),
    { url: '#forecast-astro/aurora' }
);

notify() resolves to false without showing anything when this device has an active Web Push subscription - the server-side scheduler owns delivery in that case (see Phase A note above). _check* functions can still call it unconditionally; the gate lives in one place.


Files

FileRole
static/js/notifications.jsNotificationManager, settings UI, background poller, Web Push subscription
backend/utils/push_manager.pyVAPID key generation/persistence, send_push() wrapper around pywebpush
backend/utils/push_scheduler.pyBackground thread; evaluates N1-N9 server-side every 5 min; sends push
templates/index.htmlNotifications sub-tab (My settings -> Notifications)
static/sw.jspush + notificationclick event listeners
static/i18n/*.jsonnotifications.* namespace (N1-N9 titles/bodies) + settings.notifications_*
database setting vapidGenerated VAPID key pair - never delete or regenerate (invalidates all subscriptions)

Adding a new trigger

  1. Add the ID to NOTIF_TRIGGERS in notifications.js
  2. Add default prefs to _NOTIF_DEFAULTS.triggers in notifications.js
  3. Add a row to #notif-triggers-list in templates/index.html
  4. Add notifications.nX_title and notifications.nX_body keys to all 6 i18n files
  5. Add _check_nX() function in the relevant feature module (called by the background poller)
  6. Add the poller call in _runNotificationChecks() in notifications.js
  7. Add the server-side check in utils/push_scheduler.py
  8. Update the trigger table in this document

i18n namespaces

NamespaceContent
settings.notifications_*UI strings: banner text, button labels, toggle labels, trigger labels
notifications.nX_title / notifications.nX_bodyNotification payload strings (title + body with {placeholder})

Keys in notifications namespace are ordered N1->N7 per trigger, _title before _body.

Last updated 2026-11-13

Database

Since 1.7, MyAstroBoard keeps its persistent state in one SQLite database, data/myastroboard.db, instead of JSON files. This page is for operators (upgrade, backup, recovery) and contributors (schema changes, tests).

What is stored where

In the database (data/myastroboard.db)Still files under data/
Accounts, preferences, 2FA, push subscriptionsAstrodex pictures (astrodex/images/)
Astrodex, observation log, wishlist, equipment, Plan My Night plansSession attachments (observation_sessions/attachments/)
Configuration (locations, SkyTonight, connectors)Caches (cache/) and SkyTonight results (skytonight/)
App settings, security settings, connector secrets, VAPID keys, session secret keyLogs

Caches and SkyTonight results are recomputed by the schedulers, so they stay files.

Upgrading from 1.6

Nothing to do: on the first start of 1.7 the application

  1. archives every 1.6 data file into data/backups/pre-1.7-<date>.zip and checks the archive,
  2. imports them all into the database in a single transaction,
  3. reads every record back and compares it with the original file,
  4. only then deletes the JSON files,
  5. writes a report next to the archive: data/backups/import-report-<date>.txt.

Files that cannot be imported are never lost:

  • a per-user file whose account no longer exists is moved to data/backups/orphans/,
  • an unreadable per-user file (1.6 already treated it as empty) is moved to data/backups/unreadable/.

If users.json or config.json cannot be read, or anything else goes wrong, nothing is imported, no file is deleted, and the instance starts in maintenance mode: every page answers “503” with an explanation, /health keeps answering. Check the log and the import report, fix the cause, restart. You can also go back to the 1.6 image: your files are untouched.

Going back to 1.6 after a successful upgrade

The migration is one-way. To return to 1.6, stop the container, unzip data/backups/pre-1.7-<date>.zip into data/ and start the 1.6 image. Anything changed since the upgrade is not in that archive - take an Admin backup first if you need it.

Deleting the upgrade archive

data/backups/ is never cleaned up on its own: it is the only way back to 1.6. It also holds sensitive data (password hashes, two-factor and connector secrets, the data of accounts deleted since), so delete it once 1.7 is validated: App settings -> Backup / Restore -> 1.7 upgrade archive shows the archive, its size and the files set aside, offers the import report for download, and deletes it all. The section is only shown while there is something to delete; only the files the import wrote are removed, anything else you put in data/backups/ stays.

Backups

App settings -> Backup / Restore still produces a ZIP with the same layout as in 1.6 (config.json, users.json, astrodex/, equipments/, …), readable with any text editor, and restores both 1.7 and 1.6 backups. As before, the security settings, connector secrets, VAPID keys and session key are not in it.

For a file-level copy of the whole database, stop the container first, or copy myastroboard.db together with its -wal and -shm companions.

Diagnostics

App settings -> Metrics -> Database shows the schema version (and whether it matches what the application expects), the database size and its journal mode, with a warning when the write-ahead log is unavailable (data directory on a network share). Check integrity runs SQLite’s integrity and foreign key checks and lists any problem found.

Recovery from the command line

Hand-editing users.json (lost admin password, lost authenticator) is replaced by a small command line, run inside the container as the application user:

docker exec -u appuser myastroboard python backend/db/manage.py status
docker exec -u appuser myastroboard python backend/db/manage.py list-users
docker exec -u appuser myastroboard python backend/db/manage.py reset-password admin
docker exec -u appuser myastroboard python backend/db/manage.py disable-2fa admin

reset-password prints a generated password unless --password is given.

Network shares

SQLite uses a write-ahead log (WAL) that needs a local file system. On NFS or SMB the database falls back to a slower journal mode and logs a warning at start; keep data/ on a local disk (the Home Assistant app always does).

For contributors

Layout

  • backend/db/schema.py - table definitions (SQLAlchemy Core).
  • backend/db/migrations/versions/ - Alembic revisions, applied automatically at start (db/bootstrap.py, under a cross-process lock so only one gunicorn worker migrates).
  • backend/db/documents.py - per-user data, as the dicts the feature modules work on (load_x(user_id) -> dict / save_x(user_id, dict)).
  • backend/db/collections.py - splits those dicts into relational tables, one row per object (Astrodex item and picture, observation session, night, entry and attachment, wishlist item, piece of equipment, combination and its filters/accessories, plan and plan entry), with parent -> child foreign keys and indexes, and assembles them back. Each row also keeps the object as JSON (data), so a field the schema does not know yet is never lost; the other columns are copies used for searching.
  • backend/db/queries.py - the searches across users (delete guards, shared equipment, picture visibility…) as SQL queries.
  • backend/db/settings_store.py - install-wide settings (one JSON value per key).
  • backend/db/users_store.py - accounts.
  • backend/db/legacy_import.py, legacy_sources.py, json_layout.py - the 1.6 import and the JSON file layout shared with the backup and the personal data export.

Writing data

  • Read-modify-write goes in one db.engine.transaction() (BEGIN IMMEDIATE): it serializes the gunicorn workers, so no file lock is needed. Nested calls join the outer transaction. documents.modify_document() and settings_store.modify_setting() wrap the common case.
  • Per-process caches compare a store revision (get_revision()), bumped in the same transaction as every write, to see other workers’ changes - never a file mtime.
  • Deleting an account (users_store.delete_user) deletes all of its rows in the same transaction; foreign keys with ON DELETE CASCADE back it up.
  • A save rewrites the user’s rows of that kind in one transaction; a natural id that appears twice in one list is refused (unique constraint), and the whole save rolls back.
  • A new search across users belongs in db/queries.py (SQL on the indexed columns), never in a loop over every user’s data.

Changing the schema

  1. Edit backend/db/schema.py.

  2. Generate a revision, then read and fix it (SQLite needs batch mode for most ALTERs, already enabled):

    alembic -c backend/db/alembic.ini revision --autogenerate -m "describe the change"
    
  3. Name the file NNNN_<slug>.py with the next number, set revision = 'NNNN', and write a working downgrade().

  4. pytest tests/db/ checks that upgrade/downgrade work and that the migrations match schema.py.

A data change (reshaping rows) is also a revision: use op.get_bind() to read and rewrite rows inside upgrade().

Tests

Every test runs on its own copy of a template database (tests/conftest.py, isolated_database), so tests never share state. Foreign keys are off by default there, since most tests store data for made-up user ids; add the enforce_foreign_keys fixture to a test about account deletion or cascades.

Last updated 2026-11-13

Privacy & GDPR

This page is for people who run a MyAstroBoard instance. It lists what personal data the application stores, where it goes, how long it stays, and which built-in features help you meet the EU General Data Protection Regulation (GDPR). It ends with a privacy notice template you can adapt for your users.

Important

This is a technical description, not legal advice. MyAstroBoard is self-hosted software: the project never receives any of your data. Whoever operates an instance is the data controller for it and remains responsible for its compliance.


Who is responsible?

SituationGDPR status
You run MyAstroBoard for yourself and your householdUsually covered by the household exemption (Art. 2(2)(c)) - the GDPR does not apply.
An astronomy club, association or company runs it for its membersThe GDPR applies. The organisation is the controller; this page and the template below are a starting point.
Someone else hosts it on your behalfThey are your processor; you need a processing agreement with them (Art. 28).

The MyAstroBoard maintainers are neither controller nor processor: the software sends no telemetry and has no analytics, tracking or advertising code.


What personal data is stored

Everything lives in the data/ directory (the Docker volume): records in the SQLite database myastroboard.db (details), pictures and attachments as files named <user_id>_..., where user_id is a random UUID.

DataLocation in data/Notes
Account: username, password hash, role, creation date, last logindatabase, users tablePasswords are hashed (werkzeug); never stored in clear.
Two-factor secret (if enabled)database, users tableTOTP seed, needed to verify codes.
Preferences (language, time format, default/active location…)database, users table
Web Push subscriptionsdatabase, users tableBrowser push endpoint + keys, only if the user enabled notifications.
Observing locations: name, latitude, longitude, timezonedatabase, configurationA location is often the user’s home: treat coordinates as personal data.
Astrodex: observed objects, notes, optional picture coordinatesdatabase, astrodex_items / astrodex_picturesSee Photo metadata below.
Astrodex picturesastrodex/images/
Observation log: sessions, notes, conditionsdatabase, observation_* tables
Observation log attachmentsobservation_sessions/attachments/
Equipment profiles, Plan My Night plans, wishlistdatabase, equipment_*, plans/plan_entries, wishlist_items
Application logmyastroboard.log*Contains usernames and client IP addresses (sign-ins, refused access). See Retention.
Pre-1.7 data archivebackups/pre-1.7-*.zipWritten once by the 1.7 upgrade (every 1.6 data file, secrets included); delete it from App settings -> Backup / Restore once you no longer need to go back to 1.6. Data of accounts deleted later remains in it until then.

The browser keeps only the session cookie and a few display settings in localStorage (language, time format, a photo filter, the last seen app version). None of them is used for tracking.

Photo metadata

Camera and phone pictures usually embed EXIF metadata: the GPS position where the picture was taken (often the observer’s garden), camera serial numbers, sometimes the owner’s name. Because Astrodex pictures and session attachments can be seen by other users of the instance, MyAstroBoard removes this metadata when a picture is uploaded (JPEG, PNG, WebP):

  • removed: EXIF (GPS, serial numbers, owner, dates), XMP, IPTC, comments, PNG text chunks, and secondary images some phones append to the file (HDR gain maps, depth maps);
  • kept: the pixels, byte for byte - nothing is re-encoded, astrophotos keep their full quality -, the ICC colour profile and the orientation;
  • GIF files are stored as sent (the format carries no EXIF/GPS).

Pictures uploaded before this protection existed still carry their original metadata. Non-image attachments (PDF, Word, text) are stored as sent: remove author metadata before uploading them if it matters.

The coordinates a user types manually on an Astrodex picture are different: they are deliberate data used by the photo map. On a new installation the map is private: each user only sees their own pictures. Installations created before this default keep sharing the map with every user whose Astrodex is shared, until an administrator enables Configuration -> Astrodex -> Photo map private (see Astrodex).


Cookies

MyAstroBoard sets a single cookie: the Flask session cookie, which keeps the user signed in (HttpOnly, SameSite=Lax, Secure when enabled in App settings -> Configuration). It lasts until the browser closes, or 30 days when Remember me is ticked. It is strictly necessary for the service, so no consent banner is required (ePrivacy Directive, Art. 5(3)).

On a demo instance (DEMO_MODE=true), the same cookie also carries the visitor’s UI preferences (theme, language, density), since every visitor shares one demo account. Nothing else about the visitor is stored.


Third parties

MyAstroBoard contacts external services to compute forecasts and display maps. None of them receives account data, but some receive coordinates or the user’s IP address.

ServiceWhat it receivesSent byWhy
Open-Meteo (Switzerland/EU)Coordinates of each locationServerWeather forecast
7Timer! (China)Coordinates of each locationServerSeeing / transparency forecast
Nominatim - OpenStreetMap (EU)Coordinates + the browser’s IPBrowserNaming a location, only when the user clicks Use my location
OpenStreetMap tile and vector tile servers (United Kingdom)The map areas viewed, from the server’s IP (never the users’ IP); downloaded tiles are cached on the serverServerBackground of the maps (locations, photo map, observation log, orbital stations)
Browser push services (Google FCM, Mozilla, Apple)An opaque notification payloadServerWeb Push, only for users who enabled notifications
CelesTrak, The Space Devs, NOAA SWPC, JPL, Minor Planet Center, CDS Strasbourg, wheretheiss.atNothing personalServerSatellites, launches, space weather, ephemerides, object images
DSS2 sky survey mirrors: ESA ESASky (Spain/EU), MAST on Amazon S3 (United States), CSIRO CASDA (Australia), China-VO (China)Sky coordinates of catalogue objects, from the server’s IP; nothing about the usersServerObject images and framing backgrounds, only while CDS hips2fits is unreachable
SIMBAD mirrors: CfA Harvard (United States), IDIA (South Africa)Names of the astronomical objects looked up, from the server’s IP; nothing about the usersServerObject information, only while CDS SIMBAD is unreachable
GitHubNothing personalServerCheck for new releases and download their changelog
MyMemoryPublic spaceflight texts onlyServerOn-demand translation of launch descriptions

Optional integrations send data only when an administrator enables them:

  • MQTT / Home Assistant - to your own broker; a user’s data is published only while that user’s own switch is on, location devices carry no coordinates and pictures are sent without location data (details).
  • MyAstroShine - the chosen picture is sent to the MyAstroShine instance configured by the administrator (details).
  • Astrodex Stream - a signed URL that exposes one user’s pictures to whoever holds it (details).
  • AllSky and other connectors - the application reads from them; it does not send user data.

Transfers outside the EU: 7Timer (China) receives location coordinates, and the OpenStreetMap Foundation (United Kingdom) receives the map areas viewed, from the server’s IP. Mention them in your privacy notice.


Retention

DataKept until
Account and all per-user filesThe account is deleted (see below).
LocationsAn administrator deletes them.
Application log90 days by default: older lines are removed once a day (App settings -> Logs, 0 to 3650 days; 0 keeps only the size limit of 10 MB x 6 files).
Backups downloaded from ConfigurationUnder your control, outside the application: they contain everything listed above.

Data subject rights

RightHow to fulfil it today
Access (Art. 15)My settings -> Security -> Your data downloads a ZIP of everything stored about the user; an administrator can download the same archive for any user from App settings -> Users -> Export data.
Rectification (Art. 16)Users edit their own data; an administrator edits accounts and locations.
Erasure (Art. 17)An administrator deletes the account in Users. This removes the account and all of its data: Astrodex and pictures, observation sessions and attachments, equipment, plans and wishlist. Log lines mentioning the user remain until rotation.
Portability (Art. 20)The same ZIP: account and preferences, locations, Astrodex, observation log, equipment, plans and wishlist as JSON, with pictures and attachments as the original files. Password hashes and two-factor secrets are left out.
Objection / restrictionHandled by the administrator (disable the account, remove a location).

Accounts deleted before full erasure existed may have left files behind. The 1.7 upgrade moved such leftovers (data of accounts that no longer exist) to data/backups/orphans/; delete that folder once checked. Pictures and attachments of such accounts can be found by comparing the <user_id> prefixes of the files in astrodex/images/ and observation_sessions/attachments/ with python backend/db/manage.py list-users.


Security measures

  • Password hashing, optional two-factor authentication (TOTP), and throttled sign-in: 5 failed passwords per username and address, or 20 per address, within 15 minutes (details).
  • Session cookie HttpOnly, SameSite=Lax, optional Secure flag; serve the instance over HTTPS behind a reverse proxy (guide).
  • Role-based access (admin / user / read-only) and local-only accounts restricted to trusted networks (details).
  • Private by default towards search engines: robots.txt disallows crawling and the login page is noindex unless you opt in (App settings -> Configuration -> Privacy & search engines).
  • Connector secrets (MQTT connection passwords included) are stored apart from the configuration, with owner-only file permissions, and never appear in exports or API responses.
  • All front-end libraries are served locally: no CDN, font or analytics request leaves the browser except the geocoding listed above (map tiles go through the server).

Protect the data/ volume and your backups: they hold everything on this page.


Privacy notice template

Adapt the bracketed parts and publish the notice where your users can read it (club website, sign-up email…).

Privacy notice - [Instance name]

Controller: [Club / association name], [address], contact: [email].

What we collect: your username, a hashed password, your preferences, the observing
locations you record (name and coordinates), and the content you add (Astrodex pictures
and notes, observation logs, equipment, plans, wishlist). Our server logs record your
username and IP address when you sign in, for security.

Why: to provide the MyAstroBoard service you signed up for (Art. 6(1)(b) GDPR) and to
keep it secure (legitimate interest, Art. 6(1)(f)).

Who can see it: administrators of this instance; other members only for what you share
(shared Astrodex, photo map). Pictures are stripped of their GPS metadata on upload.

Third parties: weather providers (Open-Meteo, 7Timer! - China) receive the coordinates
of the observing locations; map tiles (OpenStreetMap - United Kingdom) are loaded by our server, and
place names (OpenStreetMap) by your browser. [Add enabled integrations: MQTT, MyAstroShine...]

Retention: your data is kept while your account exists and deleted with it. Server logs
are kept for [90] days.

You can download all your data at any time from My settings -> Security -> Your data.

Your rights: access, rectification, erasure, portability, objection. Contact [email].
You may lodge a complaint with your data protection authority ([e.g. CNIL]).

Cookies: one session cookie, strictly necessary to keep you signed in.

Last updated 2026-11-13

Demo mode

Demo mode turns a MyAstroBoard instance into a public, read-only showcase. A visitor opens the address and lands straight in the application, signed in on a shared demo account. Everything is live (tonight’s sky, the weather, SkyTonight targets), but nothing a visitor does is saved.

It is meant for one public demo of the project, not for sharing your own observatory: use a separate instance with its own data, never your personal one.

What a visitor gets

  • No sign-in. Opening / (or /login) signs the visitor in on the demo account. A banner at the top of the page says it is a demo and links to the installation guide.
  • Read-only. Every request that would change stored data (POST, PUT, PATCH, DELETE) is refused with a 403 and the code demo_read_only; the page shows “Read-only demo: changes are not saved”. The check runs on the server, before any route, so no button can get around it.
  • Calculations still work. A few non-GET endpoints store nothing and stay open: the FOV calculator, coordinate conversion and the equipment recommendations for a target (DEMO_ALLOWED_WRITE_ENDPOINTS in backend/utils/demo_mode.py).
  • Own preferences. Theme, language, density and other UI preferences are kept in the visitor’s session cookie, not on the shared account, so one visitor never changes what the next one sees. Location, notification and MQTT preferences stay as you set them.
  • No notifications. No visitor is ever asked for notification permission, the Notifications settings tab is hidden, and the push notification scheduler does not run.
  • No setup wizard, no location switching, no on-demand translation (it would use your DeepL / LibreTranslate quota).

Only the demo account is restricted. Another account signed in on the same instance (yours, preparing the data) works normally.

Demo data that keeps living

An empty Astrodex shows nothing, so demo mode fills the demo account by itself and keeps it moving (backend/demo/demo_life.py, a background check every 10 minutes):

  • Location. A fresh install (no location set yet) gets the Observatoire de Haute-Provence (43.9308 N, 5.7133 E, 650 m): a public, well-known site. A location you set yourself is kept.
  • Seed, once, when the demo account holds no equipment and no Astrodex item: two equipment combinations (a Seestar S50, and an 80 mm refractor with an ASI533MC), eight classic targets in the Astrodex with one picture each, and a wishlist.
  • Every night, for each combination: the previous plan becomes an Observation Log session (frames and integration filled in), its best target joins the Astrodex with a picture, and a new Plan My Night is built from tonight’s SkyTonight results - the best-scoring targets that fit the combination’s field and are not captured yet - then ordered by the plan optimizer. The night changes when SkyTonight’s morning run computes the next night.
  • Every check (every 10 minutes): an Astrodex item still without its picture, because no survey server answered when it was added, gets another try (three items per check, stopping at the first failure). Constellations stored as IAU abbreviations by older versions are expanded to the full names the UI translates.
  • History window: logged nights older than 60 days are deleted, with the Astrodex items they added and their pictures (the seed stays). However long the demo runs, it holds about 120 Astrodex items and 40 MB of pictures at most.

The pictures are not amateur photos. Each is a DSS2 colour survey cut-out (CDS / STScI), framed to the combination’s field, and its notes say so. The acquisition figures (frames, integration, rating) are illustrative values derived from the plan, and each logged session says so in its notes.

A demo account you prepare by hand (its own equipment, under other names) is never touched: no seed and no nightly plans. To get the generated data back, delete the demo account with demo mode off; it is recreated, empty, at the next start in demo mode.

Settings

VariableDefaultMeaning
DEMO_MODEofftrue (or 1, yes, on) turns demo mode on
DEMO_USERNAMEdemoThe shared account visitors are signed in on. Created at startup or on first visit (role user, random password, wizard marked done) if it does not exist

Both are environment variables on purpose: demo mode is a property of the deployment, and an instance in demo mode has no admin on the public side to switch it off.

On a demo instance, two kinds of password sign-in are always refused:

  • the demo account itself (it is only signed in automatically);
  • any account still on the default password (admin / admin): on a public address, that would hand the instance to anyone who tries it.

The real sign-in form stays reachable at /login?signin for your own account.

Installing a demo instance

1. Run a separate container

Give the demo its own data directory and port. Do not point it at your personal data/: it holds your home location, your pictures and your accounts.

services:
  myastroboard-demo:
    image: myastroboard/myastroboard:latest
    pull_policy: always
    container_name: myastroboard-demo
    ports:
      - "5050:5000"            # any free port; 5000 is your own instance
    volumes:
      - ./data-demo:/app/data
    environment:
      - TZ=UTC
      - DATA_DIR=/app/data
      - DEMO_MODE=false        # off for the first start only (step 2)
      # - DEMO_USERNAME=demo
    restart: unless-stopped
    privileged: false
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:5000/health || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
docker compose -f docker-compose-demo.yml up -d

2. Secure the admin account (demo mode off)

Open http://<host>:5050 from your local network and:

  1. Sign in as admin / admin and change the password. Demo mode refuses the default password, so without this step the admin account stays locked out once demo mode is on.

  2. Protect the admin account (App settings -> Users -> Trusted networks and two-factor authentication): add your local network as a trusted network, then set the admin account’s scope to local. It can then only sign in from your network, never through the public address.

  3. Trust the proxy headers (App settings -> Configuration -> Reverse proxy) when the public address goes through a tunnel or reverse proxy. Without it every visitor shares the proxy’s IP address: one visitor’s failed sign-ins would throttle everyone, and if the proxy’s address is inside a trusted network, every visitor would count as local. The startup log warns when demo mode runs without trusted proxy headers.

    Leave HTTPS-only session cookie off while you reach the instance at http://<host>:5050: a browser drops a Secure cookie on a plain-HTTP page, so the admin, who signs in from the local network only, could no longer sign in or out. Turn it on only once every address you use is HTTPS, local one included; the setting refuses to turn on from a plain-HTTP page.

  4. Leave Search engine indexing (App settings -> Configuration -> Privacy & search engines) as you want the demo to be found or not.

Do not set a location or create the demo account: demo mode does both (next step).

3. Turn demo mode on

Set DEMO_MODE=true in the compose file, then:

docker compose -f docker-compose-demo.yml up -d

The log shows DEMO_MODE is on at startup. Open the address in a private window: you land in the application with the demo banner, and any save shows the read-only notice. The demo data appears within a few minutes (next section); tonight’s plans once SkyTonight has finished its first calculation.

4. Expose it

Publish the container’s port (5050 above) through your tunnel or reverse proxy of choice. See Reverse Proxy & HTTPS Setup for the general rules. What the demo needs from it: HTTPS, the client IP forwarded in X-Forwarded-For, and nothing else (no extra authentication in front, or visitors would hit a sign-in page again).

Updating the demo

  • New version: docker compose -f docker-compose-demo.yml pull then up -d. Demo mode and the data carry over.
  • Changing the demo data by hand: the demo account cannot sign in and cannot write while demo mode is on. Set DEMO_MODE=false, restart, sign in as the demo account from your local network (set its password in App settings -> Users first), make the changes, then switch it back on. Renaming or removing the generated combinations stops the nightly plans for them.
  • Admin tasks (logs, metrics, users): sign in at /login?signin from your local network while demo mode stays on.

Privacy

  • Everything the demo account holds is public by design. Put nothing personal in it.
  • The survey pictures come from CDS hips2fits (or its DSS2 mirrors), fetched by the server with sky coordinates only, like the object images of any install (see PRIVACY.md).
  • Visitors leave their IP address in the application log (trimmed after the log retention period, App settings -> Configuration -> Privacy & search engines) and in your tunnel or proxy’s logs.
  • The only cookie is the session cookie, which here also carries the visitor’s UI preferences (see PRIVACY.md).

Implementation

  • backend/utils/demo_mode.py - the switch, the write guard, the demo account and the session-scoped preferences.
  • backend/app.py - block_demo_writes() (before_request), the automatic sign-in in index(), the /login redirect, and the demo_mode template flag.
  • backend/blueprints/auth.py - refused password sign-ins, demo_mode in /api/auth/status, session-scoped GET / PUT /api/auth/preferences.
  • backend/demo/demo_life.py - the demo location, the seed and the nightly plans and logs, started from backend/app.py with the other schedulers.
  • Frontend: the banner in templates/index.html, DEMO_MODE in static/js/api_helper.js, the read-only notice in static/js/auth.js, the wizard skip in static/js/first_run.js.

Last updated 2026-11-13

Organization

How the repository and the runtime data are laid out, and how a request and a SkyTonight run flow through them. Read this before adding a module; the rules for where new code goes are in EXTENDING.md.

Architecture at a glance

                        Browser (static/js, one module per feature)
                                        |
                                        v
  backend/app.py  ->  backend/blueprints/*.py  (every HTTP route, one Blueprint per domain)
                                        |
             +--------------------------+---------------------------+
             v                          v                           v
   feature packages              cache/cache_store         db/ (SQLite, Alembic)
   (skytonight, observation,     per-location JSON          accounts, configuration,
    equipment, weather, ...)     slots with a TTL each      every per-user record
             ^                          ^
             |                          |
   skytonight_scheduler          cache_scheduler            push_scheduler, mqtt_publisher,
   (twice a night, writes        (every 25 min, runs only   mqtt_subscriber
    data/skytonight/)             the jobs whose TTL ran out) (background threads)
  • Routes never compute anything heavy: they read what the background threads wrote, from the cache or from data/skytonight/.
  • Feature packages never import blueprints; a helper needed by two features lives in utils/.
  • Per-user data lives in the database, never in a JSON file (DATABASE.md, PRIVACY.md).

Directory structure

myastroboard/
├── backend/
│   ├── app.py                       # Flask app factory: settings, extensions, blueprint registration,
│   │                                #   startup and background threads (no routes)
│   │
│   ├── blueprints/                  # HTTP routes, one Blueprint per domain
│   │   ├── admin.py                 # /api/admin/*, /api/metrics, /api/backup/*, /api/logs/*, /api/config/export
│   │   ├── astrodex.py              # /api/astrodex/*, /api/beginner-catalog
│   │   ├── astrodex_stream.py       # /api/astrodex/stream/* (signed image URLs for Home Assistant)
│   │   ├── astronomy.py             # /api/sky-widget, /api/sun/*, /api/events/*, /api/astro/*, /api/tonight/best-window
│   │   ├── auth.py                  # /api/auth/*, /api/users/*
│   │   ├── connectors.py            # /api/connectors (registry listing, shared config save)
│   │   ├── connectors_allsky.py     # /api/connectors/allsky/*
│   │   ├── connectors_mqtt.py       # /api/connectors/mqtt/*
│   │   ├── connectors_myastroshine.py # /api/connectors/myastroshine/*, /api/astrodex/integration/*
│   │   ├── equipment.py             # /api/equipment/*
│   │   ├── locations.py             # /api/config, /api/locations/*
│   │   ├── misc.py                  # /api/skyquality, /api/convert-coordinates, /api/timezones, /api/health, /api/cache, /api/version
│   │   ├── mqtt_connections.py      # /api/mqtt-connections/*
│   │   ├── observation_sessions.py  # /api/observation-sessions/*
│   │   ├── plan_my_night.py         # /api/plan-my-night/*
│   │   ├── push.py                  # /api/push/*
│   │   ├── session_analytics.py     # /api/session-analytics/*, /api/wishlist/*
│   │   ├── skytonight_api.py        # /api/skytonight/*, /api/catalogues
│   │   ├── tracking.py              # /api/object/*, /api/object-image/*, /api/iss/*, /api/css/*, /api/spaceflight/*
│   │   └── weather.py               # /api/weather/*, /api/moon/*, /api/aurora/predictions, /api/seeing-forecast
│   │
│   ├── skytonight/                  # SkyTonight calculation pipeline (docs/SKYTONIGHT.md)
│   │   ├── skytonight_bodies.py     # Moon and planets
│   │   ├── skytonight_calculator.py # Observability, AstroScore, difficulty
│   │   ├── skytonight_catalogue_builder.py # Deep-sky dataset assembly from every catalogue
│   │   ├── skytonight_comets.py     # Comets (MPC primary, JPL fallback)
│   │   ├── skytonight_models.py     # Dataset dataclasses
│   │   ├── skytonight_scheduler.py  # Run timing (nautical dawn/dusk, 6 h fallback)
│   │   ├── skytonight_scheduler_manager.py # Multi-worker scheduler coordination
│   │   ├── skytonight_storage.py    # Runtime files under data/skytonight/
│   │   └── skytonight_targets.py    # Dataset access, name resolution, lookup table
│   │
│   ├── observation/                 # Logbook, planning and events
│   │   ├── astrodex.py              # Astrodex items and pictures
│   │   ├── astrodex_stream.py       # Current frame of a user's (or the shared) slideshow
│   │   ├── beginner_catalog.py      # Curated starter list
│   │   ├── catalogue_aliases.py     # Cross-catalogue name matching
│   │   ├── catalogue_collection.py  # Catalogue Collection (caught / not caught per catalogue)
│   │   ├── hips_cutout.py           # Local sky cut-out from HiPS tiles when CDS hips2fits is down
│   │   ├── myastroshine_integration.py # Astrodex <-> MyAstroShine round-trip
│   │   ├── object_info.py           # Single-object lookup (SIMBAD, local catalogue)
│   │   ├── observation_sessions.py  # Observation Log
│   │   ├── plan_my_night.py         # Plan My Night, optimizer, CSV/PDF export
│   │   ├── planetary_events.py      # Conjunctions, oppositions, elongations
│   │   ├── session_analytics.py     # Your Sky (read-only aggregation of the Observation Log)
│   │   ├── sidereal_time.py         # Local sidereal time
│   │   ├── sky_framing.py           # Framing simulator: target resolution, sky images
│   │   ├── solar_system_events.py   # Meteor showers, comets from the live dataset
│   │   ├── special_phenomena.py     # Equinoxes, solstices, zodiacal light, Milky Way
│   │   ├── target_coordinates.py    # Frozen target record -> RA/Dec
│   │   ├── visibility_calendar.py   # Best months for a fixed target
│   │   └── wishlist.py              # Wishlist
│   │
│   ├── equipment/
│   │   ├── equipment_profiles.py    # Telescopes, cameras, mounts, filters, accessories, combinations, FOV
│   │   ├── exposure_math.py         # Sky background and integration time (docs/EXPOSURE_CALC.md)
│   │   └── framing.py               # Sensor geometry of a combination against a target
│   │
│   ├── astroweather/                # Astronomical forecasts
│   │   ├── aurora_predictions.py    # Kp forecast, aurora visibility
│   │   ├── horizon_graph.py         # Sun and Moon altitude over the day
│   │   ├── moon_astrotonight.py     # Best imaging window (3 modes, one pass)
│   │   ├── moon_calendar.py         # Monthly Moon phase calendar
│   │   ├── moon_eclipse.py          # Next lunar eclipse
│   │   ├── moon_phases.py           # Phase, rise/set, dark window
│   │   ├── moon_planner.py          # Next nights
│   │   ├── seeing_forecast_7timer.py # 7Timer seeing and transparency
│   │   ├── sun_eclipse.py           # Next solar eclipse
│   │   └── sun_phases.py            # Sunrise, sunset, twilights
│   │
│   ├── weather/                     # Weather and astrophotography conditions (docs/WEATHER.md)
│   │   ├── sky_quality.py           # Bortle / SQM
│   │   ├── weather_astro.py         # Astro-analysis metrics and alerts
│   │   ├── weather_openmeteo.py     # Open-Meteo client
│   │   └── weather_utils.py         # Cached Open-Meteo client factory
│   │
│   ├── space/                       # Spaceflight (docs/SPACEFLIGHT.md)
│   │   ├── css_passes.py            # China Space Station passes and transits (mirror of iss_passes.py)
│   │   ├── iss_passes.py            # ISS passes, solar and lunar transits
│   │   └── spaceflight_tracker.py   # Launch Library 2: launches, astronauts, events
│   │
│   ├── connectors/                  # Bridges to external tools (docs/CONNECTORS.md)
│   │   ├── base_connector.py        # Connector contract and registry
│   │   ├── allsky_connector.py      # AllSky camera
│   │   ├── astrodex_stream_connector.py # Astrodex Stream
│   │   ├── myastroshine_connector.py # MyAstroShine
│   │   ├── mqtt_connector.py        # MQTT / Home Assistant: declaration and broker probe
│   │   ├── mqtt_payloads.py         # Home Assistant discovery and state payloads
│   │   ├── mqtt_publisher.py        # Publisher thread
│   │   └── mqtt_subscriber.py       # Subscriber thread (AllSky sensor data)
│   │
│   ├── cache/                       # Background cache (docs/CACHE_SYSTEM.md)
│   │   ├── cache_scheduler.py       # Scheduler thread
│   │   ├── cache_store.py           # Per-location cache slots, TTLs, change detection
│   │   └── cache_updater.py         # One update function per job
│   │
│   ├── db/                          # SQLite storage (docs/DATABASE.md)
│   │   ├── engine.py, schema.py     # Engine, transactions, table definitions
│   │   ├── migrations/, migrate.py, alembic.ini # Alembic schema migrations
│   │   ├── bootstrap.py             # Brings the database up to date at startup
│   │   ├── users_store.py, settings_store.py # Accounts, install-wide settings
│   │   ├── documents.py, collections.py, queries.py # Per-user records
│   │   ├── legacy_import.py, legacy_sources.py, json_layout.py # 1.6 JSON import, backup layout
│   │   ├── health.py                # Diagnostics for App settings -> Metrics
│   │   └── manage.py                # Recovery command line (reset a password, disable 2FA)
│   │
│   ├── demo/
│   │   └── demo_life.py             # Demo data that keeps living (DEMO_MODE=true, docs/DEMO_MODE.md)
│   │
│   ├── utils/                       # Cross-cutting helpers
│   │   ├── __init__.py              # Generic helpers (JSON encoding, slugify_location_name)
│   │   ├── app_settings.py          # Application settings, session key, VAPID keys
│   │   ├── auth.py                  # Accounts, roles, sessions, preferences
│   │   ├── backup_archive.py        # Backup ZIP build and restore
│   │   ├── config_defaults.py       # Default configuration
│   │   ├── connector_secrets.py     # Connector credentials (kept out of the configuration)
│   │   ├── constants.py             # Paths, TTLs, URLs, limits
│   │   ├── constellation_names.py   # IAU abbreviation -> full name
│   │   ├── demo_mode.py             # Demo mode switches
│   │   ├── events_aggregator.py     # Unified upcoming events feed
│   │   ├── file_cache.py            # Size cap for on-disk image caches
│   │   ├── file_lock.py             # Cross-process lock for files shared by gunicorn workers
│   │   ├── i18n_utils.py            # Server-side translations
│   │   ├── image_privacy.py         # EXIF stripping of uploads
│   │   ├── ingress.py               # Home Assistant ingress prefix
│   │   ├── json_settings_store.py   # Small admin-managed settings dicts
│   │   ├── logging_config.py        # Centralized logger (get_logger)
│   │   ├── map_tiles.py             # OpenStreetMap resources proxy and cache
│   │   ├── metrics_collector.py     # System metrics
│   │   ├── mqtt_connections.py      # Shared MQTT broker connections
│   │   ├── on_demand_translate.py   # DeepL / LibreTranslate for third-party text
│   │   ├── push_manager.py          # VAPID keys, Web Push sending
│   │   ├── push_scheduler.py        # Push notification triggers N1-N9
│   │   ├── rate_limit.py            # Sliding-window counter (sign-in throttle)
│   │   ├── repo_config.py           # Configuration load/save, location presets
│   │   ├── route_helpers.py         # Active-location resolution for routes
│   │   ├── security_settings.py     # Trusted networks, global 2FA switch
│   │   ├── system_info.py           # Host summary logged at startup
│   │   ├── txtconf_loader.py        # Repository metadata text files
│   │   ├── user_data.py             # Per-user files: erasure and personal data export
│   │   └── version_checker.py       # GitHub release check
│   │
│   └── catalogues/                  # Static catalogue data (JSON): Pensack 500, LBN, Gary Imm, Arp,
│                                    #   Sharpless, Barnard, vdB, Abell PNe, Abell clusters, beginner list
│
├── static/
│   ├── js/                          # One module per feature (snake_case), connectors/ for connector cards
│   ├── css/                         # bs_main.css plus one bs_<feature>.css per feature
│   ├── i18n/                        # en.json (reference), fr, es, de, it, pt
│   ├── data/equipment_presets.json  # Gear presets for the wizard and the Equipment tab
│   ├── map-styles/                  # Vector basemap styles (built by scripts/build_map_styles.py)
│   ├── vendor/                      # Vendored libraries: Bootstrap, Bootstrap Icons, Chart.js, Leaflet,
│   │                                #   MapLibre GL, Plotly, qrcode (no CDN at runtime)
│   ├── img/, ico/                   # Images and app icons
│   ├── manifest*.webmanifest        # PWA manifests, one per language
│   ├── offline.html                 # Offline fallback page
│   └── sw.js                        # Service worker (app shell cache, Web Push)
│
├── templates/
│   ├── index.html                   # Main page (every tab)
│   └── login.html                   # Sign-in page
│
├── tests/                           # Mirrors backend/ (tests/skytonight/, tests/observation/...)
│   ├── conftest.py                  # Shared fixtures, temporary DATA_DIR
│   ├── e2e/                         # Browser tests (Playwright)
│   └── test_docs.py                 # Documentation checks (links, API inventory, punctuation)
│
├── scripts/
│   ├── build_map_styles.py          # Basemap styles
│   ├── build_skytonight_catalogue.py # Offline deep-sky dataset build
│   ├── check_cpu_compat.sh, cpu_compat_smoke.py # x86-64 baseline check of the image
│   ├── minify_static.py             # CSS/JS minification for the image
│   ├── translate_checker.py, validate_i18n.py # i18n parity checks
│   └── translate_i18n_values.py     # Machine translation of new keys
│
├── docs/                            # Documentation (index: docs/README.md)
├── CHANGELOG.md, ROADMAP.md, CONTRIBUTING.md, SECURITY.md, DOCKER_HUB.md
├── Dockerfile, entrypoint.sh, docker-compose.yml, docker-compose-dev.yml
├── pyproject.toml                   # ruff configuration
├── pyrightconfig.json, pytest.ini
├── requirements.txt, requirements-dev.txt
└── VERSION

Runtime data (data/)

data/
├── myastroboard.db                  # SQLite: accounts, configuration, settings, session and VAPID keys,
│                                    #   Astrodex, equipment, plans, observation log, wishlist
├── myastroboard.log (+ .1 ... .5)   # Application log, rotated at 10 MB
├── astrodex/images/                 # Astrodex pictures (<user_id>_...)
├── observation_sessions/attachments/ # Observation Log attachments (<user_id>_...)
├── backups/                         # Pre-1.7 JSON archive and import report (1.7 upgrade only)
├── cache/
│   ├── astro_cache.json             # Shared cache slots ("<job>:<location_id>")
│   ├── iers/, skyfield/             # Earth orientation data, planetary ephemeris
│   ├── map_tiles/                   # OpenStreetMap tiles, glyphs, sprites
│   ├── object_images/, framing_images/ # DSS2 sky images (capped at 200 MiB)
│   └── spaceflight_images/          # Launch and astronaut pictures
└── skytonight/
    ├── catalogues/targets.json      # Built deep-sky dataset
    ├── calculations/                # Tonight's results (dso, bodies, comets, sky map)
    ├── outputs/                     # Altitude-vs-time series per target
    ├── logs/last_calculation.log    # Last run log
    └── runtime/                     # Scheduler status, trigger file, lock

Never delete data/myastroboard.db (or its -wal / -shm companions while the app runs): it also holds the session key and the VAPID keys, and a new VAPID key silently breaks every push subscription.

Key data flows

Browser request
  └─ backend/blueprints/<domain>.py
       ├─ @login_required / @admin_required (utils/auth.py)
       ├─ location of the request: utils/route_helpers._resolve_active_location()
       ├─ cache hit  -> cached JSON for that location (cache/cache_store.py)
       └─ cache miss -> "pending" until the background scheduler fills the slot

cache/cache_scheduler.py (every 25 min)
  └─ cache/cache_updater.py: each job x each location whose TTL ran out
       ├─ astroweather/* (moon, sun, eclipses, aurora, horizon, seeing)
       ├─ weather/* (Open-Meteo, astro-analysis), space/* (ISS, CSS, launches)
       └─ observation/* (planetary events, phenomena, solar system events)

skytonight/skytonight_scheduler.py (startup, 1 h after nautical dawn, 1 h before nautical dusk)
  └─ skytonight_calculator.run_calculations()
       ├─ targets.json + comets + bodies
       ├─ observability, AstroScore, difficulty per target
       └─ data/skytonight/calculations/*.json, outputs/<target_id>_alttime.json

Last updated 2026-11-13

Extending MyAstroBoard

MyAstroBoard is an opinionated core, not a plugin platform (see ROADMAP.md - Architecture direction). You extend it through a small set of narrow, well-bounded extension points. Each is a self-contained unit a contributor can add, and a maintainer can review, without having to learn the whole codebase.

If what you want to add is a whole new vertical feature - its own tab, its own storage, its own i18n namespace, its own cache jobs - that is a core change, not an extension. Open a discussion first.

Extension pointWhat it addsStatusRecipe
TranslationA new UI language, or fixes to an existing oneAvailable7.TRANSLATIONS.md
Target catalogueCross-references or standalone deep-sky objects in SkyTonightAvailablebelow
ConnectorA bridge to an external astronomy tool, surfaced in the app tabs it declaresAvailableCONNECTORS.md - Adding a new connector
Export formatterA new “export Plan My Night / SkyTonight as X” format (Stellarium, SkySafari, NINA…)Planned for v2.1 - no stable contract yet-

Every extension follows the project-wide rules in CONTRIBUTING.md: English only, all 6 i18n files, centralized logging, tests that mirror the backend layout, and the validation block before you call it done.


Target catalogue

Two shapes, depending on whether your catalogue tags existing objects or adds new ones.

Cross-reference catalogue

Adds a catalogue_names entry and a source_catalogues tag to existing NGC/IC records. It never adds coordinates and never changes the preferred display name (OpenNGC / Messier / Caldwell always win). Examples: Herschel 400, Pensack 500, LBN, Arp.

  1. Add backend/catalogues/<name>.json, in one of the two existing formats:
    • a map { "NGC 1234": "<Cat> 5", ... } (LBN, Arp style), or
    • a list [ "NGC 1234", "IC 5678", ... ] when the source name is not needed (Pensack 500 style).
  2. Register it in _build_cross_ref_map() in backend/skytonight/skytonight_catalogue_builder.py - one _load_json_catalogue(...) block modelled on the existing ones, keying with _ngc_ic_match_key().
  3. Add the catalogue id to SKYTONIGHT_PREFERRED_NAME_ORDER in backend/utils/constants.py. Position matters: put it after the authoritative catalogues, near the other cross-refs.
  4. Add a row to the catalogue table and a source note in SKYTONIGHT.md.
  5. Add a test in tests/skytonight/test_skytonight_catalogue_builder.py: a known NGC record gains the tag.

Standalone catalogue

Creates new target records with their own coordinates, for objects with no NGC/IC identifier. Examples: Sharpless, Barnard, vdB, Abell PNe.

  1. Add backend/catalogues/<name>.json as a list of objects:
    {
      "name": "Sh2-155",
      "ra_hours": 22.951,
      "dec_degrees": 62.616,
      "size_arcmin": 50.0,
      "type": "Emission Nebula",
      "description": "Cave Nebula",
      "mag": null,
      "constellation": "Cepheus",
      "extra_catalogues": []
    }
    
  2. Register it with _build_standalone_targets_from_json('<name>.json', '<Cat>') in skytonight_catalogue_builder.py (next to the existing standalone_* calls).
  3. Steps 3-5 are the same as for a cross-reference catalogue.

After either

python scripts/build_skytonight_catalogue.py      # or POST /api/skytonight/dataset/rebuild
pytest tests/skytonight/

Catalogue data must be verifiable against a published source - cite it in the SKYTONIGHT.md note and in the PR description.


Connector

A connector is a Python class extending BaseConnector (backend/connectors/base_connector.py), registered in backend/connectors/__init__.py, exposing one or more independently-toggleable modules. Its data appears in the app tabs it names in target_modules - ["observatory"] for AllSky, and legitimately [] for a self-contained connector that feeds no existing tab. Full recipe: CONNECTORS.md - Adding a new connector.

A connector is the safest kind of third-party contribution because its contract is narrow: talk to an external system, return data. It does not own UI, user storage, or an i18n namespace of its own beyond its settings labels.

There is no public SDK or external plugin repository planned - that idea was dropped (see ROADMAP.md - Architecture direction). New connectors land directly in backend/connectors/ by PR, same as AllSky and MyAstroShine.

A connector is a connector. MyAstroShine (MYASTROSHINE.md) is bidirectional and owns UI in the Astrodex tab, but it is still an optional bridge to an external service that the user switches on or off - so it is a BaseConnector in the REGISTRY, listed by GET /api/connectors, and rendered by the same card as AllSky. What differs is carried declaratively: SECRET_FIELDS for its credentials, CONFIG_FIELDS for its extra settings, target_modules = ["astrodex"] for where its data lands, and an empty MODULES because the round-trip has no independently-toggleable parts.

Anything that writes back into MyAstroBoard data or owns its own screen still needs a core change alongside the connector - open a discussion first.

A connector may also push rather than be polled: the MQTT / Home Assistant connector (HOME_ASSISTANT.md) owns a background publisher thread (connectors/mqtt_publisher.py, started from app.py like the other schedulers) and reaches feature packages only through lazy imports, because cache/ and observation/ already import connectors/ at module level.


Export formatter

Planned for v2.1. Today, Plan My Night has two hard-coded export routes (/api/plan-my-night/export.csv, /api/plan-my-night/export.pdf). v2.1 introduces a small registry of formatters (Stellarium .stel, SkySafari .skylist, NINA target-list XML, extended CSV) so a new format is one formatter class plus a test. This section will carry the recipe once that contract exists.


The one hard rule: no new cross-feature dependency cycles

The backend is split into per-domain packages (skytonight/, observation/, equipment/, weather/, astroweather/, space/, cache/, utils/). A handful of import cycles between these already exist and are being unwound one at a time. Do not add new ones.

  • A shared helper two features both need belongs in utils/ (or a new small shared module), not imported feature-to-feature.
  • If feature A genuinely must call into feature B at request time and B does not already depend on A, a lazy import inside the function is acceptable as a deliberate, commented exception (this is how plan_my_night reaches equipment_profiles today). A new module-level import that closes a cycle is not.
  • When in doubt, ask in the PR - a reviewer will tell you whether the edge is already there or you are creating one.

This keeps every feature reviewable and testable on its own, which is the whole point of the extension-point model above.

Last updated 2026-11-13

API Endpoints

This page lists the HTTP routes currently declared across backend/blueprints/*.py (registered in backend/app.py), including backend/blueprints/skytonight_api.py.

Web & PWA Routes

  • GET /
  • GET /login
  • GET /manifest.webmanifest
  • GET /manifest.<lang>.webmanifest
  • GET /sw.js
  • GET /offline.html
  • GET /robots.txt

Authentication

  • POST /api/auth/login
  • POST /api/auth/login/verify-2fa
  • POST /api/auth/logout
  • GET /api/auth/status
  • POST /api/auth/change-password
  • GET /api/auth/preferences
  • PUT /api/auth/preferences
  • POST /api/auth/2fa/setup
  • POST /api/auth/2fa/confirm
  • POST /api/auth/2fa/disable
  • GET /api/auth/security-settings (admin)
  • POST /api/auth/security-settings (admin)

Push Notifications

  • GET /api/push/vapid-public-key
  • GET /api/push/vapid-config-status
  • POST /api/push/subscribe
  • GET /api/push/subscriptions
  • DELETE /api/push/subscriptions
  • DELETE /api/push/unsubscribe
  • POST /api/push/test
  • POST /api/push/test/<trigger_id>

User Management (admin)

  • GET /api/users
  • POST /api/users
  • PUT /api/users/<user_id>
  • DELETE /api/users/<user_id>
  • DELETE /api/users/<user_id>/2fa
  • GET /api/users/<user_id>/export (admin) - personal data ZIP of any user
  • GET /api/users/me/export - personal data ZIP of the signed-in user

Administration (admin)

  • GET /api/admin/app-settings
  • POST /api/admin/app-settings
  • POST /api/admin/restart

Configuration

  • GET /api/config
  • POST /api/config
  • GET /api/config/export
  • GET /api/skyquality

Locations (multi-location profiles, see docs/LOCATIONS.md)

  • GET /api/locations (admin)
  • POST /api/locations (admin, capped at MAX_LOCATIONS)
  • PUT /api/locations/<location_id> (admin)
  • DELETE /api/locations/<location_id>?plans=cascade|orphan (admin)
  • GET /api/locations/<location_id>/references (admin)
  • POST /api/locations/<location_id>/attribute (admin)
  • GET /api/locations/mine
  • POST /api/locations/active

Backup & Restore

  • GET /api/backup/download - Download a ZIP archive (config.json, app_settings.json, users.json, astrodex/, equipments/, observation_sessions/, wishlist/), built from the database
  • POST /api/backup/restore - Restore from a previously created backup ZIP (multipart/form-data, field file)
  • POST /api/admin/database/integrity-check (admin) - Run SQLite’s integrity and foreign key checks ({ok, problems})
  • GET /api/admin/migration-backups (admin) - What the 1.7 upgrade left in data/backups/ (archive, reports, files set aside, size)
  • GET /api/admin/migration-backups/report (admin) - Download the newest import report
  • DELETE /api/admin/migration-backups (admin) - Delete the upgrade archive, reports and files set aside (irreversible)

Platform & Utility

  • GET /api/metrics
  • GET /api/logs
  • GET /api/logs/level - Active levels: {"level": <log file>, "console_level": <console>}. They are set through POST /api/admin/app-settings (log_level, console_log_level); the LOG_LEVEL / CONSOLE_LOG_LEVEL environment variables override them when set
  • POST /api/logs/clear
  • GET /api/logs/export - Download a ZIP archive of all log files (myastroboard.log + skytonight/logs/)
  • POST /api/convert-coordinates
  • GET /api/timezones
  • POST /api/translate/on-demand
  • GET /api/health
  • GET /health
  • GET /api/cache
  • GET /api/version
  • GET /api/version/check-updates - latest GitHub release (cached 4 h). When an update is available, changes lists the CHANGELOG.md sections between the installed and the latest version (version, date, features, fixes, breaking; newest first), or is null when the changelog could not be fetched.
  • GET /api/catalogues
  • GET /api/map-tiles/vector/<z>/<x>/<y>.mvt, GET /api/map-tiles/fonts/<font>/<range>.pbf, GET /api/map-tiles/sprites/<file>, GET /api/map-tiles/raster/<z>/<x>/<y>.png - OpenStreetMap map resources served through the server’s cache, so the browser never contacts the map servers (vector tiles and glyphs are gzip-encoded; 404 for an invalid tile, font, glyph block or sprite file, 429 with Retry-After past the per-user download budget, 502 when the resource is neither cached nor downloadable); see CACHE_SYSTEM.md

Scheduler

  • GET /api/scheduler/status (legacy alias mapped to SkyTonight scheduler status)
  • POST /api/scheduler/trigger (legacy alias mapped to SkyTonight scheduler trigger)

SkyTonight

  • GET /api/skytonight/scheduler/status
  • POST /api/skytonight/scheduler/trigger
  • GET /api/skytonight/dataset/status
  • POST /api/skytonight/dataset/rebuild
  • GET /api/skytonight/log
  • GET /api/skytonight/reports
  • GET /api/skytonight/reports/<catalogue>
  • GET /api/skytonight/alttime/<target_id>
  • POST /api/skytonight/combination-recommendations
  • GET /api/skytonight/visibility-calendar - 12-month “best months to image this target” calendar for a fixed deep-sky object at the active location. Params: target (catalogue id or name, required), year (4-digit, defaults to the current year, clamped to [current-1, current+5]). Returns supported: false for solar-system bodies and comets.
  • GET /api/skytonight/framing - Framing simulator data for a fixed deep-sky object: resolved target (coordinates, apparent size), allowed background fields of view, and one frame (field of view, image scale, fit verdict, mosaic estimate) per enabled and valid equipment combination that has a camera sensor and a focal length. Param: target (catalogue id or name, required). Returns supported: false for solar-system bodies and comets. See SKYTONIGHT.md.
  • GET /api/skytonight/framing/image/<filename> - DSS2 colour background image of the framing simulator, <ra>_<dec>_<fov>.jpg in degrees (fov must be one of the allowed fields of view). Fetched once from CDS hips2fits by the server, then served from the on-disk cache; 502 when CDS is unavailable.
  • GET /api/skytonight/skymap
  • GET /api/skytonight/data/dso - Optional catalogue filter, plus the v1.4 advanced filters applied server-side before the result-set truncation: size_min, size_max, sb_max, alt_window_min_deg, alt_window_start, alt_window_end, combination_id, fov_fit, max_integration_h. Malformed values are ignored rather than rejected. See SKYTONIGHT.md.
  • GET /api/skytonight/data/bodies
  • GET /api/skytonight/data/comets
  • GET /api/skytonight/logs/<catalogue>
  • GET /api/skytonight/logs/<catalogue>/exists
  • GET /api/skytonight/target-debug
  • GET /api/skytonight/recommendations - Difficulty-aware “what to shoot tonight” recommendations, filtered by the user’s experience_level preference. Params: limit (default 5, max 10), lang (mandatory for translated content).

Weather, Moon, Sun, and Astronomy

  • GET /api/weather/forecast
  • GET /api/weather/astro-analysis
  • GET /api/weather/astro-current
  • GET /api/weather/alerts
  • GET /api/seeing-forecast
  • GET /api/moon/report
  • GET /api/moon/dark-window
  • GET /api/moon/next-7-nights
  • GET /api/moon/phase-calendar - Full calendar month of Moon phases (illumination, waxing/waning, near-new-moon flag) plus the month’s principal phase timestamps, for the active location. Params: year, month (1-12); both are clamped to the allowed 2-month window (current month and the next one), and missing or malformed values fall back to the current month. Response includes is_current_month, can_go_prev, can_go_next, today, days, principal_phases.
  • GET /api/aurora/predictions
  • GET /api/iss/passes - Returns ISS passes, solar transits, and lunar transits; all times in configured local TZ. Response includes passes, solar_transits, lunar_transits, next_visible_passage, next_solar_transit, next_lunar_transit, total_passes, total_solar_transits, total_lunar_transits.
  • GET /api/iss/location
  • POST /api/iss/celestrak/restart - Admin only. Clears the Celestrak block flag so the next crawl may query Celestrak again.
  • GET /api/css/passes - Returns CSS (China Space Station, NORAD 48274/Tiangong) passes, solar transits, and lunar transits. Same response structure as ISS; includes station: "CSS" field.
  • GET /api/css/location
  • POST /api/css/celestrak/restart - Admin only. Same as the ISS one, for the CSS TLE crawl.
  • GET /api/sun/today
  • GET /api/sun/next-eclipse
  • GET /api/moon/next-eclipse
  • GET /api/events/upcoming
  • GET /api/events/planetary
  • GET /api/events/phenomena
  • GET /api/events/solarsystem
  • GET /api/astro/sidereal-time
  • GET /api/astro/horizon-graph
  • GET /api/tonight/best-window
  • GET /api/sky-widget - Current sky status for the sky status widget in the header: period of the night, sky score, active location name

Spaceflight

  • GET /api/spaceflight/launches
  • GET /api/spaceflight/astronauts
  • GET /api/spaceflight/events
  • GET /api/spaceflight/img/<filename>
  • GET /api/spaceflight/launch/<launch_id>/vidurls

Connectors

  • GET /api/connectors - List all registered connectors with installed/enabled state, module config, target_modules (app tabs the connector surfaces in), and a config block whose SECRET_FIELDS are masked (**** + last 4, plus a has_<field> boolean). A connector on a shared MQTT connection also carries connection_field and connection_options ([{id, name}])
  • POST /api/connectors/<name>/config (admin) - Save one connector’s config; merged server-side, int / bool fields coerced to their declared type, and a blank or still-masked secret means “keep current”. Secrets are written to the connector secrets store, never to the configuration. A connector on a shared MQTT connection: 400 for an unknown connection id, or for a client_id another connector already uses on the same connection
  • GET /api/connectors/allsky/status - AllSky sensor data: the variables of the last MQTT message from AllSky’s Publish Data module, plus _received_at (when the board received it); {} while none arrived or the last one is older than 15 min. Requires the sensor_data module enabled
  • GET /api/connectors/allsky/health - Run a per-module health check against the AllSky instance with the saved configuration (cached 2 minutes); accepts ?fresh=1 to bypass the cache
  • POST /api/connectors/allsky/health - Quick reachability probe of {"url": "..."} before saving (the card’s test button); http(s) only
  • GET /api/connectors/allsky/urls - Return proxy URLs for all enabled AllSky modules; accepts ?date=YYYYMMDD
  • GET /api/connectors/allsky/proxy - Proxy an AllSky resource through the backend; params: module=<slug> and optional date=YYYYMMDD
  • GET /api/connectors/mqtt/health (admin) - One real MQTT connect against the saved connector’s connection, with the module toggles (a connection picked on the card is tested through POST /api/mqtt-connections/health). A failed probe is a 200 with reachable: false and an error string
  • GET /api/connectors/mqtt/status - What the publisher thread is doing: enabled, connected, broker, last_publish_at, last_error, devices[], messages_total (read from the status file the thread writes, so any worker can answer)
  • POST /api/connectors/mqtt/publish (admin) - Ask the publisher for a full republish (discovery + every state) on its next tick
  • POST /api/connectors/mqtt/remove (admin) - Switch the connector off and purge every retained MyAstroBoard topic from the broker (Home Assistant drops the devices)

MQTT connections

Brokers shared by the MQTT connectors (App settings -> Configuration). All admin-only; the password is never returned, not even in part: a fixed ******** mask and a has_password flag.

  • GET /api/mqtt-connections (admin) - Every connection: id, name, url, username, tls_insecure, password (******** when one is stored, nothing of it revealed), has_password, used_by (connector names)
  • POST /api/mqtt-connections (admin) - Create from {name, url, username?, password?, tls_insecure?}; 201, or 400 with the reason (name required or taken, invalid URL…)
  • PUT /api/mqtt-connections/<connection_id> (admin) - Edit; a blank or still-masked password keeps the stored one, a blank username drops it. 404 for an unknown id
  • DELETE /api/mqtt-connections/<connection_id> (admin) - Delete; 409 {error, used_by} while a connector uses it
  • POST /api/mqtt-connections/health (admin) - One real MQTT connect with {url, username?, password?, tls_insecure?, id?} as typed; a blank or masked password uses the stored one of connection id only when url is that connection’s saved URL. {id} alone probes that saved connection (the connectors’ picker). A failed probe is a 200 with reachable: false and an error string

Object Lookup

  • GET /api/object/<path:identifier>
  • GET /api/object-image/<filename> - Serve a locally cached DSS2 sky image (object thumbnails, framing backgrounds), e.g. 10.684000_41.269000.jpg; see SKYTONIGHT.md

Beginner Catalog

  • GET /api/beginner-catalog - Curated beginner-friendly DSO catalog, enriched with visible_tonight/astro_score/in_astrodex/in_plan. Params: lang (mandatory), visible_only (default true; ignored if no SkyTonight results are cached yet).

Astrodex

  • GET /api/astrodex
  • GET /api/astrodex/map - Flat list of geotagged pictures for the Photo Map sub-tab, gated by its own map_private config flag (independent from the general private sharing flag)
  • POST /api/astrodex/items
  • POST /api/astrodex/items/<item_id>/catalogue-name
  • GET /api/astrodex/items/<item_id>
  • PUT /api/astrodex/items/<item_id>
  • DELETE /api/astrodex/items/<item_id>
  • POST /api/astrodex/items/<item_id>/pictures
  • PUT /api/astrodex/items/<item_id>/pictures/<picture_id>
  • DELETE /api/astrodex/items/<item_id>/pictures/<picture_id>
  • POST /api/astrodex/items/<item_id>/pictures/<picture_id>/main
  • POST /api/astrodex/upload
  • GET /api/astrodex/images/<filename>
  • GET /api/astrodex/check/<item_name>
  • GET /api/astrodex/constellations
  • GET /api/astrodex/catalogue-lookup
  • GET /api/astrodex/collection/catalogues
  • GET /api/astrodex/collection

Astrodex Stream (see docs/ASTRODEX_STREAM.md)

  • GET /api/astrodex/stream/urls - The caller’s personal stream URL and, unless the Astrodex is private, the shared one; external_base_url_required: true under Home Assistant ingress with no external base URL set
  • GET /api/astrodex/stream/<user_id>/<token>/current.jpg - No session cookie. Current frame of one user’s slideshow; the signed token is the credential. 404 when the connector is off or the token is wrong, 429 when rate limited
  • GET /api/astrodex/stream/shared/<token>/current.jpg - No session cookie. Current frame of the slideshow of every user’s pictures; 404 while the Astrodex is private
  • POST /api/connectors/astrodex_stream/rotate (admin) - Rotate the signing secret: every stream URL handed out so far stops working

MyAstroShine integration (see docs/MYASTROSHINE.md)

Astrodex <-> MyAstroShine image round-trip. Not a BaseConnector - config lives under config.connectors.myastroshine but the routes and UI are Astrodex-side.

  • GET /api/astrodex/integration/status - { enabled: bool } - drives the per-photo “Send to MyAstroShine” button
  • GET|POST /api/connectors/myastroshine/health - Best-effort reachability probe of <url>/api/health (loopback / link-local / unspecified / multicast refused). GET probes the saved URL, POST the one in the body. The card’s config is served by the shared connector routes above
  • POST /api/astrodex/integration/handoff - Mint a signed, single-use handoff token for one of the caller’s own pictures; returns { handoff, myastroshine_url, open_url }
  • GET /api/astrodex/integration/source?handoff=<token> - No session cookie (MyAstroShine container calls it). Source picture metadata for a valid handoff
  • GET /api/astrodex/integration/source/image?handoff=<token> - No session cookie. Source image bytes
  • POST /api/astrodex/integration/enhanced - No session cookie. multipart/form-data (handoff, payload, image) + X-Webhook-Signature: sha256=<hex>. Creates a duplicated picture on the same item; 201 / 401 bad signature or handoff / 409 handoff already consumed / 404 item or picture gone / 413 image too large

Plan My Night

  • GET /api/plan-my-night/list
  • GET /api/plan-my-night
  • PATCH /api/plan-my-night
  • POST /api/plan-my-night/targets
  • PUT /api/plan-my-night/targets/<entry_id>
  • POST /api/plan-my-night/targets/<entry_id>/reorder
  • DELETE /api/plan-my-night/targets/<entry_id>
  • DELETE /api/plan-my-night/clear
  • DELETE /api/plan-my-night/clear-all
  • POST /api/plan-my-night/targets/<entry_id>/add-to-astrodex
  • GET /api/plan-my-night/export.csv
  • GET /api/plan-my-night/export.pdf
  • GET /api/plan-my-night/optimize - Preview a reordering and initial delay that maximize each target’s overlap with its real visibility window for the night
  • POST /api/plan-my-night/optimize/apply - Apply a previewed optimized order and initial delay to the plan

Observation Log (see docs/OBSERVATION_LOG.md)

  • GET /api/observation-sessions - Own sessions (newest observation date first) + minimal stats
  • POST /api/observation-sessions
  • GET /api/observation-sessions/<session_id>
  • PUT /api/observation-sessions/<session_id>
  • DELETE /api/observation-sessions/<session_id> - Deletes the session and its entries; linked Astrodex items/pictures are never touched
  • POST /api/observation-sessions/from-plan - Seed/merge a session from a Plan My Night plan (works on current and previous plans); reuses an existing night of the same date, or appends a new one
  • POST /api/observation-sessions/<session_id>/nights - Add another night to a multi-night session (v1.3.1)
  • PUT /api/observation-sessions/<session_id>/nights/<night_id> - Update one night’s conditions/notes (v1.3.1)
  • DELETE /api/observation-sessions/<session_id>/nights/<night_id> - Refused while it’s the session’s last remaining night, or while any entry still points at it (v1.3.1)
  • POST /api/observation-sessions/<session_id>/entries - Side effect: auto-registers the target in Astrodex when frame_count > 0
  • PUT /api/observation-sessions/<session_id>/entries/<entry_id> - Same auto-registration whenever frame_count newly becomes > 0
  • DELETE /api/observation-sessions/<session_id>/entries/<entry_id>
  • POST /api/observation-sessions/<session_id>/entries/<entry_id>/astrodex-picture - Manual attach of an image already uploaded through POST /api/astrodex/upload
  • POST /api/observation-sessions/<session_id>/attachments - Upload + record a generic session attachment (image/PDF/text/Word) in one step (v1.3.1)
  • GET /api/observation-sessions/attachments/<filename> - Serve an attachment file, ownership-checked (v1.3.1)
  • PUT /api/observation-sessions/<session_id>/attachments/<attachment_id> - Set/clear an attachment’s custom display name (v1.3.1)
  • DELETE /api/observation-sessions/<session_id>/attachments/<attachment_id> - Remove an attachment’s record and file (v1.3.1)
  • GET /api/observation-sessions/<session_id>/export.pdf - One session as a PDF (common info + every logged target, with its attached photo when there is one)
  • GET /api/observation-sessions/export.pdf - Every own session as one PDF (cover + summary + per-session pages); optional from_date/to_date/order (asc/desc, default asc)

Session Analytics and Wishlist (see docs/SESSION_ANALYTICS.md)

All self-scoped: a user only ever sees aggregates of their own Observation Log and Astrodex.

  • GET /api/session-analytics/summary - Integration hours (lifetime/year/month), monthly series, distinct objects, object-type and constellation breakdowns, equipment usage, top targets, plus the separately-labelled Astrodex collection size; optional year
  • GET /api/session-analytics/sky-coverage - Captured objects placed on an RA/Dec grid, the count and names of those that could not be placed, and the never-visible declination bound for the active location
  • GET /api/session-analytics/conditions - Rated entries joined to their night’s seeing/transparency/SQM/Moon, plus bucketed averages carrying their own sample counts
  • GET /api/session-analytics/best-months - Dark and moonless-dark hours available per month at the active location, next to the hours the user actually logged; not a weather statistic; optional year
  • GET /api/wishlist - Wishlist items with derived captured state, progress counters and next-visibility figures; optional sort (visibility/priority/name) and visibility=0 to skip the ephemeris pass
  • POST /api/wishlist (user) - Add targets; body is always {"targets": [...]}; returns the added items plus duplicate/invalid/full skip counts
  • PATCH /api/wishlist/<item_id> (user) - Update priority or notes
  • DELETE /api/wishlist/<item_id> (user) - Remove one item
  • POST /api/wishlist/archive-captured (user) - Remove every item currently derived as captured

Equipment

  • GET /api/equipment/telescopes
  • POST /api/equipment/telescopes
  • GET /api/equipment/telescopes/<telescope_id>
  • PUT /api/equipment/telescopes/<telescope_id>
  • DELETE /api/equipment/telescopes/<telescope_id>
  • GET /api/equipment/cameras
  • POST /api/equipment/cameras
  • GET /api/equipment/cameras/<camera_id>
  • PUT /api/equipment/cameras/<camera_id>
  • DELETE /api/equipment/cameras/<camera_id>
  • GET /api/equipment/mounts
  • POST /api/equipment/mounts
  • GET /api/equipment/mounts/<mount_id>
  • PUT /api/equipment/mounts/<mount_id>
  • DELETE /api/equipment/mounts/<mount_id>
  • GET /api/equipment/filters
  • POST /api/equipment/filters
  • GET /api/equipment/filters/<filter_id>
  • PUT /api/equipment/filters/<filter_id>
  • DELETE /api/equipment/filters/<filter_id>
  • GET /api/equipment/accessories
  • POST /api/equipment/accessories
  • GET /api/equipment/accessories/<accessory_id>
  • PUT /api/equipment/accessories/<accessory_id>
  • DELETE /api/equipment/accessories/<accessory_id>
  • GET /api/equipment/combinations
  • POST /api/equipment/combinations
  • GET /api/equipment/combinations/<combination_id>
  • PUT /api/equipment/combinations/<combination_id>
  • DELETE /api/equipment/combinations/<combination_id>
  • POST /api/equipment/fov-calculator
  • GET /api/equipment/summary

Last updated 2026-11-13

Cache System - Server-Side Management

Overview

The cache system is managed entirely server-side with per-job TTL-based expiration and intelligent cache invalidation when location parameters change.

The key design principle is selective refresh: the background scheduler polls every 5 minutes, but each job only executes when its own TTL has expired. Heavy computations (eclipses, planetary events) run at most once a day, while time-sensitive data (aurora, sidereal) refreshes hourly. This dramatically reduces CPU and memory pressure on low-resource machines.

Multi-location model

Caches are split into two buckets (full details: docs/LOCATIONS.md):

  • Location-scoped caches keep one slot per location preset, keyed by the preset’s immutable id. On-disk keys in astro_cache.json are "<name>:<location_id>". Access goes through cache_store.get_location_cache_entry(name, location_id), load_location_cache(...) and update_location_cache(...) - the pre-v1.2 module-level singletons (_sun_report_cache, …) no longer exist for these. Scoped names: moon_report, dark_window, moon_planner, sun_report, best_window_strict/practical/illumination, solar_eclipse, lunar_eclipse, horizon_graph, aurora, iss_passes, css_passes, planetary_events, special_phenomena, solar_system_events, sidereal_time, seeing_forecast, weather_forecast, astro_weather (single source of truth: cache_store.LOCATION_SCOPED_CACHE_TTLS).
  • Global caches keep the single-slot module-level shape: spaceflight_launches, spaceflight_astronauts, spaceflight_events, iers, the AllSky health cache (its sensor data arrives over MQTT, not through a cache job), and the version-check cache. The ISS/CSS TLE fetch is also global (its own on-disk cache inside space/iss_passes.py/space/css_passes.py); only pass-visibility math is per location.

The scheduler runs each location-scoped job once per scheduler location (install default + presets attributed to at least one user), gated by the (job, location) TTL. Job × location units share the same fixed-size thread pool (≤6 workers), so total concurrent API calls never exceed the pre-v1.2 ceiling. Metrics rows for non-default locations use the "<job>@<location-slug>" key format.

Key Features

Server-Side Only Management

  • No browser-side refresh required - F5 works normally
  • All cache calculations happen on the server
  • Cache is automatically refreshed on schedule (selective per-job TTL)

Per-Job TTL Configuration

Each cache job has an individual TTL defined in backend/utils/constants.py:

JobTTLRationale
moon_report2 hoursMoon phase changes gradually
dark_window2 hoursDerived from moon report
moon_planner2 hours7-night forecast, daily precision is sufficient
sun_report6 hoursSunrise/sunset changes slowly
best_window3 hours“Best window for tonight”
solar_eclipse24 hoursNext event is months away
lunar_eclipse24 hoursNext event is months away
horizon_graph6 hoursDaily arc of sun/moon
aurora1 hourGeomagnetic forecast updates hourly
iss_passes6 hours20-day prediction window, stable
css_passes6 hours20-day prediction window, stable (CSS NORAD 48274)
planetary_events24 hours365-day planetary forecast
special_phenomena24 hoursAnnual events (equinoxes, solstices…)
solar_system_events24 hoursAnnual events (meteor showers, comets…)
sidereal_time1 hourHourly precision is sufficient
seeing_forecast6 hours7Timer API update frequency
weather_forecast1 hourSuitable for UI display
astro_weather30 minJet-stream-aware astro analysis; source of the app-wide observation_score
spaceflight_launches2 hoursLaunch Library API budget and cadence
spaceflight_astronauts6 hoursCrew roster changes are less frequent
spaceflight_events2 hoursEvent feed changes but does not require minute-level polling
iers21 daysEarth-orientation data is long-lived

Note on Weather Data:

  • UI Weather Forecast: Cached for 1 hour (suitable for display)
  • SkyTonight Conditions: NOT cached - always fetches fresh real-time data
    • SkyTonight requires accurate current conditions (temperature, pressure, humidity)
    • Each SkyTonight run bypasses cache to get live conditions from Open-Meteo API

Selective Refresh (Resource Efficiency)

fully_initialize_caches() in cache/cache_updater.py now:

  1. Syncs each in-memory cache entry from the shared JSON file (gets persisted timestamp)
  2. Checks each job’s TTL individually
  3. Skips jobs whose TTL has not yet expired
  4. Only runs stale jobs - often just 2-4 jobs per poll cycle

On a typical steady-state machine, most 5-minute poll cycles skip all jobs because TTLs are still valid. Hourly-class jobs run only when their own TTL expires; daily jobs run at most once every 24 hours.

Automatic Location Change Detection

Location change detection is per preset (v1.2). When any of these parameters change on a preset, that preset’s caches only are immediately reset (timestamps zeroed), forcing a refresh of its slots on the next scheduler cycle - other presets’ caches are untouched:

  • Latitude
  • Longitude
  • Elevation
  • Timezone

Signatures are tracked per preset id in data/cache/location_cache.json (cache_store.has_location_changed(preset) / update_location_config(preset) / reset_caches_for_location(id)). Deleting a preset drops its slots and signature (drop_location_caches(id)).

Background Cache Scheduler

  • Runs in a dedicated daemon thread
  • Starts automatically on app/container startup
  • Updates all caches immediately on first run (all timestamps = 0)
  • Then polls every 300 seconds (5 minutes), running only expired jobs
  • Uses file locking to ensure only one scheduler runs across multiple workers

Architecture

Components

1. cache/cache_store.py - Cache Storage & Validation

  • Maintains in-memory cache entries with timestamps
  • Persists cache entries to data/cache/astro_cache.json for cross-worker visibility
  • Tracks location configuration changes
  • Provides TTL validation using per-job constants
  • New: record_cache_execution(job_name, duration_s, success) - persists per-job timing
  • New: get_cache_metrics() - returns per-job execution metrics from shared cache
  • Key functions:
    • is_cache_valid(cache_entry, ttl_seconds) - check if cache is still fresh
    • is_astronomical_cache_ready() - check if all caches are valid (uses per-job TTLs); the Open-Meteo-backed slots (weather_forecast, astro_weather) are excluded - they have a live-fetch fallback and must not gate readiness (_READINESS_LOCATION_CACHES)
    • has_location_changed(location_config) - detect location parameter changes
    • reset_all_caches() - immediately reset all astronomical caches
    • get_cache_init_status() - detailed cache status including ttls and execution_metrics

2. cache/cache_updater.py - Cache Calculation (Selective)

  • Contains all individual cache update functions
  • fully_initialize_caches() - selective: syncs timestamps, skips valid caches, records per-job timing
  • check_and_handle_config_changes() - detects and resets caches on location change

3. cache/cache_scheduler.py - Background Task Management

  • CacheScheduler class polls at 300-second interval
  • Uses file locking to prevent multiple instances
  • Calls fully_initialize_caches() which handles selective execution
  • Sets cache_ready_event after the first successful cycle

4. utils/constants.py - TTL Constants

  • CACHE_TTL_<JOB_NAME> constants define the TTL for each job
  • WEATHER_CACHE_TTL = 3600s (1 hour) for weather
  • CACHE_TTL = 1800s kept for backward compatibility only (not used for job scheduling)

5. weather/weather_utils.py - Dual Weather Client System

  • Cached Client (create_weather_client()): 1-hour HTTP-level cache for UI forecasts
  • Fresh Client (create_fresh_weather_client()): no caching, used by SkyTonight for real-time conditions

Cache Flow

App Startup
  ↓
Cache Scheduler starts
  ↓
Initial cache population (all timestamps = 0 -> all jobs run)
  ├─ Check location config
  ├─ Run all registered jobs
  └─ Record timing per job, set timestamps
  ↓
Wait 300s (5 min)
  ↓
Periodic poll
  ├─ Sync in-memory timestamps from shared JSON file
  ├─ For each job: check if TTL elapsed
  │   ├─ TTL not elapsed -> skip job
  │   └─ TTL elapsed -> run job, record duration, update timestamp
  └─ Log: "X/N jobs ran, Y/X succeeded in Z.Zs"
  ↓
Next poll (300s later)...

Configuration Changes

When location is updated via /api/config POST endpoint:

  1. System detects change in latitude, longitude, elevation, or timezone
  2. All cache timestamps zeroed (reset_all_caches())
  3. Configuration saved to file
  4. Next scheduler poll will run all 16 jobs (all timestamps = 0)
  5. Browser receives confirmation including "cache_reset": true

API Endpoints

/api/cache - Cache Status (GET)

Purpose: Informational - allows UI to display cache status and job metrics Returns:

{
  "cache_status": true,
  "in_progress": false,
  "current_step": 0,
  "total_steps": 0,
  "step_name": "",
  "progress_percent": 0,
  "details": {
    "moon_report": true,
    "sun_report": true,
    "best_window_strict": true,
    "best_window_practical": true,
    "best_window_illumination": true,
    "moon_planner": true,
    "dark_window": true,
    "solar_eclipse": true,
    "lunar_eclipse": true,
    "horizon_graph": true,
    "aurora": true,
    "iss_passes": true,
    "css_passes": true,
    "planetary_events": true,
    "special_phenomena": true,
    "solar_system_events": true,
    "sidereal_time": true,
    "seeing_forecast": true,
    "weather_forecast": true,
    "astro_weather": true,
    "all_ready": true,
    "ttls": {
      "moon_report": 7200,
      "dark_window": 7200,
      "moon_planner": 7200,
      "sun_report": 21600,
      "best_window": 10800,
      "solar_eclipse": 86400,
      "lunar_eclipse": 86400,
      "horizon_graph": 21600,
      "aurora": 3600,
      "iss_passes": 21600,
      "css_passes": 21600,
      "planetary_events": 86400,
      "special_phenomena": 86400,
      "solar_system_events": 86400,
      "sidereal_time": 3600,
      "seeing_forecast": 21600,
      "weather_forecast": 3600,
      "astro_weather": 1800,
      "spaceflight_launches": 7200,
      "spaceflight_astronauts": 21600,
      "spaceflight_events": 7200,
      "iers": 1814400
    },
    "execution_metrics": {
      "moon_report": {
        "last_run_at": "2026-05-01T20:00:01.123",
        "last_duration_s": 0.842,
        "last_success": true
      }
    }
  }
}

/api/config - Configuration Update (POST)

  • Detects if latitude, longitude, elevation, or timezone changed
  • Immediately resets all caches (timestamps to 0)
  • Returns "cache_reset": true when location changed

Data Endpoints (Astronomical Data)

These endpoints return from cache only:

  • GET /api/moon/report - Moon report
  • GET /api/moon/dark-window - Dark window
  • GET /api/moon/next-7-nights - Moon planner
  • GET /api/sun/today - Sun report
  • GET /api/tonight/best-window - Best observation window

Response codes:

  • 200: Data returned (cache is valid)
  • 202: Pending - cache being prepared (retry shortly)

Metrics Dashboard (Cache Jobs section)

The Metrics tab in the UI (admin only) includes a Cache Jobs table populated from GET /api/cache:

ColumnDescription
JobHuman-readable job name
TTLConfigured TTL for this job
StatusValid (green) / Stale (yellow) based on current TTL
Last runISO timestamp of last execution
DurationWall-clock time of last execution

A “Failed” badge appears in the Duration column if the last execution threw an exception.

Map tile cache

The maps (locations, photo map, observation log, orbital stations) load everything they draw from /api/map-tiles/... instead of a map server: utils/map_tiles.py downloads each resource once from the OpenStreetMap Foundation servers and keeps it under data/cache/map_tiles/<kind>/. The browser never contacts a third-party map server, and the map servers only see the server’s address.

KindRouteUpstreamKeptBrowser cache
Vector tiles (Shortbread)/api/map-tiles/vector/<z>/<x>/<y>.mvtvector.openstreetmap.org7 days1 day
Glyphs (label fonts)/api/map-tiles/fonts/<font>/<range>.pbfvector.openstreetmap.org30 days7 days
Sprites (icons)/api/map-tiles/sprites/<file>vector.openstreetmap.org30 days7 days
Raster tiles (PNG)/api/map-tiles/raster/<z>/<x>/<y>.pngtile.openstreetmap.org7 days1 day
  • Rendering: with WebGL, MapLibre GL JS draws the vector tiles inside a Leaflet layer (static/js/utils.js), with one style per look in static/map-styles/: light (VersaTiles Colorful), dark (VersaTiles Eclipse) and red (Eclipse recoloured to dim reds, used by the night vision theme whatever the light/dark choice, and swapped live when the theme changes). Without WebGL the maps fall back to raster tiles, recoloured for the dark variant and the red theme by CSS classes.
  • Styles: python scripts/build_map_styles.py downloads the VersaTiles styles (CC0) and rewrites their URLs to the proxy. Run it again after a new Shortbread major version, and bump _MAP_STYLES_VERSION in utils.js.
  • Outages: an expired resource is still served while the map server is unreachable.
  • Size: once the folder passes 300 MiB, the oldest files are deleted down to 240 MiB (checked every 200 downloads). Deleting the folder is always safe.
  • Fair use (tile and vector usage policies): at most 2 concurrent downloads per worker, an identifying User-Agent, resources kept at least 7 days, and a budget of 1000 uncached resources per user per 10 minutes per worker (cache hits are free) so the proxy cannot be used for bulk downloading.

Performance Impact

Benefits

  • No browser delays - F5 is instant
  • Low-resource friendly - heavy jobs (eclipses, planetary events) run at most once a day instead of every scheduler poll
  • Typical steady-state: most 5-min polls run 0 jobs (all TTLs intact); hourly-class jobs run only on expiry; daily jobs run ~once per day
  • Automatic location handling - no stale data when location changes
  • Full observability - per-job last run time and duration visible in Metrics tab

Cache Sizes (Typical)

  • Moon report: ~5-10 KB
  • Sun report: ~3-5 KB
  • Best windows: ~5-8 KB each (×3 modes)
  • Moon planner: ~20-30 KB
  • Dark window: ~1-2 KB
  • Total: ~50-100 KB in memory + shared JSON file on disk

Troubleshooting

Cache Always Shows “Pending”

  1. Check cache scheduler is running: GET /api/skytonight/scheduler/status
  2. Check server logs for errors in cache calculations
  3. Verify location configuration is complete
  4. Check Metrics -> Cache Jobs for failed jobs

Cache Not Updating After Location Change

  1. Verify location parameters actually changed
  2. Watch server logs for “Location configuration changed” message
  3. Check /api/cache shows caches as not ready (timestamps zeroed)
  4. Wait for next scheduler poll (max 5 minutes)

Multiple Cache Updates Running

  • Should not happen - file locking prevents it
  • If it does, check data/cache/cache_scheduler.lock for stale lock

SkyTonight Getting Stale Weather Conditions

  • This should NOT happen - SkyTonight always fetches fresh data via create_fresh_weather_client()
  • Each SkyTonight run bypasses the weather cache
  • If conditions seem old, check Open-Meteo API availability

Technical Implementation Details

Selective Refresh Logic (fully_initialize_caches)

for job_name, shared_key, update_fn, ttl, cache_entry in cache_jobs:
    cache_store.sync_cache_from_shared(shared_key, cache_entry)
    if cache_store.is_cache_valid(cache_entry, ttl):
        continue  # skip - still fresh
    # run the job and record timing
    t0 = time.time()
    update_fn()
    cache_store.record_cache_execution(job_name, time.time() - t0, success=True)

Execution Metrics Storage

Per-job metrics are stored under the _cache_metrics key in data/cache/astro_cache.json:

{
  "_cache_metrics": {
    "moon_report": {
      "last_run_at": "2026-05-01T20:00:01.123",
      "last_duration_s": 0.842,
      "last_success": true
    }
  }
}

Location Change Detection

# Signature-based comparison
new_signature = {"latitude": 45.5, "longitude": -73.5, "elevation": 100, "timezone": "America/Montreal"}
# If any value differs -> reset_all_caches() -> all timestamps zeroed

Adding a New Cache Job

  1. Add a CACHE_TTL_<NAME> constant in utils/constants.py with a justified value
  2. Add a _<name>_cache entry in cache/cache_store.py
  3. Add update_<name>_cache(config=None) in cache/cache_updater.py - guard with if config is None: config = load_config()
  4. Register it in the cache_jobs list in fully_initialize_caches() using partial(update_<name>_cache, config=config)
  5. Add it to is_astronomical_cache_ready() and get_cache_init_status() with its TTL constant
  6. Add it to reset_all_caches() and _write_all_astronomical_caches_to_shared()
  7. Document the TTL rationale in this file and .github/instructions/copilot.instructions.md

Computation Optimisations

To keep CPU and memory usage low on single-worker Docker deployments, the cache system applies three computation-level optimisations on each refresh cycle:

OptimisationWhereSaving
Config loaded oncefully_initialize_caches() calls load_config() once and forwards it to all update functions via functools.partialEliminates ~14 redundant disk reads per cycle
Moon caches mergedupdate_moon_caches() instantiates MoonService and calls get_report() once; both moon_report and dark_window caches are written from the same resultSaves one full MoonService.get_report() Astropy computation per cycle
Best-window single passAstroTonightService.best_windows_all_modes() runs one 12-hour night-scan loop; AltAz transforms are computed once per step while all three modes (strict, practical, illumination) are evaluated simultaneouslyReduces Astropy AltAz transforms by ~66% (144 instead of 432)

These optimisations are enforced by the config=None parameter pattern and must not be removed when adding or modifying cache jobs.

Last updated 2026-11-13

MyAstroBoard - Internationalization (i18n) System

Overview

MyAstroBoard implements a structured multilingual system supporting English, French, Spanish, German, Italian, and Portuguese. The i18n system ensures user-facing content can be translated while preventing hardcoded strings.

Current Status: ✅ Active implementation for weather alerts, astro-weather dynamic content, and events API translations.

Architecture

Components

MyAstroBoard i18n System
│
├── Frontend (JavaScript)
│   ├── static/js/i18n.js               # Global i18n manager
│   ├── static/i18n/en.json             # English translations
│   ├── static/i18n/fr.json             # French translations
│   ├── static/i18n/xx.json             # Other translations
│   ├── static/manifest.webmanifest     # General webmanifest (English)
│   └── static/manifest.xx.webmanifest  # Translated webmanifest
│
└── Backend (Python)
    └── backend/utils/i18n_utils.py     # Translation utilities

Language Support

LanguageCodeStatusTranslationType
Englishen✅ ActiveHumanDefault
Frenchfr✅ ActiveHuman-
Spanishes✅ ActiveGoogle*-
Germande✅ ActiveGoogle*-
Italianit✅ ActiveGoogle*-
Portuguesept✅ ActiveGoogle*-

Note: Google is used in scripts/translate_i18n_values.py

Frontend Implementation

i18n.js - The Global Manager

The file static/js/i18n.js:

  • Loads translation files from static/i18n/[language].json
  • Detects browser language automatically
  • Manages language switching with persistent storage (localStorage)
  • Handles placeholders in translation strings
  • Provides fallback to English for missing keys
  • Dispatches events when language changes

Using Translations in HTML

<h2 data-i18n="astro_weather.section_title">Current Conditions</h2>
<p data-i18n="common.loading">Loading...</p>

Using Translations in JavaScript

const msg = i18n.t('common.loading');
const alert = i18n.t('weather_alerts.critical_dew_risk', { time: '14:30' });
const lang = i18n.getCurrentLanguage();
await i18n.setLanguage('fr');

Language Selector

Files involved:

  • templates/index.html
  • static/js/language_selector.js

Features:

  • ✅ Dropdown selector for English, French, Spanish, German, Italian, Portuguese
  • ✅ Automatic current-language selection
  • ✅ URL/localStorage persistence
  • ✅ i18nLanguageChanged event propagation
  • ✅ Responsive behavior

Backend Implementation

i18n_utils.py

The module backend/utils/i18n_utils.py provides:

  • Translation loading from JSON files
  • Dot-notation key lookup
  • Placeholder replacement
  • Language management per request
  • Alert creation helpers with translated content

Example:

from i18n_utils import I18nManager, get_translated_message

msg = get_translated_message('common.loading', language='fr')
manager = I18nManager('en')
caption = manager.t('weather_alerts.critical_dew_risk', time='14:30')

API Language Contract

For endpoints returning translated user-facing content:

  • Frontend sends current language via ?lang=<code>
  • Backend may fallback to Accept-Language, then default to en
  • Backend resolves placeholders before serialization
  • Responses should not return hardcoded English where i18n keys exist

Implemented examples:

  • /api/weather/alerts?lang=fr
  • /api/weather/astro-analysis?hours=24&lang=fr
  • /api/events/upcoming?lang=fr

Exception - MQTT: the MQTT connector’s background publish loop (backend/connectors/mqtt_payloads.py) has no request or session to read a language from, so next_event’s title/description are hardcoded to en and always render English prose, on purpose - this is not a bug. MQTT consumers that want localized text (e.g. a Home Assistant Lovelace card) should translate client-side off the always-English, always-untranslated structured attributes instead: event_type, and the per-event-kind fields eclipse_type, planet/planet2, shower_name, comet_name on next_event_attributes (see docs/HOME_ASSISTANT.md). top_target_attributes.object_type is the same pattern, already relied on by the card today.

Translation Keys Structure

Keys use dot notation:

namespace.section.key

Example:

{
  "common": {
    "loading": "Loading...",
    "error": "Error"
  },
  "astro_weather": {
    "section_title": "Current Conditions",
    "loading_message": "Loading...",
    "alerts_title": "Weather Alerts"
  }
}

Add new translation

Translate all keys

First you need to translate all json key stored in static/i18n

You can copy en.json as reference or use Google translation script, which will automatically translate and create the json.

py scripts/translate_i18n_values.py --lang pt

By safety, the language must be manually add in this file first.

parser.add_argument("--lang", choices=["es", "de", "it", "pt"], required=True)

After a long time, you will receive a feedback. Please be carefull with it!

stats: translated=1223 kept=186 failed=2

You can also make a quick check to see if there is no missing translation.

py scripts/translate_checker.py

Of course, Google Translation must be manually checked, specially if this is your native language

Declare new translation

This is a multi-step checklist. Every item must be updated or the new language will be partially broken.

1. backend/utils/i18n_utils.py - translation file registry

# Supported languages
DEFAULT_LANGUAGE = 'en'
_TRANSLATION_FILENAMES = {
    'en': 'en.json',
    'fr': 'fr.json',
    'es': 'es.json',
    'de': 'de.json',
    'it': 'it.json',
    'pt': 'pt.json',
}

2. backend/app.py - manifest route allowlist

The web_manifest_localized() route has an explicit allowlist. Adding the language here is mandatory - missing it returns 404 and breaks the PWA service worker install.

@app.route('/manifest.<lang>.webmanifest')
def web_manifest_localized(lang):
    allowed = {'fr', 'es', 'de', 'it', 'pt'}  # <- add new language code here
    if lang not in allowed:
        return '', 404
    ...

3. static/sw.js - service worker shell cache

Add both the manifest and the i18n JSON file to APP_SHELL_URLS so they are pre-cached on PWA install:

const APP_SHELL_URLS = [
    ...
    '/manifest.xx.webmanifest',   // <- add localized manifest
    ...
    '/static/i18n/xx.json',       // <- add i18n dictionary
    ...
];

4. templates/index.html - language selector and manifest switcher

Add the language option to the footer selector:

<select id="language-select-footer" class="form-select form-select-sm language-select" aria-label="Language">
    <option value="en">English</option>
    <option value="fr">Français</option>
    <option value="es">Español</option>
    <option value="de">Deutsch</option>
    <option value="it">Italiano</option>
    <option value="pt">Português</option>
    <!-- add new language here -->
</select>

Also add the language code to the supported array in the inline <script> block that switches the PWA manifest URL:

var supported = ['fr', 'es', 'de', 'it', 'pt'];  // <- add new language code

5. scripts/translate_i18n_values.py - translation script

Add the language code to the choices list before running the script:

parser.add_argument("--lang", choices=["es", "de", "it", "pt", "xx"], required=True)

Translate webmanifest

Webmanifest is used in the PWA app - translating it gives native-language shortcut labels and app description on the home screen.

Webmanifest files are located in static/manifest.xx.webmanifest, where xx is the language code.

Be carefull, only some keys must be translated:

"description"
"lang"
"screenshots"->"label" (all)
"shortcuts"->"name" (all)
"shortcuts"->"short_name" (all)
"shortcuts"->"description" (all)

Then, translated webmanifest have to be declared on index.html

<script>
  localStorage.setItem('myastroboard_app_version', '{{ version }}');

  (function() {
    var lang = localStorage.getItem('myastroboard_language') || '';
    var supported = ['fr', 'es', 'de', 'it', 'pt'];
    if (supported.indexOf(lang) !== -1) {
      document.getElementById('pwa-manifest').href = '/manifest.' + lang + '.webmanifest?v={{ version }}';
    }
  })();
</script>

Last updated 2026-11-13

🚀 Release Process

This guide explains how to publish a new version of MyAstroBoard, including creating releases and publishing Docker images.

📋 Prerequisites

🏷️ Versioning Strategy

MyAstroBoard follows Semantic Versioning:

  • MAJOR version (x.0.0): Incompatible API changes, breaking changes
  • MINOR version (0.x.0): New features, backwards-compatible
  • PATCH version (0.0.x): Bug fixes, backwards-compatible

📦 Release Process

Step 1: Prepare the Release

  1. Ensure all changes are merged to the main branch:

    git checkout main
    git pull origin main
    
  2. Check CHANGELOG.md: the ## [Unreleased] section at the top of CHANGELOG.md is what becomes the GitHub release notes - it should already be up to date, since every feature//fix/ PR is required to add its own entry there (see CONTRIBUTING.md#changelog). Fix anything that reads like a novel before tagging - it goes into the release notes as-is.

  3. Update the VERSION file:

    # Edit the VERSION file with the new version number
    echo "0.2.0" > VERSION
    
  4. Review and test changes:

    • Run all tests: pytest --cov=backend tests/
    • Build locally: docker compose build
    • Test the application thoroughly
  5. Commit the version change:

    git add VERSION
    git commit -m "chore: bump version to 0.2.0"
    git push origin main
    

Step 2: Create a Git Tag

Create and push a version tag. The tag must follow the format v*.*.* (e.g., v0.2.0):

# Create an annotated tag
git tag -a v0.2.0 -m "Release v0.2.0"

# Push the tag to GitHub
git push origin v0.2.0

Step 3: Automated Build and Release

Once you push the tag, the GitHub Actions workflow will automatically:

  1. ✅ Trigger the docker-publish.yml workflow
  2. ✅ Build the Docker image for linux/amd64 and linux/arm64
  3. ✅ Publish the image to Docker Hub (myastroboard/myastroboard) and to the GitHub Container Registry (ghcr.io/myastroboard/myastroboard), with four tags each:
    • 0.2.0 (specific version)
    • 0.2 (minor version)
    • 0 (major version)
    • latest
  4. ✅ Create a GitHub Release, with release notes built from CHANGELOG.md’s ## [Unreleased] section (plus the commit list and Docker pull commands)
  5. ✅ Archive the changelog: the post-release-cleanup.yml workflow then opens a PR that moves ## [Unreleased]’s content into a new dated ## X.Y.Z (YYYY-MM-DD) section and resets ## [Unreleased] back to the empty template - review and merge that PR to close out the release

Step 4: Verify the Release

  1. Check the GitHub Actions workflow:

    • Go to: https://github.com/myastroboard/myastroboard/actions
    • Verify the “Build and Publish Docker Image” workflow completed successfully
  2. Verify the Docker image:

    docker pull myastroboard/myastroboard:0.2.0
    docker images | grep myastroboard
    
  3. Check the GitHub Release:

    • Go to: https://github.com/myastroboard/myastroboard/releases
    • Verify the new release appears with auto-generated notes
  4. Test the published image:

    # Pull and run the new version
    docker run --rm myastroboard/myastroboard:0.2.0 cat /app/VERSION
    

🔄 Rebuilding without a new version

The publish workflow only runs on a v*.*.* tag push; it has no manual trigger. To republish, push a new patch tag (see Hotfix Releases).

📝 Release Notes Best Practices

Release notes are generated automatically from CHANGELOG.md’s ## [Unreleased] section (see Step 1 above) plus the commit list and Docker pull commands - there’s normally nothing to write by hand at release time. The real editing happens before tagging, in the PRs that add their CHANGELOG.md entry: keep each bullet to one or two lines (see CONTRIBUTING.md#changelog), and use the right subsection (### Features, ### Fixes, ### Breaking changes) so it lands in the right place both in CHANGELOG.md and in the generated release notes.

If a release still needs manual editing after creation (a migration note, a known issue found after tagging, contributor shout-outs):

  1. Go to the release page
  2. Click “Edit release”
  3. Add the extra section below what was generated - don’t rewrite the generated Changelog/Commits sections by hand, fix the source in CHANGELOG.md instead (via a follow-up PR) so the archived entry stays correct too

Example addition:

## 📖 Migration Guide
If upgrading from v0.1.x, you need to:
1. Update your docker-compose.yml
2. Run migration script

## 🙏 Contributors
Thanks to @username for their contribution!

🐛 Hotfix Releases

For critical bug fixes that need immediate release:

  1. Create a hotfix branch:

    git checkout -b fix/hotfix-0.2.1 v0.2.0
    
  2. Apply the fix:

    # Make your changes
    git add .
    git commit -m "fix: critical bug in X"
    
  3. Update VERSION:

    echo "0.2.1" > VERSION
    git add VERSION
    git commit -m "chore: bump version to 0.2.1"
    
  4. Merge to main:

    git checkout main
    git merge fix/hotfix-0.2.1
    git push origin main
    
  5. Tag and release:

    git tag -a v0.2.1 -m "Hotfix v0.2.1"
    git push origin v0.2.1
    

🔙 Rolling Back a Release

If a release has issues:

# Delete the tag locally
git tag -d v0.2.0

# Delete the tag remotely
git push origin :refs/tags/v0.2.0

# Delete the release on GitHub (do this manually via GitHub UI)

Option 2: Create a New Patch Release

If users have already pulled the image, create a fix:

# Fix the issue
echo "0.2.1" > VERSION
git add VERSION
git commit -m "fix: issue in v0.2.0"
git push origin main

# Create new release
git tag -a v0.2.1 -m "Fix for v0.2.0"
git push origin v0.2.1

📚 Additional Resources

❓ Troubleshooting

Workflow Fails to Trigger

  • Ensure the tag follows the v*.*.* format
  • Check that you pushed the tag: git push origin v0.2.0
  • Verify GitHub Actions is enabled in repository settings

Docker Build Fails

  • Check the workflow logs in GitHub Actions
  • Verify the Dockerfile is valid
  • Ensure all dependencies are available

Release Not Created

  • Check that the tag starts with v (e.g., v0.2.0)
  • Verify the workflow has contents: write permission
  • Check GitHub Actions logs for errors

Image Not Published

  • Verify GitHub Packages is enabled
  • Check repository permissions for GITHUB_TOKEN
  • Ensure the workflow completed successfully

Last updated 2026-11-13