forked from AitorDB/home-assistant-sun-card
-
Notifications
You must be signed in to change notification settings - Fork 40
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Improve normalization/calculation of event times (#18)
- Resolve issue with using UTC to normalize event times - Handle high latitude locations where sunrise/sunset may not occur at certain times - Show the time for events that don't occur as "-" (previously the whole label would disappear) - Add interactive dev mode with predefined locations/dates to test things in a visual manner + python script to generate new test data if needed - More tests
- Loading branch information
Showing
18 changed files
with
1,339 additions
and
148 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,3 +14,6 @@ trim_trailing_whitespace = false | |
|
||
[*.snap] | ||
trim_trailing_whitespace = false | ||
|
||
[*.py] | ||
indent_size = 4 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
module.exports = { | ||
"env": { | ||
"browser": true | ||
}, | ||
"rules": { | ||
"semi": ["warn", "always"], | ||
"space-before-function-paren": ["warn", "never"], | ||
"no-console": "warn", | ||
"no-multiple-empty-lines": "warn", | ||
"prefer-const": "warn", | ||
"@typescript-eslint/no-floating-promises": "off", | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@100;200;300;400;500;700;900&display=swap'); | ||
|
||
body { | ||
font-family: 'Roboto', sans-serif; | ||
background: #111111; | ||
} | ||
|
||
body.light { | ||
background: #fafafa; | ||
} | ||
|
||
.card { | ||
background-color: rgb(28, 28, 28); | ||
border-radius: 4px; | ||
box-shadow: 0px 2px 1px -1px rgba(0, 0, 0, 0.2), 0px 1px 1px 0px rgba(0, 0, 0, 0.14), 0px 1px 3px 0px rgba(0, 0, 0, 0.12); | ||
padding: 1rem; | ||
max-width: 1000px; | ||
margin: auto; | ||
} | ||
|
||
body.light .card { | ||
background-color: #fff; | ||
} | ||
|
||
body:not(.light) #dev-panel { | ||
color: #fafafa; | ||
} | ||
|
||
#dev-panel > div { | ||
max-width: 1000px; | ||
margin: auto; | ||
} | ||
|
||
#time { | ||
text-align: center; | ||
padding-top: 1em; | ||
font-size: 120%; | ||
} | ||
|
||
#buttons { | ||
display: flex; | ||
} | ||
|
||
#langs { | ||
display: flex; | ||
flex-wrap: wrap; | ||
justify-content: space-around; | ||
} | ||
|
||
#buttons > div { | ||
flex-grow: 1; | ||
} | ||
|
||
.radio { | ||
display: flex; | ||
flex-flow: column wrap; | ||
max-height: 150px; | ||
} | ||
|
||
#buttons div { | ||
padding-bottom: 10px; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,180 @@ | ||
const settings = { | ||
darkMode: false, | ||
intervalUpdateMs: 200, | ||
stepMinutes: 20, | ||
date: null, | ||
place: null, | ||
lang: "en" | ||
}; | ||
|
||
fetch("/test-data.json") | ||
.then((response) => response.json()) | ||
.then((json) => init(json)); | ||
|
||
function init(testData) { | ||
const test = document.querySelector("#test"); | ||
const dates = Object.keys(testData); | ||
const places = Object.keys(testData[dates[0]]); | ||
settings.date = dates[0]; | ||
settings.place = places[0]; | ||
|
||
createRadioButtons("Date", "date", dates, settings.date, | ||
(date) => { | ||
settings.date = date; | ||
update(); | ||
}); | ||
|
||
createRadioButtons("Place", "place", places, settings.place, | ||
(place) => { | ||
settings.place = place; | ||
update(); | ||
}); | ||
|
||
createRadioButtons("Step (minutes)", "step", [20, 10, 5], settings.stepMinutes, (min) => { | ||
settings.stepMinutes = min; | ||
update(); | ||
}); | ||
|
||
createRadioButtons("Interval (ms)", "interval", [500, 200, 100], settings.intervalUpdateMs, | ||
(ms) => { | ||
settings.intervalUpdateMs = ms; | ||
resetInterval(); | ||
}); | ||
|
||
createRadioButtons("Theme", "theme", ["Light", "Dark"], settings.darkMode ? "Dark" : "Light", | ||
(theme) => { | ||
settings.darkMode = theme !== "Light"; | ||
update(); | ||
}); | ||
|
||
createLanguageButtons((lang) => { | ||
settings.lang = lang; | ||
update(); | ||
}); | ||
|
||
let fixedOffset = 6 * 60 * 60 * 1000; // 06:00:00 | ||
let interval = null; | ||
|
||
update(); | ||
|
||
resetInterval(); | ||
|
||
function update() { | ||
const hours = Math.floor(fixedOffset / (60 * 60 * 1000)); | ||
const remainingMillis = fixedOffset % (60 * 60 * 1000); | ||
const minutes = Math.floor(remainingMillis / (60 * 1000)); | ||
const fixedTime = String(hours).padStart(2, "0") + ":" + String(minutes).padStart(2, "0"); | ||
test.setFixedNow(new Date(settings.date + "T" + fixedTime + ":00")); | ||
document.querySelector("#time").innerText = fixedTime; | ||
|
||
if (settings.darkMode) { | ||
document.body.classList.remove("light"); | ||
} else { | ||
document.body.classList.add("light"); | ||
} | ||
test.setConfig({ | ||
title: "Sunrise & Sunset", | ||
showAzimuth: true, | ||
showElevation: true, | ||
darkMode: settings.darkMode | ||
}); | ||
|
||
const tzOffset = testData[settings.date][settings.place]["tzOffset"]; | ||
const sunData = Object.assign({}, testData[settings.date][settings.place]["sun"]); | ||
fixAllTimesTz(sunData, tzOffset); | ||
|
||
test.hass = { | ||
language: settings.lang, | ||
locale: { | ||
language: settings.lang | ||
}, | ||
themes: { | ||
darkMode: settings.darkMode | ||
}, | ||
states: { | ||
"sun.sun": { | ||
state: "above_horizon", | ||
attributes: sunData | ||
} | ||
} | ||
}; | ||
} | ||
|
||
function resetInterval() { | ||
if (interval) { | ||
clearInterval(interval); | ||
} | ||
interval = setInterval(function() { | ||
fixedOffset += settings.stepMinutes * 60 * 1000; | ||
if (fixedOffset >= 24 * 60 * 60 * 1000) { | ||
fixedOffset = 0; | ||
} | ||
update(); | ||
}, settings.intervalUpdateMs); | ||
} | ||
|
||
function fixAllTimesTz(sunData, tzOffset) { | ||
Object.keys(sunData).forEach(key => { | ||
if (key.startsWith("next_")) { | ||
sunData[key] = fixTz(sunData[key], tzOffset); | ||
} | ||
}); | ||
} | ||
|
||
function fixTz(date, tzOffset) { | ||
const original = new Date(date); | ||
const localTzOffset = original.getTimezoneOffset() * 60; | ||
return new Date(original.getTime() + localTzOffset * 1000 + tzOffset * 1000).toISOString(); | ||
} | ||
} | ||
|
||
function createRadioButtons(desc, id, values, defaultValue, callback) { | ||
const buttons = document.querySelector("#buttons"); | ||
const container = document.createElement("div"); | ||
container.id = id; | ||
if (values.length > 5) { | ||
// I couldn't figure how to make the div grow on its own when the items wrap :( | ||
container.style.flexGrow = "2.4"; | ||
} | ||
buttons.appendChild(container); | ||
|
||
const label = document.createElement("div"); | ||
label.innerText = desc; | ||
container.appendChild(label); | ||
|
||
const radios = document.createElement("div"); | ||
radios.classList.add("radio"); | ||
container.appendChild(radios); | ||
|
||
values.forEach((value, i) => { | ||
const div = document.createElement("div"); | ||
radios.appendChild(div); | ||
|
||
const input = document.createElement("input"); | ||
input.type = "radio"; | ||
input.id = id + i; | ||
input.name = id; | ||
input.value = value; | ||
if (value === defaultValue) { | ||
input.setAttribute("checked", "checked"); | ||
} | ||
input.onclick = () => callback(value); | ||
div.appendChild(input); | ||
|
||
const label = document.createElement("label"); | ||
label.setAttribute("for", input.id); | ||
label.innerText = value; | ||
div.appendChild(label); | ||
}); | ||
} | ||
|
||
function createLanguageButtons(callback) { | ||
const langs = document.querySelector("#langs"); | ||
Object.keys(window.Constants.LOCALIZATION_LANGUAGES).forEach(lang => { | ||
const langButton = document.createElement("button"); | ||
langButton.innerText = lang; | ||
langButton.onclick = () => callback(lang); | ||
langs.appendChild(langButton); | ||
}); | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
""" | ||
Generates test data (sun event times) for some places and dates. | ||
The test data will be saved to test-data.json in the same directory. | ||
The values are generated with the assumption that the current moment is either | ||
before noon (but after sunrise) or after noon (but before dusk) to mimic | ||
how Home Assistant provides those values: | ||
- next_dawn will be tomorrow's value | ||
- next_dusk will be today's value | ||
- next_midnight will be today's value | ||
- next_noon will be today or tomorrow's value | ||
- next_sunrise will be tomorrow's value | ||
- next_sunset will be today's value | ||
All times will be serialized with TZ +00:00 to mimic Home Assistant. | ||
The generated values for azimuth and elevation correspond to the moment of solar noon. | ||
The elevation is used to detect certain weirdness at high latitudes when the sun is | ||
below the horizon -- since the generated elevation is the maximum for the day it | ||
does the job fine even though it doesn't change as it would in a real environment. | ||
""" | ||
import datetime | ||
import json | ||
|
||
from astral import LocationInfo | ||
from astral.sun import azimuth, elevation, dawn, dusk, noon, sunrise, sunset, midnight | ||
|
||
# Places to generate data for | ||
PLACES = [ | ||
LocationInfo("Sofia", "BG", "Europe/Sofia", 42.7, 23.3), | ||
LocationInfo("Berlin", "DE", "Europe/Berlin", 52.5, 13.4), | ||
LocationInfo("Karasjok", "NO", "Europe/Oslo", 69.5, 25.5), | ||
LocationInfo("Quito", "EC", "America/Lima", -0.15, -78.5), | ||
LocationInfo("Sao Paulo", "BR", "America/Sao_Paulo", -23.5, -46.6) | ||
] | ||
|
||
# Dates to generate data for with a boolean that indicates if next_noon is the same day | ||
DATES = [ | ||
# Quirky in Karasjok - sunrise for next day, no sunrise/sunset on actual day | ||
(datetime.date(2023, 1, 19), False), | ||
(datetime.date(2023, 3, 17), True), | ||
# Quirky in Karasjok - sunrise is right after sunset before midnight | ||
(datetime.date(2023, 5, 18), False), | ||
# Summer solstice | ||
(datetime.date(2023, 6, 21), True), | ||
(datetime.date(2023, 9, 21), False), | ||
(datetime.date(2023, 8, 8), True), | ||
(datetime.date(2023, 11, 1), False), | ||
# Winter solstice | ||
(datetime.date(2023, 12, 22), True) | ||
] | ||
|
||
|
||
def next_event(place, date, event_fun): | ||
mod = 0 | ||
while True: | ||
# Loop until we find the next event, this mimics Home Assistant's Sun integration | ||
try: | ||
check_date = date + datetime.timedelta(days=mod) | ||
return event_fun(place.observer, date=check_date, tzinfo="UTC").isoformat() | ||
except ValueError: | ||
mod += 1 | ||
|
||
|
||
def generate_data(place, date_today, tomorrow_noon): | ||
date_tomorrow = date_today + datetime.timedelta(days=1) | ||
|
||
_noon = noon(place, date_today, place.tzinfo) | ||
_azimuth = round(azimuth(place, _noon), 2) | ||
_elevation = round(elevation(place, _noon), 2) | ||
|
||
# tz and tzOffset are used by the dev code | ||
return { | ||
"tz": place.tzinfo.zone, | ||
"tzOffset": noon(place, date_today, place.tzinfo).utcoffset().total_seconds(), | ||
"sun": { | ||
'next_dawn': next_event(place, date_tomorrow, dawn), | ||
'next_dusk': next_event(place, date_today, dusk), | ||
'next_midnight': next_event(place, date_tomorrow, midnight), | ||
'next_noon': next_event(place, | ||
date_tomorrow if tomorrow_noon else date_today, | ||
noon), | ||
'next_rising': next_event(place, date_tomorrow, sunrise), | ||
'next_setting': next_event(place, date_today, sunset), | ||
'elevation': _elevation, | ||
'azimuth': _azimuth, | ||
'rising': False | ||
}, | ||
} | ||
|
||
|
||
data = {} | ||
for date, tomorrow_noon in DATES: | ||
place_data = {} | ||
data[str(date)] = place_data | ||
for place in PLACES: | ||
place_name = f"{place.name} {place.region} ({place.latitude} {place.longitude})" | ||
place_data[place_name] = generate_data(place, date, tomorrow_noon) | ||
|
||
with open('test-data.json', 'w') as fp: | ||
json.dump(data, fp, indent=2) | ||
print(json.dumps(data, indent=2)) |
Oops, something went wrong.