diff --git a/CLAUDE.md b/CLAUDE.md
index 59dce74..878c038 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -132,6 +132,30 @@ showcase updates itself. (Manual local regen still works too; the sandbox needs
Newest first. One short entry per working session: what shipped + open threads.
+### 2026-07-21 — Sun card: AstroWeather moon data + azimuth positioning (v0.17.1)
+- **User:** add "next full moon", "next dark night", and make the moon graphic
+ reflect the moon azimuth — data from the AstroWeather integration
+ (mawinkler/astroweather).
+- `_moonView` now positions the moon glyph on the sky **by azimuth**: a dedicated
+ `moon_azimuth_entity` (or an `azimuth`/`moon_azimuth` attribute on the moon
+ sensor) maps E(90)→S(180)→W(270) to left→top→right along a dome; `moon_altitude`
+ (when present as an attr) scales how high it rides. No azimuth → centred disk
+ fallback. Verified E/S/W → cx 40/100/160.
+- Added chips below the ends row: moonrise ↑ / moonset ↓ (moved off the old
+ subrow), **Full in Xd** (`next_full_moon_entity`, a timestamp via `relFuture`),
+ and **Dark …** (`next_dark_night_entity` — a timestamp → "in Xh", or a duration
+ sensor like AstroWeather `deep_sky_darkness` → "6.2 h"). Fixed a `relFuture`
+ rollover bug ("4d 24h" → "5d"; minute carry too).
+- `getGridOptions`/`getCardSize` bump to **4 rows** when any optional moon sensor
+ is set (room for the chip row); 3 otherwise. New config keys: `moon_azimuth_entity`,
+ `next_full_moon_entity`, `next_dark_night_entity` (+ existing moonrise/moonset/
+ illumination). Editor Moon section extended + hint mentions AstroWeather.
+- Wired smoke (moon azimuth/next-full-moon/deep-sky-darkness fixtures + case) and
+ preview (Sun & Moon demo now passes all sensors). README + docs/cards.md option
+ table, AstroWeather example, sizing note. VERSION 0.17.0 → 0.17.1. Verified in
+ Chromium (azimuth placement, chips, 4-row grid, no console errors); build +
+ smoke green.
+
### 2026-07-21 — Sun card: moon-phase view after sunset (v0.17.0)
- **User:** refactor the sun card to also show moon phases / rise / set, with an
option to show the moon info after sunset. Kept the daytime sun-path arc as-is
diff --git a/README.md b/README.md
index 951edf8..b493022 100644
--- a/README.md
+++ b/README.md
@@ -236,8 +236,12 @@ title: Sun
accent: amber
show_moon: true # after sunset: moon phase + illumination
moon_entity: sensor.moon # optional (defaults to sensor.moon)
-# moonrise_entity: sensor.moonrise # optional
-# moonset_entity: sensor.moonset # optional
+# Optional (AstroWeather): azimuth positions the moon; plus full-moon / dark-night chips
+# moon_azimuth_entity: sensor.astroweather_moon_azimuth
+# moonrise_entity: sensor.astroweather_moonrise
+# moonset_entity: sensor.astroweather_moonset
+# next_full_moon_entity: sensor.astroweather_moon_next_full_moon
+# next_dark_night_entity: sensor.astroweather_deep_sky_darkness
```
```yaml
diff --git a/docs/cards.md b/docs/cards.md
index b178fd1..5ef5051 100644
--- a/docs/cards.md
+++ b/docs/cards.md
@@ -544,7 +544,7 @@ units: us
Flat **sun-path arc**: a dome of the sky from sunrise to sunset with the sun riding along it, the **traveled portion filled with the accent**, **sunrise/sunset times** at each end, and a live **"sets in / rises in"** countdown in the middle.
-Optionally, **after sunset** it flips to a **moon view**: a moon-phase glyph drawn to the current illuminated fraction, the phase name + **illumination %**, the next **sunrise**, and — when their sensors are configured — **moonrise / moonset**.
+Optionally, **after sunset** it flips to a **moon view**: a moon-phase glyph drawn to the current illuminated fraction and **positioned on the sky by the moon's azimuth**, the phase name + **illumination %**, the next **sunrise**, and chips for **moonrise / moonset**, **next full moon**, and **next dark night** when their sensors are configured.
| Key | Type | Default | Notes |
|-----|------|---------|-------|
@@ -553,11 +553,16 @@ Optionally, **after sunset** it flips to a **moon view**: a moon-phase glyph dra
| `title` | string | — | Card header. |
| `accent` | string | `amber` | Colours the traveled arc and the sun. |
| `show_moon` | bool | `true` | After sunset, show the moon view instead of the resting sun arc. |
-| `moon_entity` | string | `sensor.moon` | Moon-phase sensor (the Moon integration's `sensor.moon`). Its state is a phase name like `waxing_gibbous`, or a numeric phase. |
-| `moonrise_entity` | string | — | Optional sensor with a moonrise timestamp. |
-| `moonset_entity` | string | — | Optional sensor with a moonset timestamp. |
+| `moon_entity` | string | `sensor.moon` | Moon-phase sensor (the Moon integration's `sensor.moon`). State is a phase name like `waxing_gibbous`, or a numeric phase. |
+| `moon_azimuth_entity` | string | — | Optional azimuth sensor (degrees) — positions the moon on the sky (E→S→W maps left→top→right). Also read from an `azimuth`/`moon_azimuth` attribute on the moon sensor. AstroWeather: `moon_azimuth`. |
+| `moonrise_entity` | string | — | Optional moonrise timestamp sensor. |
+| `moonset_entity` | string | — | Optional moonset timestamp sensor. |
+| `next_full_moon_entity` | string | — | Optional timestamp sensor → a "Full in Xd" chip. AstroWeather: `moon_next_full_moon`. |
+| `next_dark_night_entity` | string | — | Optional. A timestamp → "Dark in Xh", or a duration sensor (e.g. AstroWeather `deep_sky_darkness` hours) → "Dark 6.2 h". |
| `moon_illumination_entity` | string | — | Optional illumination-% sensor. If unset, illumination is computed from the phase. |
+The azimuth / next-full-moon / dark-night values are designed to pair with the **[AstroWeather](https://github.com/mawinkler/astroweather)** integration, but any sensor providing those works. When any of the optional moon sensors are set, the tile is one grid row taller to fit the chip row.
+
Daytime progress uses the next setting as today's sunset and approximates today's sunrise from the next rising. At night, with `show_moon` on and a moon sensor available, the card shows the moon phase; otherwise the arc rests with a moon on the horizon. Tap opens more-info.
```yaml
@@ -579,6 +584,20 @@ moonrise_entity: sensor.moonrise # optional
moonset_entity: sensor.moonset # optional
```
+```yaml
+# Full moon view with AstroWeather sensors.
+type: custom:prism-sun-card
+entity: sun.sun
+title: Sun & Moon
+accent: amber
+moon_entity: sensor.astroweather_moon_phase
+moon_azimuth_entity: sensor.astroweather_moon_azimuth
+moonrise_entity: sensor.astroweather_moonrise
+moonset_entity: sensor.astroweather_moonset
+next_full_moon_entity: sensor.astroweather_moon_next_full_moon
+next_dark_night_entity: sensor.astroweather_deep_sky_darkness
+```
+
---
## `custom:prism-uv-card`
@@ -730,7 +749,7 @@ Cards implement `getGridOptions()` for the sections layout:
| Wind | 3 | 6 |
| Weather | 3 | 6 |
| Forecast | 3 | 12 |
-| Sun | 3 | 6 |
+| Sun | 3 (4 with moon chips) | 6 |
| UV | 3 | 6 |
| Rain | 3 | 6 |
| Air Quality | 3 | 6 |
diff --git a/prism.js b/prism.js
index d7742e4..b5a36f7 100644
--- a/prism.js
+++ b/prism.js
@@ -20,7 +20,7 @@
if (window.PrismUI && window.PrismUI.version) return; // already loaded
- const VERSION = '0.17.0';
+ const VERSION = '0.17.1';
// ── Named accent presets ──────────────────────────────────────────
// Selectable in every card editor; a card may also use a raw hex value.
@@ -4809,10 +4809,12 @@
* times at each end, and a live "sets in / rises in" countdown. Reads `sun.sun`.
*
* Optionally, after sunset it flips to a **moon view**: a moon-phase glyph
- * drawn to the current illuminated fraction, the phase name + illumination, the
- * next sunrise, and (when sensors are configured) moonrise / moonset. Reads a
- * moon-phase sensor (`sensor.moon` from the Moon integration) plus optional
- * moonrise / moonset / illumination sensors.
+ * drawn to the current illuminated fraction and positioned on the sky by the
+ * moon's azimuth, the phase name + illumination, the next sunrise, and chips
+ * for moonrise / moonset / next full moon / next dark night. The extra data
+ * (azimuth, next full moon, dark-night duration) is designed to pair with the
+ * AstroWeather integration (github.com/mawinkler/astroweather), but any sensor
+ * providing those values works.
*
* type: custom:prism-sun-card
*/
@@ -4827,6 +4829,22 @@
const m = Math.round(ms / 60000), h = Math.floor(m / 60), mm = m % 60;
return h ? `${h}h ${mm}m` : `${mm}m`;
}
+ // Coarse "in Xd / Xh / Xm" for a future timestamp (used for the next full moon).
+ function relFuture(t) {
+ if (isNaN(t)) return '';
+ const d = t - Date.now();
+ if (d <= 0) return 'now';
+ let days = Math.floor(d / DAY);
+ if (days >= 1) {
+ let h = Math.round((d - days * DAY) / 3600000);
+ if (h >= 24) { days += Math.floor(h / 24); h %= 24; } // avoid "4d 24h"
+ return h ? `${days}d ${h}h` : `${days}d`;
+ }
+ let h = Math.floor(d / 3600000);
+ let mm = Math.round((d % 3600000) / 60000);
+ if (mm >= 60) { h += 1; mm = 0; }
+ return h ? `${h}h ${mm}m` : `${mm}m`;
+ }
// Moon phase names (HA `sensor.moon`) → label + phase fraction k
// (0 = new, 0.25 = first quarter, 0.5 = full, 0.75 = last quarter).
@@ -4843,7 +4861,7 @@
const illumFromK = (k) => (1 - Math.cos(2 * Math.PI * k)) / 2;
// Resolve a moon-phase state into { label, k }. Accepts a phase name, or a
- // numeric value (0–1 fraction or 0–100 percent → nearest named phase feel).
+ // numeric value (0–1 fraction or 0–100 percent).
function moonInfo(state) {
const s = String(state == null ? '' : state).toLowerCase().trim();
if (MOON[s]) return MOON[s];
@@ -4852,7 +4870,6 @@
const n = parseFloat(s);
if (!isNaN(n)) {
const k = n > 1 ? (n / 100) % 1 : n % 1; // percent or fraction → cycle pos
- // Nearest named phase for the label.
let best = 'new_moon', bd = Infinity;
for (const name in MOON) { const d = Math.abs(MOON[name].k - k); if (d < bd) { bd = d; best = name; } }
return { label: MOON[best].label, k };
@@ -4885,10 +4902,13 @@
this._section('Moon'),
this._switch('Show moon after sunset', c.show_moon !== false, (v) => this._patch('show_moon', v)),
this._picker('Moon phase sensor', c.moon_entity, (v) => this._patch('moon_entity', v), { domains: ['sensor'] }),
+ this._picker('Moon azimuth sensor (optional)', c.moon_azimuth_entity, (v) => this._patch('moon_azimuth_entity', v), { domains: ['sensor'] }),
this._picker('Moonrise sensor (optional)', c.moonrise_entity, (v) => this._patch('moonrise_entity', v), { domains: ['sensor'] }),
this._picker('Moonset sensor (optional)', c.moonset_entity, (v) => this._patch('moonset_entity', v), { domains: ['sensor'] }),
+ this._picker('Next full moon sensor (optional)', c.next_full_moon_entity, (v) => this._patch('next_full_moon_entity', v), { domains: ['sensor'] }),
+ this._picker('Next dark night sensor (optional)', c.next_dark_night_entity, (v) => this._patch('next_dark_night_entity', v), { domains: ['sensor'] }),
this._picker('Illumination sensor (optional)', c.moon_illumination_entity, (v) => this._patch('moon_illumination_entity', v), { domains: ['sensor'] }),
- this._hint('Defaults to sensor.moon (Moon integration) for the phase. Moonrise/moonset need their own sensors; illumination is computed from the phase if no sensor is set.')
+ this._hint('Defaults to sensor.moon for the phase. Azimuth positions the moon on the sky; next full moon / next dark night pair with the AstroWeather integration (moon_azimuth, moon_next_full_moon, deep_sky_darkness). Illumination is computed from the phase if no sensor is set.')
);
}
}
@@ -4910,8 +4930,18 @@
set hass(hass) { this._hass = hass; this._render(); }
- getCardSize() { return 3; }
- getGridOptions() { return { rows: 3, columns: 6, min_rows: 3, min_columns: 4 }; }
+ // The moon view can carry an extra chip row, so make the tile a touch taller
+ // when any of the optional moon sensors are configured.
+ _hasMoonExtras() {
+ const c = this._config || {};
+ return c.show_moon !== false && !!(c.moonrise_entity || c.moonset_entity || c.next_full_moon_entity || c.next_dark_night_entity);
+ }
+ getCardSize() { return this._hasMoonExtras() ? 4 : 3; }
+ getGridOptions() {
+ return this._hasMoonExtras()
+ ? { rows: 4, columns: 6, min_rows: 3, min_columns: 4 }
+ : { rows: 3, columns: 6, min_rows: 3, min_columns: 4 };
+ }
static getConfigElement() { return document.createElement('prism-sun-card-editor'); }
static getStubConfig(hass) {
@@ -4925,11 +4955,38 @@
}));
}
- _num(id) {
- return id && this._hass.states[id] ? parseFloat(this._hass.states[id].state) : NaN;
- }
- _time(id) {
- return id && this._hass.states[id] ? Date.parse(this._hass.states[id].state) : NaN;
+ _num(id) { return id && this._hass.states[id] ? parseFloat(this._hass.states[id].state) : NaN; }
+ _time(id) { return id && this._hass.states[id] ? Date.parse(this._hass.states[id].state) : NaN; }
+
+ // Moon azimuth: a dedicated sensor, else an azimuth attribute on the moon
+ // sensor (AstroWeather exposes moon_azimuth as both).
+ _azimuth(c, moonSt) {
+ const a = this._num(c.moon_azimuth_entity);
+ if (!isNaN(a)) return ((a % 360) + 360) % 360;
+ const at = (moonSt && moonSt.attributes) || {};
+ const n = parseFloat(at.azimuth != null ? at.azimuth : at.moon_azimuth);
+ return isNaN(n) ? NaN : ((n % 360) + 360) % 360;
+ }
+ _altitude(c, moonSt) {
+ const src = (c.moon_azimuth_entity && this._hass.states[c.moon_azimuth_entity]) || moonSt;
+ const at = (src && src.attributes) || {};
+ const n = parseFloat(at.elevation != null ? at.elevation : (at.altitude != null ? at.altitude : at.moon_altitude));
+ return isNaN(n) ? NaN : n;
+ }
+ // "Next dark night" chip text: a timestamp → "in Xh", or a duration sensor
+ // (e.g. AstroWeather deep-sky darkness hours) → "6.2 h".
+ _darkText(id) {
+ const st = id && this._hass.states[id];
+ if (!st) return '';
+ const s = st.state;
+ if (s == null || s === '' || s === 'unknown' || s === 'unavailable') return '';
+ if (/^\d{4}-\d\d-\d\dt/i.test(String(s))) {
+ const t = Date.parse(s);
+ if (!isNaN(t)) { const r = relFuture(t); return r === 'now' ? fmtT(t) : `in ${r}`; }
+ }
+ const n = parseFloat(s);
+ if (!isNaN(n)) { const u = st.attributes.unit_of_measurement || 'h'; return `${P.fmtNumber(n, n < 10 ? 1 : 0)} ${u}`; }
+ return String(s);
}
_render() {
@@ -4946,8 +5003,6 @@
const elev = parseFloat(a.elevation);
const isDay = st ? (st.state === 'above_horizon' || (!isNaN(elev) && elev > 0)) : false;
- // Should we render the moon view? Only after sunset, when enabled and a
- // moon-phase sensor is available.
const moonId = c.moon_entity || 'sensor.moon';
const moonSt = this._hass.states[moonId];
const showMoon = c.show_moon !== false && !isDay && !!moonSt;
@@ -4968,9 +5023,10 @@
.moon-lit { fill:#cdd6e5; stroke:var(--_text-2); stroke-width:1.2; }
.star { fill:var(--_text-2); }
.moon .end ha-icon { color:var(--_text-2); }
- .subrow { display:flex; align-items:center; justify-content:center; gap:10px; margin-top:6px;
- font-size:12px; font-weight:600; color:var(--_text-2); --mdc-icon-size:15px; }
- .subrow .rs { display:inline-flex; align-items:center; gap:4px; }
+ .chips { display:flex; flex-wrap:wrap; justify-content:center; gap:6px; margin-top:8px; }
+ .chip { display:inline-flex; align-items:center; gap:4px; padding:3px 9px 3px 6px; border-radius:999px;
+ background:var(--_surface-2); font-size:11px; font-weight:600; color:var(--_text-2);
+ --mdc-icon-size:14px; white-space:nowrap; }
`;
const inner = showMoon
@@ -4987,7 +5043,7 @@
P.bindTap(this.shadowRoot.querySelector('.prism-card'), () => this._moreInfo(), () => this._moreInfo());
}
- // Daytime sun-path arc (unchanged behaviour).
+ // Daytime sun-path arc.
_sunView(a, st, rise, set, now, isDay, accent, name) {
let f = 0, haveArc = false;
if (isDay && !isNaN(rise) && !isNaN(set)) {
@@ -5046,32 +5102,52 @@
`;
}
- // Night moon view: phase glyph + phase name / illumination, next sunrise,
- // and moonrise / moonset when their sensors are configured.
+ // Night moon view: phase glyph positioned by azimuth, phase / illumination,
+ // next sunrise, and chips for moonrise / moonset / full moon / dark night.
_moonView(moonSt, sunRise, c, name) {
const mi = moonInfo(moonSt.state) || { label: (moonSt.state || 'Moon'), k: 0.5 };
const illumSensor = this._num(c.moon_illumination_entity);
const illum = !isNaN(illumSensor) ? illumSensor : Math.round(illumFromK(mi.k) * 100);
const moonrise = this._time(c.moonrise_entity);
const moonset = this._time(c.moonset_entity);
+ const az = this._azimuth(c, moonSt);
+ const alt = this._altitude(c, moonSt);
+
+ // Place the moon on the sky dome by azimuth (E→S→W maps left→top→right);
+ // altitude, when available, scales how high it rides. Falls back to a
+ // centred disk when there's no azimuth.
+ let cx = 100, cy = 40, r = 22;
+ const haveAz = !isNaN(az);
+ if (haveAz) {
+ const R = 60, CXo = 100, CYo = 78;
+ const fr = P.clamp((az - 90) / 180, 0, 1);
+ const th = Math.PI * (1 - fr);
+ const altScale = !isNaN(alt) ? P.clamp(alt / 70, 0.18, 1) : 0.85;
+ cx = CXo + R * Math.cos(th);
+ cy = CYo - R * Math.sin(th) * altScale;
+ r = 18;
+ }
- // Moon glyph (viewBox 200×96): a disk with the lit fraction + a few stars.
- const CX = 100, CY = 40, R = 26;
- const stars = [[46, 22], [58, 52], [150, 26], [162, 58], [134, 16]]
+ const stars = [[46, 22], [58, 52], [150, 26], [162, 58], [134, 16], [92, 12]]
.map(([x, y], i) => ``).join('');
- const litPath = mi.k <= 0.001 ? '' // new moon: nothing lit
- : mi.k >= 0.499 && mi.k <= 0.501
- ? `` // full
- : ``;
+ const litPath = mi.k <= 0.001 ? ''
+ : (mi.k >= 0.499 && mi.k <= 0.501)
+ ? ``
+ : ``;
- const rsBits = [];
- if (!isNaN(moonrise)) rsBits.push(`Moonrise ${fmtT(moonrise)}`);
- if (!isNaN(moonset)) rsBits.push(`Moonset ${fmtT(moonset)}`);
+ const chip = (icon, text) => `${P.esc(text)}`;
+ const chips = [];
+ if (!isNaN(moonrise)) chips.push(chip('mdi:arrow-up-thin', `Rise ${fmtT(moonrise)}`));
+ if (!isNaN(moonset)) chips.push(chip('mdi:arrow-down-thin', `Set ${fmtT(moonset)}`));
+ const ffm = this._time(c.next_full_moon_entity);
+ if (!isNaN(ffm)) chips.push(chip('mdi:moon-full', `Full in ${relFuture(ffm)}`));
+ const dark = this._darkText(c.next_dark_night_entity);
+ if (dark) chips.push(chip('mdi:weather-night', `Dark ${dark}`));
return `
@@ -5080,7 +5156,7 @@
${P.esc(mi.label)}
${fmtT(sunRise)}
- ${rsBits.length ? `
${rsBits.join('')}
` : ''}`;
+ ${chips.length ? `${chips.join('')}
` : ''}`;
}
}
@@ -5088,7 +5164,7 @@
P.registerCard({
type: 'prism-sun-card',
name: 'Prism Sun Card',
- description: 'Flat sun-path arc with sunrise/sunset + countdown; optionally flips to a moon-phase view after sunset.',
+ description: 'Flat sun-path arc with sunrise/sunset + countdown; optionally an azimuth-positioned moon-phase view (phase, illumination, next full moon, dark night) after sunset.',
});
})();
diff --git a/src/prism-shared.js b/src/prism-shared.js
index 1708362..8c8bc0d 100644
--- a/src/prism-shared.js
+++ b/src/prism-shared.js
@@ -16,7 +16,7 @@
if (window.PrismUI && window.PrismUI.version) return; // already loaded
- const VERSION = '0.17.0';
+ const VERSION = '0.17.1';
// ── Named accent presets ──────────────────────────────────────────
// Selectable in every card editor; a card may also use a raw hex value.
diff --git a/src/prism-sun-card.js b/src/prism-sun-card.js
index 5776d1b..0307028 100644
--- a/src/prism-sun-card.js
+++ b/src/prism-sun-card.js
@@ -5,10 +5,12 @@
* times at each end, and a live "sets in / rises in" countdown. Reads `sun.sun`.
*
* Optionally, after sunset it flips to a **moon view**: a moon-phase glyph
- * drawn to the current illuminated fraction, the phase name + illumination, the
- * next sunrise, and (when sensors are configured) moonrise / moonset. Reads a
- * moon-phase sensor (`sensor.moon` from the Moon integration) plus optional
- * moonrise / moonset / illumination sensors.
+ * drawn to the current illuminated fraction and positioned on the sky by the
+ * moon's azimuth, the phase name + illumination, the next sunrise, and chips
+ * for moonrise / moonset / next full moon / next dark night. The extra data
+ * (azimuth, next full moon, dark-night duration) is designed to pair with the
+ * AstroWeather integration (github.com/mawinkler/astroweather), but any sensor
+ * providing those values works.
*
* type: custom:prism-sun-card
*/
@@ -23,6 +25,22 @@
const m = Math.round(ms / 60000), h = Math.floor(m / 60), mm = m % 60;
return h ? `${h}h ${mm}m` : `${mm}m`;
}
+ // Coarse "in Xd / Xh / Xm" for a future timestamp (used for the next full moon).
+ function relFuture(t) {
+ if (isNaN(t)) return '';
+ const d = t - Date.now();
+ if (d <= 0) return 'now';
+ let days = Math.floor(d / DAY);
+ if (days >= 1) {
+ let h = Math.round((d - days * DAY) / 3600000);
+ if (h >= 24) { days += Math.floor(h / 24); h %= 24; } // avoid "4d 24h"
+ return h ? `${days}d ${h}h` : `${days}d`;
+ }
+ let h = Math.floor(d / 3600000);
+ let mm = Math.round((d % 3600000) / 60000);
+ if (mm >= 60) { h += 1; mm = 0; }
+ return h ? `${h}h ${mm}m` : `${mm}m`;
+ }
// Moon phase names (HA `sensor.moon`) → label + phase fraction k
// (0 = new, 0.25 = first quarter, 0.5 = full, 0.75 = last quarter).
@@ -39,7 +57,7 @@
const illumFromK = (k) => (1 - Math.cos(2 * Math.PI * k)) / 2;
// Resolve a moon-phase state into { label, k }. Accepts a phase name, or a
- // numeric value (0–1 fraction or 0–100 percent → nearest named phase feel).
+ // numeric value (0–1 fraction or 0–100 percent).
function moonInfo(state) {
const s = String(state == null ? '' : state).toLowerCase().trim();
if (MOON[s]) return MOON[s];
@@ -48,7 +66,6 @@
const n = parseFloat(s);
if (!isNaN(n)) {
const k = n > 1 ? (n / 100) % 1 : n % 1; // percent or fraction → cycle pos
- // Nearest named phase for the label.
let best = 'new_moon', bd = Infinity;
for (const name in MOON) { const d = Math.abs(MOON[name].k - k); if (d < bd) { bd = d; best = name; } }
return { label: MOON[best].label, k };
@@ -81,10 +98,13 @@
this._section('Moon'),
this._switch('Show moon after sunset', c.show_moon !== false, (v) => this._patch('show_moon', v)),
this._picker('Moon phase sensor', c.moon_entity, (v) => this._patch('moon_entity', v), { domains: ['sensor'] }),
+ this._picker('Moon azimuth sensor (optional)', c.moon_azimuth_entity, (v) => this._patch('moon_azimuth_entity', v), { domains: ['sensor'] }),
this._picker('Moonrise sensor (optional)', c.moonrise_entity, (v) => this._patch('moonrise_entity', v), { domains: ['sensor'] }),
this._picker('Moonset sensor (optional)', c.moonset_entity, (v) => this._patch('moonset_entity', v), { domains: ['sensor'] }),
+ this._picker('Next full moon sensor (optional)', c.next_full_moon_entity, (v) => this._patch('next_full_moon_entity', v), { domains: ['sensor'] }),
+ this._picker('Next dark night sensor (optional)', c.next_dark_night_entity, (v) => this._patch('next_dark_night_entity', v), { domains: ['sensor'] }),
this._picker('Illumination sensor (optional)', c.moon_illumination_entity, (v) => this._patch('moon_illumination_entity', v), { domains: ['sensor'] }),
- this._hint('Defaults to sensor.moon (Moon integration) for the phase. Moonrise/moonset need their own sensors; illumination is computed from the phase if no sensor is set.')
+ this._hint('Defaults to sensor.moon for the phase. Azimuth positions the moon on the sky; next full moon / next dark night pair with the AstroWeather integration (moon_azimuth, moon_next_full_moon, deep_sky_darkness). Illumination is computed from the phase if no sensor is set.')
);
}
}
@@ -106,8 +126,18 @@
set hass(hass) { this._hass = hass; this._render(); }
- getCardSize() { return 3; }
- getGridOptions() { return { rows: 3, columns: 6, min_rows: 3, min_columns: 4 }; }
+ // The moon view can carry an extra chip row, so make the tile a touch taller
+ // when any of the optional moon sensors are configured.
+ _hasMoonExtras() {
+ const c = this._config || {};
+ return c.show_moon !== false && !!(c.moonrise_entity || c.moonset_entity || c.next_full_moon_entity || c.next_dark_night_entity);
+ }
+ getCardSize() { return this._hasMoonExtras() ? 4 : 3; }
+ getGridOptions() {
+ return this._hasMoonExtras()
+ ? { rows: 4, columns: 6, min_rows: 3, min_columns: 4 }
+ : { rows: 3, columns: 6, min_rows: 3, min_columns: 4 };
+ }
static getConfigElement() { return document.createElement('prism-sun-card-editor'); }
static getStubConfig(hass) {
@@ -121,11 +151,38 @@
}));
}
- _num(id) {
- return id && this._hass.states[id] ? parseFloat(this._hass.states[id].state) : NaN;
+ _num(id) { return id && this._hass.states[id] ? parseFloat(this._hass.states[id].state) : NaN; }
+ _time(id) { return id && this._hass.states[id] ? Date.parse(this._hass.states[id].state) : NaN; }
+
+ // Moon azimuth: a dedicated sensor, else an azimuth attribute on the moon
+ // sensor (AstroWeather exposes moon_azimuth as both).
+ _azimuth(c, moonSt) {
+ const a = this._num(c.moon_azimuth_entity);
+ if (!isNaN(a)) return ((a % 360) + 360) % 360;
+ const at = (moonSt && moonSt.attributes) || {};
+ const n = parseFloat(at.azimuth != null ? at.azimuth : at.moon_azimuth);
+ return isNaN(n) ? NaN : ((n % 360) + 360) % 360;
+ }
+ _altitude(c, moonSt) {
+ const src = (c.moon_azimuth_entity && this._hass.states[c.moon_azimuth_entity]) || moonSt;
+ const at = (src && src.attributes) || {};
+ const n = parseFloat(at.elevation != null ? at.elevation : (at.altitude != null ? at.altitude : at.moon_altitude));
+ return isNaN(n) ? NaN : n;
}
- _time(id) {
- return id && this._hass.states[id] ? Date.parse(this._hass.states[id].state) : NaN;
+ // "Next dark night" chip text: a timestamp → "in Xh", or a duration sensor
+ // (e.g. AstroWeather deep-sky darkness hours) → "6.2 h".
+ _darkText(id) {
+ const st = id && this._hass.states[id];
+ if (!st) return '';
+ const s = st.state;
+ if (s == null || s === '' || s === 'unknown' || s === 'unavailable') return '';
+ if (/^\d{4}-\d\d-\d\dt/i.test(String(s))) {
+ const t = Date.parse(s);
+ if (!isNaN(t)) { const r = relFuture(t); return r === 'now' ? fmtT(t) : `in ${r}`; }
+ }
+ const n = parseFloat(s);
+ if (!isNaN(n)) { const u = st.attributes.unit_of_measurement || 'h'; return `${P.fmtNumber(n, n < 10 ? 1 : 0)} ${u}`; }
+ return String(s);
}
_render() {
@@ -142,8 +199,6 @@
const elev = parseFloat(a.elevation);
const isDay = st ? (st.state === 'above_horizon' || (!isNaN(elev) && elev > 0)) : false;
- // Should we render the moon view? Only after sunset, when enabled and a
- // moon-phase sensor is available.
const moonId = c.moon_entity || 'sensor.moon';
const moonSt = this._hass.states[moonId];
const showMoon = c.show_moon !== false && !isDay && !!moonSt;
@@ -164,9 +219,10 @@
.moon-lit { fill:#cdd6e5; stroke:var(--_text-2); stroke-width:1.2; }
.star { fill:var(--_text-2); }
.moon .end ha-icon { color:var(--_text-2); }
- .subrow { display:flex; align-items:center; justify-content:center; gap:10px; margin-top:6px;
- font-size:12px; font-weight:600; color:var(--_text-2); --mdc-icon-size:15px; }
- .subrow .rs { display:inline-flex; align-items:center; gap:4px; }
+ .chips { display:flex; flex-wrap:wrap; justify-content:center; gap:6px; margin-top:8px; }
+ .chip { display:inline-flex; align-items:center; gap:4px; padding:3px 9px 3px 6px; border-radius:999px;
+ background:var(--_surface-2); font-size:11px; font-weight:600; color:var(--_text-2);
+ --mdc-icon-size:14px; white-space:nowrap; }
`;
const inner = showMoon
@@ -183,7 +239,7 @@
P.bindTap(this.shadowRoot.querySelector('.prism-card'), () => this._moreInfo(), () => this._moreInfo());
}
- // Daytime sun-path arc (unchanged behaviour).
+ // Daytime sun-path arc.
_sunView(a, st, rise, set, now, isDay, accent, name) {
let f = 0, haveArc = false;
if (isDay && !isNaN(rise) && !isNaN(set)) {
@@ -242,32 +298,52 @@
`;
}
- // Night moon view: phase glyph + phase name / illumination, next sunrise,
- // and moonrise / moonset when their sensors are configured.
+ // Night moon view: phase glyph positioned by azimuth, phase / illumination,
+ // next sunrise, and chips for moonrise / moonset / full moon / dark night.
_moonView(moonSt, sunRise, c, name) {
const mi = moonInfo(moonSt.state) || { label: (moonSt.state || 'Moon'), k: 0.5 };
const illumSensor = this._num(c.moon_illumination_entity);
const illum = !isNaN(illumSensor) ? illumSensor : Math.round(illumFromK(mi.k) * 100);
const moonrise = this._time(c.moonrise_entity);
const moonset = this._time(c.moonset_entity);
+ const az = this._azimuth(c, moonSt);
+ const alt = this._altitude(c, moonSt);
+
+ // Place the moon on the sky dome by azimuth (E→S→W maps left→top→right);
+ // altitude, when available, scales how high it rides. Falls back to a
+ // centred disk when there's no azimuth.
+ let cx = 100, cy = 40, r = 22;
+ const haveAz = !isNaN(az);
+ if (haveAz) {
+ const R = 60, CXo = 100, CYo = 78;
+ const fr = P.clamp((az - 90) / 180, 0, 1);
+ const th = Math.PI * (1 - fr);
+ const altScale = !isNaN(alt) ? P.clamp(alt / 70, 0.18, 1) : 0.85;
+ cx = CXo + R * Math.cos(th);
+ cy = CYo - R * Math.sin(th) * altScale;
+ r = 18;
+ }
- // Moon glyph (viewBox 200×96): a disk with the lit fraction + a few stars.
- const CX = 100, CY = 40, R = 26;
- const stars = [[46, 22], [58, 52], [150, 26], [162, 58], [134, 16]]
+ const stars = [[46, 22], [58, 52], [150, 26], [162, 58], [134, 16], [92, 12]]
.map(([x, y], i) => ``).join('');
- const litPath = mi.k <= 0.001 ? '' // new moon: nothing lit
- : mi.k >= 0.499 && mi.k <= 0.501
- ? `` // full
- : ``;
+ const litPath = mi.k <= 0.001 ? ''
+ : (mi.k >= 0.499 && mi.k <= 0.501)
+ ? ``
+ : ``;
- const rsBits = [];
- if (!isNaN(moonrise)) rsBits.push(`Moonrise ${fmtT(moonrise)}`);
- if (!isNaN(moonset)) rsBits.push(`Moonset ${fmtT(moonset)}`);
+ const chip = (icon, text) => `${P.esc(text)}`;
+ const chips = [];
+ if (!isNaN(moonrise)) chips.push(chip('mdi:arrow-up-thin', `Rise ${fmtT(moonrise)}`));
+ if (!isNaN(moonset)) chips.push(chip('mdi:arrow-down-thin', `Set ${fmtT(moonset)}`));
+ const ffm = this._time(c.next_full_moon_entity);
+ if (!isNaN(ffm)) chips.push(chip('mdi:moon-full', `Full in ${relFuture(ffm)}`));
+ const dark = this._darkText(c.next_dark_night_entity);
+ if (dark) chips.push(chip('mdi:weather-night', `Dark ${dark}`));
return `
@@ -276,7 +352,7 @@
${P.esc(mi.label)}
${fmtT(sunRise)}
- ${rsBits.length ? `${rsBits.join('')}
` : ''}`;
+ ${chips.length ? `${chips.join('')}
` : ''}`;
}
}
@@ -284,6 +360,6 @@
P.registerCard({
type: 'prism-sun-card',
name: 'Prism Sun Card',
- description: 'Flat sun-path arc with sunrise/sunset + countdown; optionally flips to a moon-phase view after sunset.',
+ description: 'Flat sun-path arc with sunrise/sunset + countdown; optionally an azimuth-positioned moon-phase view (phase, illumination, next full moon, dark night) after sunset.',
});
})();
diff --git a/test/preview.html b/test/preview.html
index 7c78930..f5583b2 100644
--- a/test/preview.html
+++ b/test/preview.html
@@ -108,9 +108,12 @@ Prism — Card Preview
'weather.home': { state: 'partlycloudy', attributes: { friendly_name: 'Home', temperature: 18, apparent_temperature: 16, temperature_unit: '°C', humidity: 62, pressure: 1013, pressure_unit: 'hPa', wind_speed: 24, wind_bearing: 315, wind_gust_speed: 41, wind_speed_unit: 'km/h' } },
'sun.sun': { state: 'above_horizon', attributes: { friendly_name: 'Sun', next_rising: (() => { const d = new Date(); d.setDate(d.getDate() + 1); d.setHours(6, 12, 0, 0); return d.toISOString(); })(), next_setting: (() => { const d = new Date(); d.setHours(20, 24, 0, 0); return d.toISOString(); })(), elevation: 38, azimuth: 232 } },
'sun.after_sunset': { state: 'below_horizon', attributes: { friendly_name: 'Sun', next_rising: (() => { const d = new Date(); d.setDate(d.getDate() + 1); d.setHours(6, 14, 0, 0); return d.toISOString(); })(), next_setting: (() => { const d = new Date(); d.setDate(d.getDate() + 1); d.setHours(20, 22, 0, 0); return d.toISOString(); })(), elevation: -14, azimuth: 300 } },
- 'sensor.moon': { state: 'waxing_gibbous', attributes: { friendly_name: 'Moon' } },
+ 'sensor.moon': { state: 'waxing_gibbous', attributes: { friendly_name: 'Moon', azimuth: 205 } },
'sensor.moonrise': { state: (() => { const d = new Date(); d.setHours(15, 42, 0, 0); return d.toISOString(); })(), attributes: { friendly_name: 'Moonrise', device_class: 'timestamp' } },
'sensor.moonset': { state: (() => { const d = new Date(); d.setDate(d.getDate() + 1); d.setHours(2, 8, 0, 0); return d.toISOString(); })(), attributes: { friendly_name: 'Moonset', device_class: 'timestamp' } },
+ 'sensor.moon_azimuth': { state: '205.4', attributes: { friendly_name: 'Moon Azimuth', unit_of_measurement: '°', elevation: 34 } },
+ 'sensor.next_full_moon': { state: (() => { const d = new Date(); d.setDate(d.getDate() + 5); d.setHours(21, 0, 0, 0); return d.toISOString(); })(), attributes: { friendly_name: 'Next Full Moon', device_class: 'timestamp' } },
+ 'sensor.deep_sky_darkness': { state: '6.2', attributes: { friendly_name: 'Deep Sky Darkness', unit_of_measurement: 'h' } },
'sensor.uv_index': { state: '7', attributes: { friendly_name: 'UV Index' } },
'sensor.eco_temp': { state: '19.4', attributes: { friendly_name: 'Outdoor Temp', unit_of_measurement: '°C', device_class: 'temperature' } },
'sensor.eco_hum': { state: '58', attributes: { friendly_name: 'Outdoor Humidity', unit_of_measurement: '%', device_class: 'humidity' } },
@@ -219,7 +222,7 @@ Prism — Card Preview
{ type: 'prism-weather-card', cfg: { entity: 'weather.home', title: 'Weather (local sensors)', accent: 'blue', temperature_entity: 'sensor.eco_temp', humidity_entity: 'sensor.eco_hum', wind_speed_entity: 'sensor.eco_wind', wind_bearing_entity: 'sensor.eco_dir' } },
{ type: 'prism-forecast-card', cfg: { entity: 'weather.home', title: 'Forecast', forecast_type: 'daily', count: 7, accent: 'blue' }, wide: true },
{ type: 'prism-sun-card', cfg: { entity: 'sun.sun', title: 'Sun', accent: 'amber' } },
- { type: 'prism-sun-card', cfg: { entity: 'sun.after_sunset', title: 'Sun & Moon', accent: 'amber', moon_entity: 'sensor.moon', moonrise_entity: 'sensor.moonrise', moonset_entity: 'sensor.moonset' } },
+ { type: 'prism-sun-card', cfg: { entity: 'sun.after_sunset', title: 'Sun & Moon', accent: 'amber', moon_entity: 'sensor.moon', moon_azimuth_entity: 'sensor.moon_azimuth', moonrise_entity: 'sensor.moonrise', moonset_entity: 'sensor.moonset', next_full_moon_entity: 'sensor.next_full_moon', next_dark_night_entity: 'sensor.deep_sky_darkness' } },
{ type: 'prism-uv-card', cfg: { entity: 'sensor.uv_index', title: 'UV Index' } },
{ type: 'prism-rain-card', cfg: { title: 'Rainfall', accent: 'blue', event_entity: 'sensor.rain_event', intensity_entity: 'sensor.rain_rate', hourly_entity: 'sensor.rain_hourly', daily_entity: 'sensor.rain_daily', weekly_entity: 'sensor.rain_weekly', monthly_entity: 'sensor.rain_monthly' } },
{ type: 'prism-aqi-card', cfg: { title: 'Air Quality', entity: 'sensor.aqi' } },
diff --git a/test/smoke.js b/test/smoke.js
index a9d61dd..1966587 100644
--- a/test/smoke.js
+++ b/test/smoke.js
@@ -64,9 +64,12 @@ const hass = {
'sensor.wind_dir': { state: 'NW', attributes: { friendly_name: 'Wind Dir' } },
'sun.sun': { state: 'above_horizon', attributes: { friendly_name: 'Sun', next_rising: new Date(now + 20 * 3600000).toISOString(), next_setting: new Date(now + 6 * 3600000).toISOString(), elevation: 34, azimuth: 210 } },
'sun.night': { state: 'below_horizon', attributes: { friendly_name: 'Sun', next_rising: new Date(now + 9 * 3600000).toISOString(), next_setting: new Date(now + 20 * 3600000).toISOString(), elevation: -18, azimuth: 300 } },
- 'sensor.moon': { state: 'waxing_gibbous', attributes: { friendly_name: 'Moon' } },
+ 'sensor.moon': { state: 'waxing_gibbous', attributes: { friendly_name: 'Moon', azimuth: 205 } },
'sensor.moonrise': { state: new Date(now + 3 * 3600000).toISOString(), attributes: { friendly_name: 'Moonrise', device_class: 'timestamp' } },
'sensor.moonset': { state: new Date(now + 11 * 3600000).toISOString(), attributes: { friendly_name: 'Moonset', device_class: 'timestamp' } },
+ 'sensor.moon_azimuth': { state: '205.4', attributes: { friendly_name: 'Moon Azimuth', unit_of_measurement: '°', elevation: 32 } },
+ 'sensor.next_full_moon': { state: new Date(now + 5 * 86400000).toISOString(), attributes: { friendly_name: 'Next Full Moon', device_class: 'timestamp' } },
+ 'sensor.deep_sky_darkness': { state: '6.2', attributes: { friendly_name: 'Deep Sky Darkness', unit_of_measurement: 'h' } },
'sensor.uv_index': { state: '6.2', attributes: { friendly_name: 'UV Index' } },
'sensor.eco_temp': { state: '19.4', attributes: { friendly_name: 'Outdoor Temp', unit_of_measurement: '°C' } },
'sensor.eco_hum': { state: '58', attributes: { friendly_name: 'Outdoor Humidity', unit_of_measurement: '%' } },
@@ -164,7 +167,7 @@ check('parses compact history', async () => {});
['prism-forecast-card', { source: 'nws', location: 'custom', latitude: 40.7128, longitude: -74.006, forecast_type: 'hourly', count: 8 }],
['prism-sun-card', { entity: 'sun.sun', title: 'Sun' }],
['prism-sun-card', { entity: 'sun.sun', accent: 'orange' }],
- ['prism-sun-card', { entity: 'sun.night', show_moon: true, moon_entity: 'sensor.moon', moonrise_entity: 'sensor.moonrise', moonset_entity: 'sensor.moonset' }],
+ ['prism-sun-card', { entity: 'sun.night', show_moon: true, moon_entity: 'sensor.moon', moonrise_entity: 'sensor.moonrise', moonset_entity: 'sensor.moonset', moon_azimuth_entity: 'sensor.moon_azimuth', next_full_moon_entity: 'sensor.next_full_moon', next_dark_night_entity: 'sensor.deep_sky_darkness' }],
['prism-sun-card', { entity: 'sun.night', show_moon: false }],
['prism-uv-card', { entity: 'sensor.uv_index', title: 'UV' }],
['prism-uv-card', { entity: 'weather.x', attribute: 'uv_index', show_advice: false }],