Initial commit
@@ -0,0 +1,11 @@
|
||||
# .env
|
||||
|
||||
# Network & Sensor
|
||||
SENSOR_IP=192.168.0.188
|
||||
MQTT_USERNAME=weather_station
|
||||
MQTT_PASSWORD=SensorData12345!
|
||||
MQTT_FALLBACK_IP=192.168.0.168
|
||||
|
||||
# Location (Defaulting to your Skåne coordinates)
|
||||
LATITUDE=56.2006
|
||||
LONGITUDE=12.5553
|
||||
@@ -0,0 +1,46 @@
|
||||
# constants.py
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from tools.geometry import Coordinate
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# --- Network & Credentials ---
|
||||
SENSOR_IP = os.getenv("SENSOR_IP", "192.168.0.188")
|
||||
SENSOR_HTTP = f"http://{SENSOR_IP}"
|
||||
|
||||
MQTT_USERNAME = os.getenv("MQTT_USERNAME", "weather_station")
|
||||
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD", "SensorData12345!")
|
||||
MQTT_FALLBACK_IP = os.getenv("MQTT_FALLBACK_IP", "192.168.0.168")
|
||||
|
||||
# --- Location ---
|
||||
LAT = float(os.getenv("LATITUDE", "56.2006"))
|
||||
LONG = float(os.getenv("LONGITUDE", "12.5553"))
|
||||
HOME_LOCATION = Coordinate(LAT, LONG)
|
||||
|
||||
# --- Localization ---
|
||||
WEEK_DAYS = {
|
||||
0: "Måndag",
|
||||
1: "Tisdag",
|
||||
2: "Onsdag",
|
||||
3: "Torsdag",
|
||||
4: "Fredag",
|
||||
5: "Lördag",
|
||||
6: "Söndag",
|
||||
}
|
||||
|
||||
MONTH_NAMES = {
|
||||
1: "jan.",
|
||||
2: "feb.",
|
||||
3: "mars",
|
||||
4: "apr.",
|
||||
5: "maj",
|
||||
6: "juni",
|
||||
7: "juli",
|
||||
8: "aug.",
|
||||
9: "sep.",
|
||||
10: "okt.",
|
||||
11: "nov.",
|
||||
12: "dec.",
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
# display/__init__.py
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from PIL import Image
|
||||
|
||||
from .epd4in2 import EPD, epdconfig
|
||||
from .image_maker import eInkImage
|
||||
|
||||
logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Display:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.image = eInkImage()
|
||||
self.e_Paper = self._start_up()
|
||||
|
||||
def _start_up(self) -> EPD:
|
||||
try:
|
||||
epd = EPD()
|
||||
epd.init()
|
||||
epd.Clear()
|
||||
return epd
|
||||
except Exception as e:
|
||||
logging.error(f"eInk Display not found! | {e}")
|
||||
|
||||
@staticmethod
|
||||
def _wait_until(timestamp: str, absolute_date: datetime) -> None:
|
||||
while (
|
||||
not timestamp in datetime.today().time().strftime("%H:%M:%S")
|
||||
and absolute_date >= datetime.today()
|
||||
):
|
||||
pass
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def exit():
|
||||
epdconfig.module_exit()
|
||||
|
||||
def refresh(self, image: Image.Image, date: datetime, first: bool) -> None:
|
||||
try:
|
||||
self.e_Paper.Init_4Gray()
|
||||
if not first:
|
||||
self._wait_until(":00", date)
|
||||
self.e_Paper.display_4Gray(self.e_Paper.getbuffer_4Gray(image))
|
||||
self.e_Paper.sleep()
|
||||
except Exception as e:
|
||||
logging.error(f"Failed displaying image - ERROR: {e}")
|
||||
@@ -0,0 +1,118 @@
|
||||
# display/epdconfig.py
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DummyGPIO:
|
||||
BCM = OUT = IN = 0
|
||||
|
||||
@staticmethod
|
||||
def setmode(*args):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def setwarnings(*args):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def setup(*args):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def output(*args):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def input(*args):
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def cleanup(*args):
|
||||
pass
|
||||
|
||||
|
||||
class DummySPI:
|
||||
max_speed_hz = 4000000
|
||||
mode = 0b00
|
||||
|
||||
@staticmethod
|
||||
def open(*args):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def writebytes(*args):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def writebytes2(*args):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def close():
|
||||
pass
|
||||
|
||||
|
||||
try:
|
||||
import RPi.GPIO as GPIO
|
||||
import spidev
|
||||
|
||||
SPI = spidev.SpiDev()
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"RPi.GPIO or spidev not found. Using Dummy hardware drivers (Test Mode)."
|
||||
)
|
||||
GPIO = DummyGPIO()
|
||||
SPI = DummySPI()
|
||||
|
||||
RST_PIN = 17
|
||||
DC_PIN = 25
|
||||
CS_PIN = 8
|
||||
BUSY_PIN = 24
|
||||
PWR_PIN = 18
|
||||
|
||||
|
||||
def digital_write(pin, value):
|
||||
GPIO.output(pin, value)
|
||||
|
||||
|
||||
def digital_read(pin):
|
||||
return GPIO.input(pin)
|
||||
|
||||
|
||||
def delay_ms(delaytime):
|
||||
time.sleep(delaytime / 1000.0)
|
||||
|
||||
|
||||
def spi_writebyte(data):
|
||||
SPI.writebytes(data)
|
||||
|
||||
|
||||
def spi_writebyte2(data):
|
||||
SPI.writebytes2(data)
|
||||
|
||||
|
||||
def module_init():
|
||||
GPIO.setmode(GPIO.BCM)
|
||||
GPIO.setwarnings(False)
|
||||
GPIO.setup(RST_PIN, GPIO.OUT)
|
||||
GPIO.setup(DC_PIN, GPIO.OUT)
|
||||
GPIO.setup(CS_PIN, GPIO.OUT)
|
||||
GPIO.setup(PWR_PIN, GPIO.OUT)
|
||||
GPIO.setup(BUSY_PIN, GPIO.IN)
|
||||
GPIO.output(PWR_PIN, 1)
|
||||
|
||||
SPI.open(0, 0)
|
||||
SPI.max_speed_hz = 4000000
|
||||
SPI.mode = 0b00
|
||||
return 0
|
||||
|
||||
|
||||
def module_exit():
|
||||
SPI.close()
|
||||
GPIO.output(RST_PIN, 0)
|
||||
GPIO.output(DC_PIN, 0)
|
||||
GPIO.output(PWR_PIN, 0)
|
||||
GPIO.cleanup([RST_PIN, DC_PIN, CS_PIN, BUSY_PIN, PWR_PIN])
|
||||
@@ -0,0 +1,290 @@
|
||||
# display/image_maker.py
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
||||
|
||||
from fonts import Font
|
||||
from icons import Icon
|
||||
|
||||
from .epd4in2 import EPD_HEIGHT as HEIGHT
|
||||
from .epd4in2 import EPD_WIDTH as WIDTH
|
||||
|
||||
|
||||
def closest(num_list: list, K):
|
||||
return num_list[min(range(len(num_list)), key=lambda i: abs(num_list[i] - K))]
|
||||
|
||||
|
||||
def draw_temp_block(
|
||||
d: ImageDraw.ImageDraw,
|
||||
coords: tuple[int, int],
|
||||
content: list[tuple[str, int, ImageFont.FreeTypeFont]],
|
||||
):
|
||||
x, y = coords
|
||||
for text, color, font in content:
|
||||
d.text((x, y), text, fill=color, font=font)
|
||||
text_width = int(font.getlength(text))
|
||||
x += text_width
|
||||
|
||||
|
||||
class eInkImage:
|
||||
def __init__(self):
|
||||
self.info_font_y_offset = 1
|
||||
self.info_y_offset = 14
|
||||
|
||||
def make_image(
|
||||
self, date: tuple, weather_data: dict, network_status: bool
|
||||
) -> Image.Image:
|
||||
forecast = weather_data.get("forecast", {})
|
||||
image = Image.new("1", (WIDTH, HEIGHT), 255)
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# Top
|
||||
draw.text((6, 2), date[0], font=Font.f20, fill=0)
|
||||
draw.text((6, 20), date[1], font=Font.f60, fill=0)
|
||||
|
||||
# --- Left-side ---
|
||||
image.paste(Icon.default["indoor"], (6, 78 + self.info_y_offset))
|
||||
draw.text(
|
||||
(36, 78 - self.info_font_y_offset + self.info_y_offset),
|
||||
"Inomhus",
|
||||
font=Font.f20,
|
||||
fill=0,
|
||||
)
|
||||
|
||||
# Temp Logic
|
||||
indoor_temp = weather_data.get("indoor_temp", 20)
|
||||
if indoor_temp >= 24.0:
|
||||
indoor_temp_icon = Icon.default["temp_high"]
|
||||
elif indoor_temp <= 18.0:
|
||||
indoor_temp_icon = Icon.default["temp_low"]
|
||||
else:
|
||||
indoor_temp_icon = Icon.default["temp_mid"]
|
||||
|
||||
image.paste(indoor_temp_icon, (6, 116 + self.info_y_offset))
|
||||
|
||||
draw_temp_block(
|
||||
draw,
|
||||
(38, 93 + self.info_y_offset),
|
||||
[
|
||||
(str(weather_data.get("indoor_temp", "--")), 0, Font.f55),
|
||||
("°C", 0, Font.f25),
|
||||
],
|
||||
)
|
||||
# ----------
|
||||
image.paste(Icon.default["humidity"], (6, 160 + self.info_y_offset))
|
||||
draw.text(
|
||||
(36, 160 - self.info_font_y_offset + self.info_y_offset),
|
||||
f"{weather_data.get('indoor_humidity', '--')}%",
|
||||
font=Font.f20,
|
||||
fill=0,
|
||||
)
|
||||
# ----------
|
||||
image.paste(Icon.default["pressure"], (6, 190 + self.info_y_offset))
|
||||
draw.text(
|
||||
(36, 190 - self.info_font_y_offset + self.info_y_offset),
|
||||
f"{weather_data.get('pressure', '--')}hPa",
|
||||
font=Font.f20,
|
||||
fill=0,
|
||||
)
|
||||
|
||||
# Sun
|
||||
image.paste(Icon.default["sunrise"], (6, 220 + self.info_y_offset))
|
||||
draw.text(
|
||||
(36, 220 - self.info_font_y_offset + self.info_y_offset),
|
||||
weather_data.get("sunrise", "--"),
|
||||
font=Font.f20,
|
||||
fill=0,
|
||||
)
|
||||
# ----------
|
||||
image.paste(Icon.default["sunset"], (101, 220 + self.info_y_offset))
|
||||
draw.text(
|
||||
(131, 220 - self.info_font_y_offset + self.info_y_offset),
|
||||
weather_data.get("sunset", "--"),
|
||||
font=Font.f20,
|
||||
fill=0,
|
||||
)
|
||||
|
||||
# Moon
|
||||
moon_value = closest(
|
||||
[direction for direction, _ in Icon.moon.items()],
|
||||
weather_data.get("moon_illumination", 0.167) * 100,
|
||||
)
|
||||
moon_icon = Icon.moon[moon_value]
|
||||
|
||||
if not weather_data.get("moon_to_full", True):
|
||||
image.paste(moon_icon, (6, 250 + self.info_y_offset))
|
||||
else:
|
||||
image.paste(ImageOps.mirror(moon_icon), (6, 250 + self.info_y_offset))
|
||||
draw.text(
|
||||
(42, 250 + self.info_y_offset),
|
||||
weather_data.get("moon_phase", "----"),
|
||||
font=Font.f20,
|
||||
fill=0,
|
||||
)
|
||||
|
||||
# Right-side
|
||||
right_side_x = 204
|
||||
if weather_data.get("temperature", 10) >= 24.0:
|
||||
outdoor_temp_icon = Icon.default["temp_high"]
|
||||
elif weather_data.get("temperature", 10) < 0.0:
|
||||
outdoor_temp_icon = Icon.default["temp_low"]
|
||||
else:
|
||||
outdoor_temp_icon = Icon.default["temp_mid"]
|
||||
image.paste(outdoor_temp_icon, (right_side_x, 116 + self.info_y_offset))
|
||||
draw_temp_block(
|
||||
draw,
|
||||
(right_side_x + 30, 93 + self.info_y_offset),
|
||||
[
|
||||
(str(weather_data.get("temperature", "--")), 0, Font.f55),
|
||||
("°C", 0, Font.f25),
|
||||
],
|
||||
)
|
||||
# ----------
|
||||
image.paste(Icon.default["humidity"], (right_side_x, 160 + self.info_y_offset))
|
||||
draw.text(
|
||||
(right_side_x + 30, 160 - self.info_font_y_offset + self.info_y_offset),
|
||||
f"{weather_data.get('humidity', '--')}%",
|
||||
font=Font.f20,
|
||||
fill=0,
|
||||
)
|
||||
# ----------
|
||||
image.paste(Icon.default["dew"], (right_side_x + 96, 160 + self.info_y_offset))
|
||||
draw.text(
|
||||
(right_side_x + 126, 160 - self.info_font_y_offset + self.info_y_offset),
|
||||
f"{weather_data.get('dew', '--')}°C",
|
||||
font=Font.f20,
|
||||
fill=0,
|
||||
)
|
||||
|
||||
# Wind
|
||||
wind_offset = 20
|
||||
wind_deg = forecast.get("wind", {}).get("deg", 0)
|
||||
wind_speed = forecast.get("wind", {}).get("speed", "--")
|
||||
wind_gust = forecast.get("wind", {}).get("gust", "--")
|
||||
has_wind = bool(forecast.get("wind"))
|
||||
|
||||
wind_value = closest(
|
||||
[direction for direction, _ in Icon.wind.items()], wind_deg
|
||||
)
|
||||
|
||||
wind_direction, wind_icon = (
|
||||
Icon.wind[wind_value] if has_wind else ("--", Icon.wind[180][1])
|
||||
)
|
||||
|
||||
direction_width = int(Font.f14.getlength(wind_direction))
|
||||
wind_direction_text_x = (
|
||||
right_side_x + wind_offset + ((30 - direction_width) / 2)
|
||||
)
|
||||
|
||||
image.paste(wind_icon, (right_side_x + wind_offset, 192 + self.info_y_offset))
|
||||
image.paste(
|
||||
Icon.default["wind"],
|
||||
(right_side_x + 40 + wind_offset, 190 + self.info_y_offset),
|
||||
)
|
||||
image.paste(
|
||||
Icon.default["wind_gust"],
|
||||
(right_side_x + 40 + wind_offset, 210 + self.info_y_offset),
|
||||
)
|
||||
|
||||
draw.text(
|
||||
(wind_direction_text_x, 222 + self.info_y_offset),
|
||||
wind_direction,
|
||||
font=Font.f14,
|
||||
fill=0,
|
||||
)
|
||||
draw.text(
|
||||
(
|
||||
right_side_x + 70 + wind_offset,
|
||||
190 - self.info_font_y_offset + self.info_y_offset,
|
||||
),
|
||||
f"{wind_speed}{'m/s' if has_wind else ''}",
|
||||
font=Font.f20,
|
||||
fill=0,
|
||||
)
|
||||
draw.text(
|
||||
(
|
||||
right_side_x + 70 + wind_offset,
|
||||
210 - self.info_font_y_offset + self.info_y_offset,
|
||||
),
|
||||
f"{wind_gust}{'m/s' if has_wind else ''}",
|
||||
font=Font.f20,
|
||||
fill=0,
|
||||
)
|
||||
|
||||
# UV
|
||||
image.paste(Icon.default["uv"], (right_side_x + 60, 240 + self.info_y_offset))
|
||||
draw.text(
|
||||
(
|
||||
right_side_x + 60 + 30,
|
||||
240 - self.info_font_y_offset + self.info_y_offset,
|
||||
),
|
||||
str(weather_data.get("uv_index", "--")),
|
||||
font=Font.f20,
|
||||
fill=0,
|
||||
)
|
||||
|
||||
# Warnings
|
||||
if not network_status:
|
||||
image.paste(
|
||||
Icon.warning["no_wifi"],
|
||||
(WIDTH - Icon.warning["no_wifi"].width - 4, 80),
|
||||
)
|
||||
|
||||
if (
|
||||
forecast.get("wind", {}).get("speed", 0) >= 14.0
|
||||
or forecast.get("wind", {}).get("gust", 0) >= 16.0
|
||||
):
|
||||
image.paste(
|
||||
Icon.warning["wind"], (250 - Icon.warning["wind"].width - 4, 80)
|
||||
)
|
||||
|
||||
if weather_data.get("fire_index", 0) > 3:
|
||||
image.paste(
|
||||
Icon.warning["fire"], (WIDTH - Icon.warning["fire"].width - 4, 4)
|
||||
)
|
||||
|
||||
alerts = weather_data.get("weather_alerts")
|
||||
|
||||
if alerts and isinstance(alerts, list) and len(alerts) > 0:
|
||||
warning_code = alerts[0].get("type")
|
||||
|
||||
if warning_code in ["RED", "ORANGE", "YELLOW"]:
|
||||
image.paste(
|
||||
Icon.warning[warning_code],
|
||||
(WIDTH - Icon.warning[warning_code].width - 4, 80),
|
||||
)
|
||||
elif warning_code == "MESSAGE":
|
||||
if warning_code == "WATER_SHORTAGE":
|
||||
image.paste(
|
||||
Icon.warning["water_shortage"],
|
||||
(WIDTH - Icon.warning["water_shortage"].width - 4, 80),
|
||||
)
|
||||
elif warning_code == "HIGH_TEMPERATURES":
|
||||
image.paste(
|
||||
Icon.warning["temperature_high"],
|
||||
(WIDTH - Icon.warning["temperature_high"].width - 4, 80),
|
||||
)
|
||||
else:
|
||||
image.paste(
|
||||
Icon.warning["YELLOW"],
|
||||
(WIDTH - Icon.warning["YELLOW"].width - 4, 80),
|
||||
)
|
||||
|
||||
# Weather-icon
|
||||
image = image.convert("L")
|
||||
if (
|
||||
weather_data.get("sunrise", "06:00")
|
||||
< date[1]
|
||||
< weather_data.get("sunset", "20:00")
|
||||
):
|
||||
time_of_day = "day"
|
||||
else:
|
||||
time_of_day = "night"
|
||||
weather_icon = Icon.get_forecast(
|
||||
forecast.get("weather_symbol", 0),
|
||||
time_of_day,
|
||||
forecast.get("wind", {}).get("speed", 0),
|
||||
)
|
||||
weather_icon_y_offset = int(((120 - weather_icon.size[1]) / 2))
|
||||
image.paste(weather_icon, (250, weather_icon_y_offset), weather_icon)
|
||||
|
||||
return image
|
||||
@@ -0,0 +1,35 @@
|
||||
# environment/__init__.py
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
|
||||
from constants import HOME_LOCATION
|
||||
from tools.geometry import Coordinate
|
||||
|
||||
from .local_sensors import Sensors
|
||||
from .pysky import Moon, Sun
|
||||
from .smhi import SMHI
|
||||
from .wireless_sensor import WirelessSensor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Weather:
|
||||
def __init__(self, location: Coordinate = HOME_LOCATION) -> None:
|
||||
self.location = location
|
||||
self.session = requests.Session()
|
||||
self.smhi_api = SMHI(self.location, self.session)
|
||||
self.sensor_data = Sensors()
|
||||
self.wireless_sensor = WirelessSensor(self.session)
|
||||
|
||||
def get(self, date: datetime) -> dict:
|
||||
logger.debug("Collecting weather data from all sensors...")
|
||||
return (
|
||||
self.sensor_data.get()
|
||||
| self.wireless_sensor.get()
|
||||
| Moon.get_illumination(date)
|
||||
| Sun.get_sunset_sunrise(self.location, date)
|
||||
| self.smhi_api.get()
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
# environment/local_sensors.py
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import board
|
||||
from adafruit_bme280 import basic as adafruit_bme280
|
||||
except ImportError as e:
|
||||
logger.warning(
|
||||
f"BME280 dependencies not installed (Normal for test environments) | {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing Raspberry Pi specific dependencies | {e}")
|
||||
|
||||
|
||||
class Sensors:
|
||||
def __init__(self):
|
||||
self._bme280 = None
|
||||
self._data = {}
|
||||
try:
|
||||
if "adafruit_bme280" in globals():
|
||||
self._bme280 = adafruit_bme280.Adafruit_BME280_I2C(
|
||||
board.I2C(), address=0x76
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"BME280 hardware not found on I2C bus! | {e}")
|
||||
|
||||
def _get_data(self):
|
||||
if self._bme280 is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self._data = {
|
||||
"indoor_temp": round(self._bme280.temperature, 1),
|
||||
"indoor_humidity": round(self._bme280.relative_humidity, 1),
|
||||
"pressure": round(self._bme280.pressure, 1),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed getting indoor sensor data: {e}")
|
||||
|
||||
def get(self) -> dict:
|
||||
logger.debug("Measuring environment indoors...")
|
||||
self._get_data()
|
||||
return self._data
|
||||
@@ -0,0 +1,152 @@
|
||||
# environment/pysky.py
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from math import acos, asin, ceil, cos, degrees, fmod
|
||||
from math import pi as PI
|
||||
from math import radians, sin, sqrt
|
||||
|
||||
from constants import Coordinate
|
||||
from tools.julian import from_julian, julian_date
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Sun:
|
||||
|
||||
@staticmethod
|
||||
def _calc(location: Coordinate, date: datetime, elevation: float = 0.0) -> dict:
|
||||
J_date = julian_date(date)
|
||||
n = ceil(J_date - (2451545.0 + 0.0009) + 69.184 / 86400.0) - 1
|
||||
J_ = n + 0.0009 - location.long / 360.0
|
||||
M_degrees = fmod(357.5291 + 0.98560028 * J_, 360)
|
||||
M_radians = radians(M_degrees)
|
||||
C_degrees = (
|
||||
1.9148 * sin(M_radians)
|
||||
+ 0.02 * sin(2 * M_radians)
|
||||
+ 0.0003 * sin(3 * M_radians)
|
||||
)
|
||||
L_degrees = fmod(M_degrees + C_degrees + 180.0 + 102.9372, 360)
|
||||
Lambda_radians = radians(L_degrees)
|
||||
J_transit = (
|
||||
2451545.0 + J_ + 0.0053 * sin(M_radians) - 0.0069 * sin(2 * Lambda_radians)
|
||||
)
|
||||
sin_d = sin(Lambda_radians) * sin(radians(23.4397))
|
||||
cos_d = cos(asin(sin_d))
|
||||
some_cos = (
|
||||
sin(radians(-0.833 - 2.076 * sqrt(elevation) / 60.0))
|
||||
- sin(radians(location.lat)) * sin_d
|
||||
) / (cos(radians(location.lat)) * cos_d)
|
||||
|
||||
try:
|
||||
w0_radians = acos(some_cos)
|
||||
except ValueError:
|
||||
return {"sunrise": "--:--", "sunset": "--:--"}
|
||||
|
||||
w0_degrees = degrees(w0_radians)
|
||||
j_rise = J_transit - w0_degrees / 360
|
||||
j_set = J_transit + w0_degrees / 360
|
||||
|
||||
sunset_time = from_julian(j_set)
|
||||
sunrise_time = from_julian(j_rise)
|
||||
|
||||
return {
|
||||
"sunrise": f"{sunrise_time.hour:02d}:{sunrise_time.minute:02d}",
|
||||
"sunset": f"{sunset_time.hour:02d}:{sunset_time.minute:02d}",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_sunset_sunrise(location: Coordinate, date: datetime | None = None) -> dict:
|
||||
if date is None:
|
||||
date = datetime.now()
|
||||
logger.debug("Calculating Sun trajectory...")
|
||||
return Sun._calc(location, date)
|
||||
|
||||
|
||||
class Moon:
|
||||
|
||||
@staticmethod
|
||||
def _illumination_name(illumination: float, waxing: bool = True) -> str:
|
||||
if illumination > 0.996:
|
||||
return "Fullmåne"
|
||||
elif illumination > 0.57:
|
||||
return "Tilltagande halvmåne" if waxing else "Avtagande halvmåne"
|
||||
elif illumination > 0.43:
|
||||
return "Halvmåne (tilltagande)" if waxing else "Halvmåne (avtagande)"
|
||||
elif illumination > 0.02:
|
||||
return "Tilltagande skära" if waxing else "Avtagande skära"
|
||||
else:
|
||||
return "Nymåne"
|
||||
|
||||
@staticmethod
|
||||
def _constrain(d: float) -> float:
|
||||
t = d % 360
|
||||
if t < 0:
|
||||
t += 360
|
||||
return t
|
||||
|
||||
@staticmethod
|
||||
def _get_illuminated_fraction(jd: float) -> float:
|
||||
toRad = PI / 180.0
|
||||
T = (jd - 2451545) / 36525.0
|
||||
D = (
|
||||
Moon._constrain(
|
||||
297.8501921
|
||||
+ 445267.1114034 * T
|
||||
- 0.0018819 * T * T
|
||||
+ 1.0 / 545868.0 * T * T * T
|
||||
- 1.0 / 113065000.0 * T * T * T * T
|
||||
)
|
||||
* toRad
|
||||
)
|
||||
M = (
|
||||
Moon._constrain(
|
||||
357.5291092
|
||||
+ 35999.0502909 * T
|
||||
- 0.0001536 * T * T
|
||||
+ 1.0 / 24490000.0 * T * T * T
|
||||
)
|
||||
* toRad
|
||||
)
|
||||
Mp = (
|
||||
Moon._constrain(
|
||||
134.9633964
|
||||
+ 477198.8675055 * T
|
||||
+ 0.0087414 * T * T
|
||||
+ 1.0 / 69699.0 * T * T * T
|
||||
- 1.0 / 14712000.0 * T * T * T * T
|
||||
)
|
||||
* toRad
|
||||
)
|
||||
i = (
|
||||
Moon._constrain(
|
||||
180
|
||||
- D * 180 / PI
|
||||
- 6.289 * sin(Mp)
|
||||
+ 2.1 * sin(M)
|
||||
- 1.274 * sin(2 * D - Mp)
|
||||
- 0.658 * sin(2 * D)
|
||||
- 0.214 * sin(2 * Mp)
|
||||
- 0.11 * sin(D)
|
||||
)
|
||||
* toRad
|
||||
)
|
||||
return (1 + cos(i)) / 2
|
||||
|
||||
@staticmethod
|
||||
def get_illumination(date: datetime | None = None) -> dict:
|
||||
if date is None:
|
||||
date = datetime.now()
|
||||
logger.debug("Calculating Moon illumination...")
|
||||
|
||||
i = Moon._get_illuminated_fraction(julian_date(date))
|
||||
i_future = Moon._get_illuminated_fraction(
|
||||
julian_date(date + timedelta(seconds=1))
|
||||
)
|
||||
waxing = i_future > i
|
||||
|
||||
return {
|
||||
"moon_illumination": round(i, 4),
|
||||
"moon_phase": Moon._illumination_name(i, waxing),
|
||||
"waxing": waxing,
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
# environment/smhi.py
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
from tools.geometry import Coordinate, LineString, MultiPolygon, Polygon
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SMHI:
|
||||
|
||||
fire_warning_api = "https://opendata-download-metfcst.smhi.se/api/category/fwif1g/version/1/daily/geotype/point/lon/{lon}/lat/{lat}/data.json"
|
||||
forecast_api = "https://opendata-download-metfcst.smhi.se/api/category/pmp3g/version/2/geotype/point/lon/{lon}/lat/{lat}/data.json"
|
||||
alerts_api = (
|
||||
"https://opendata-download-warnings.smhi.se/ibww/api/version/1/warning.json"
|
||||
)
|
||||
|
||||
def __init__(self, location: Coordinate, session: requests.Session) -> None:
|
||||
self.location = location
|
||||
self._session = session
|
||||
self._data = {"fire_index": {}, "weather_alerts": {}, "forecast": {}}
|
||||
self._api = {
|
||||
"fire_index": self._get_fire_warning,
|
||||
"weather_alerts": self._get_alerts,
|
||||
"forecast": self._get_forecast,
|
||||
}
|
||||
|
||||
def _calculate_area(self, area_type: str, area_list: list) -> bool:
|
||||
"""Helper to check if our home coordinates fall within an SMHI warning zone."""
|
||||
point = self.location.to_Point(reversed=True)
|
||||
match area_type:
|
||||
case "Polygon":
|
||||
return Polygon(area_list[0]).contains(point)
|
||||
case "LineString":
|
||||
return LineString(area_list).distance_to(point) <= 0.045
|
||||
case "MultiPolygon":
|
||||
return MultiPolygon(area_list).contains(point)
|
||||
return False
|
||||
|
||||
def _get_fire_warning(self) -> bool:
|
||||
try:
|
||||
resp = self._session.get(
|
||||
self.fire_warning_api.format(
|
||||
lon=self.location.long, lat=self.location.lat
|
||||
),
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
for param in resp.json()["timeSeries"][0]["parameters"]:
|
||||
if param["name"] == "fwiindex":
|
||||
self._data["fire_index"] = {
|
||||
"data": param["values"][0],
|
||||
"valid_time": datetime.now(),
|
||||
}
|
||||
return True
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Network error getting SMHI Fire index: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Parsing error for SMHI Fire index: {e}")
|
||||
return False
|
||||
|
||||
def _get_alerts(self) -> bool:
|
||||
try:
|
||||
resp = self._session.get(self.alerts_api, timeout=10)
|
||||
resp.raise_for_status()
|
||||
|
||||
area_list = []
|
||||
for warning in resp.json():
|
||||
for areas in warning["warningAreas"]:
|
||||
is_in_area = False
|
||||
if areas["area"]["type"] == "FeatureCollection":
|
||||
for features in areas["area"]["features"]:
|
||||
if self._calculate_area(
|
||||
features["geometry"]["type"],
|
||||
features["geometry"]["coordinates"],
|
||||
):
|
||||
is_in_area = True
|
||||
break
|
||||
elif self._calculate_area(
|
||||
areas["area"]["geometry"]["type"],
|
||||
areas["area"]["geometry"]["coordinates"],
|
||||
):
|
||||
is_in_area = True
|
||||
|
||||
if is_in_area:
|
||||
area_list.append(
|
||||
{
|
||||
"areaName": areas["areaName"]["sv"],
|
||||
"type": areas["warningLevel"]["code"],
|
||||
"description": areas["eventDescription"]["code"],
|
||||
}
|
||||
)
|
||||
|
||||
self._data["weather_alerts"] = {
|
||||
"data": area_list,
|
||||
"valid_time": datetime.now(),
|
||||
}
|
||||
return True
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Network error getting SMHI Alerts: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Parsing error for SMHI Alerts: {e}")
|
||||
return False
|
||||
|
||||
def _get_forecast(self) -> bool:
|
||||
try:
|
||||
resp = self._session.get(
|
||||
self.forecast_api.format(lon=self.location.long, lat=self.location.lat),
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
data = resp.json()["timeSeries"][1]
|
||||
w_data = {x["name"]: x["values"][0] for x in data["parameters"]}
|
||||
|
||||
self._data["forecast"] = {
|
||||
"data": {
|
||||
"wind": {
|
||||
"deg": w_data["wd"],
|
||||
"speed": round(w_data["ws"], 1),
|
||||
"gust": round(w_data["gust"], 1),
|
||||
},
|
||||
"weather_symbol": w_data["Wsymb2"] - 1,
|
||||
},
|
||||
"valid_time": datetime.now(),
|
||||
}
|
||||
return True
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Network error getting SMHI Forecast: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Parsing error for SMHI Forecast: {e}")
|
||||
return False
|
||||
|
||||
def get(self) -> dict:
|
||||
"""Returns valid SMHI data, utilizing cached data if recently fetched."""
|
||||
valid_data = {}
|
||||
for api_name, fetch_func in self._api.items():
|
||||
cache = self._data[api_name]
|
||||
valid_time = cache.get("valid_time", datetime.fromtimestamp(0))
|
||||
age_seconds = (datetime.now() - valid_time).total_seconds()
|
||||
|
||||
if age_seconds <= 300:
|
||||
logger.debug(f'Using cached data for "{api_name}"')
|
||||
valid_data[api_name] = cache["data"]
|
||||
else:
|
||||
logger.debug(f'Fetching new data for "{api_name}"...')
|
||||
if fetch_func():
|
||||
logger.debug(f'Successfully fetched new data for "{api_name}"')
|
||||
valid_data[api_name] = self._data[api_name]["data"]
|
||||
elif age_seconds <= 600:
|
||||
logger.warning(
|
||||
f'Failed fetching "{api_name}", falling back to 10-min cache.'
|
||||
)
|
||||
valid_data[api_name] = cache["data"]
|
||||
else:
|
||||
logger.error(
|
||||
f'SMHI data "{api_name}" is too old and cannot be refreshed.'
|
||||
)
|
||||
|
||||
return valid_data
|
||||
@@ -0,0 +1,42 @@
|
||||
# environment/wireless_sensor.py
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
from constants import SENSOR_HTTP
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WirelessSensor:
|
||||
|
||||
def __init__(self, session: requests.Session) -> None:
|
||||
self._sensor_data = {}
|
||||
self._last_poll = None
|
||||
self._session = session
|
||||
|
||||
def _poll_sensor(self) -> None:
|
||||
try:
|
||||
resp = self._session.get(SENSOR_HTTP, timeout=6)
|
||||
resp.raise_for_status()
|
||||
|
||||
self._last_poll = datetime.now()
|
||||
self._sensor_data = resp.json()
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Network error connecting to wireless sensor: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing wireless sensor data: {e}")
|
||||
|
||||
def get(self) -> dict:
|
||||
logger.debug("Polling Wireless Sensor...")
|
||||
self._poll_sensor()
|
||||
|
||||
if self._last_poll:
|
||||
age_seconds = abs((datetime.now() - self._last_poll).total_seconds())
|
||||
if age_seconds < 600:
|
||||
return self._sensor_data
|
||||
else:
|
||||
logger.warning("Wireless sensor data is too old (over 10 mins).")
|
||||
|
||||
return {}
|
||||
@@ -0,0 +1,15 @@
|
||||
# fonts/__init__.py
|
||||
|
||||
from pathlib import Path
|
||||
from PIL import ImageFont
|
||||
|
||||
|
||||
class Font:
|
||||
Chakra = str(Path(__file__).with_name("chakra.ttf"))
|
||||
Arial = str(Path(__file__).with_name("arial.otf"))
|
||||
|
||||
f60 = ImageFont.truetype(Chakra, 60) # Time
|
||||
f55 = ImageFont.truetype(Chakra, 55) # Temp
|
||||
f25 = ImageFont.truetype(Chakra, 25) # Temp C
|
||||
f14 = ImageFont.truetype(Chakra, 14) # Wind
|
||||
f20 = ImageFont.truetype(Chakra, 20) # Date / Info
|
||||
@@ -0,0 +1,128 @@
|
||||
# icons/__init__.py
|
||||
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def _open_icon(*files_folder) -> Image.Image:
|
||||
"""Helper function to load images cleanly."""
|
||||
return Image.open(Path(__file__).parent.joinpath(*files_folder))
|
||||
|
||||
|
||||
class Icon:
|
||||
|
||||
wind = {
|
||||
0.0: ("N", _open_icon("wind", "N.bmp")),
|
||||
22.5: ("NNE", _open_icon("wind", "NNE.bmp")),
|
||||
45.0: ("NE", _open_icon("wind", "NE.bmp")),
|
||||
67.5: ("ENE", _open_icon("wind", "ENE.bmp")),
|
||||
90.0: ("E", _open_icon("wind", "E.bmp")),
|
||||
112.5: ("ESE", _open_icon("wind", "ESE.bmp")),
|
||||
135.0: ("SE", _open_icon("wind", "SE.bmp")),
|
||||
157.5: ("SSE", _open_icon("wind", "SSE.bmp")),
|
||||
180.0: ("S", _open_icon("wind", "S.bmp")),
|
||||
202.5: ("SSW", _open_icon("wind", "SSW.bmp")),
|
||||
225.0: ("SW", _open_icon("wind", "SW.bmp")),
|
||||
247.5: ("WSW", _open_icon("wind", "WSW.bmp")),
|
||||
270.0: ("W", _open_icon("wind", "W.bmp")),
|
||||
292.5: ("WNW", _open_icon("wind", "WNW.bmp")),
|
||||
315.0: ("NW", _open_icon("wind", "NW.bmp")),
|
||||
337.5: ("NNW", _open_icon("wind", "NNW.bmp")),
|
||||
}
|
||||
|
||||
moon = {
|
||||
0.0: _open_icon("moon", "1.bmp"),
|
||||
16.7: _open_icon("moon", "2.bmp"),
|
||||
33.3: _open_icon("moon", "3.bmp"),
|
||||
50.0: _open_icon("moon", "4.bmp"),
|
||||
66.7: _open_icon("moon", "5.bmp"),
|
||||
83.3: _open_icon("moon", "6.bmp"),
|
||||
100.0: _open_icon("moon", "7.bmp"),
|
||||
}
|
||||
|
||||
warning = {
|
||||
"fire": _open_icon("warnings", "fire.png"),
|
||||
"wind": _open_icon("warnings", "wind.bmp"),
|
||||
"water_shortage": _open_icon("warnings", "water_shortage.png"),
|
||||
"temperature_high": _open_icon("warnings", "temperature_high.png"),
|
||||
"no_wifi": _open_icon("warnings", "no_wifi.bmp"),
|
||||
"RED": _open_icon("warnings", "RED.png"),
|
||||
"YELLOW": _open_icon("warnings", "YELLOW.png"),
|
||||
"ORANGE": _open_icon("warnings", "ORANGE.png"),
|
||||
}
|
||||
|
||||
default = {
|
||||
"indoor": _open_icon("default", "indoor.bmp"),
|
||||
"temp_low": _open_icon("default", "temp_low.bmp"),
|
||||
"temp_mid": _open_icon("default", "temp_mid.bmp"),
|
||||
"temp_high": _open_icon("default", "temp_high.bmp"),
|
||||
"humidity": _open_icon("default", "humidity.bmp"),
|
||||
"pressure": _open_icon("default", "pressure.bmp"),
|
||||
"sunrise": _open_icon("default", "sunrise.bmp"),
|
||||
"sunset": _open_icon("default", "sunset.bmp"),
|
||||
"dew": _open_icon("default", "dew.bmp"),
|
||||
"wind": _open_icon("default", "wind.bmp"),
|
||||
"wind_gust": _open_icon("default", "wind_gust.bmp"),
|
||||
"uv": _open_icon("default", "uv.bmp"),
|
||||
}
|
||||
|
||||
_weather_id_map = {
|
||||
0: ("clear", "breezy"),
|
||||
1: ("partly_cloudy", "breezy"),
|
||||
2: ("partly_cloudy", "breezy"),
|
||||
3: ("partly_cloudy", "windy_mostly_cloudy"),
|
||||
4: ("mostly_cloudy", "windy_mostly_cloudy"),
|
||||
5: ("mostly_cloudy", "windy_mostly_cloudy"),
|
||||
6: ("fog", "fog"),
|
||||
7: ("scattered_showers", "scattered_showers"),
|
||||
8: ("scattered_showers", "scattered_showers"),
|
||||
9: ("heavy_rain", "heavy_rain"),
|
||||
10: ("mix_rainfall", "mix_rainfall"),
|
||||
11: ("sleet", "sleet"),
|
||||
12: ("sleet", "sleet"),
|
||||
13: ("sleet", "sleet"),
|
||||
14: ("snow", "breezy_snow"),
|
||||
15: ("snow", "breezy_snow"),
|
||||
16: ("blizzard", "blizzard"),
|
||||
17: ("drizzle", "drizzle"),
|
||||
18: ("rain", "rain"),
|
||||
19: ("heavy_rain", "heavy_rain"),
|
||||
20: ("scattered_thunderstorm", "scattered_thunderstorm"),
|
||||
21: ("sleet", "sleet"),
|
||||
22: ("sleet", "sleet"),
|
||||
23: ("sleet", "sleet"),
|
||||
24: ("snow", "breezy_snow"),
|
||||
25: ("snow", "breezy_snow"),
|
||||
26: ("blizzard", "blizzard"),
|
||||
}
|
||||
|
||||
_forecast_icons = {"day": {}, "night": {}}
|
||||
|
||||
for forecast_pic in Path(__file__).parent.joinpath("forecast", "day").glob("*.png"):
|
||||
_forecast_icons["day"][forecast_pic.stem] = _open_icon(
|
||||
"forecast", "day", forecast_pic.name
|
||||
)
|
||||
|
||||
for forecast_pic in (
|
||||
Path(__file__).parent.joinpath("forecast", "night").glob("*.png")
|
||||
):
|
||||
_forecast_icons["night"][forecast_pic.stem] = _open_icon(
|
||||
"forecast", "night", forecast_pic.name
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_forecast(
|
||||
cls,
|
||||
id: int,
|
||||
time_of_day: str,
|
||||
wind_strength: float,
|
||||
wind_speed_limit: float = 12.0,
|
||||
) -> Image.Image:
|
||||
"""Fetches the correct forecast icon instantly without looping."""
|
||||
icon_names = cls._weather_id_map.get(id, ("clear", "breezy"))
|
||||
|
||||
selected_icon_name = (
|
||||
icon_names[1] if wind_strength >= wind_speed_limit else icon_names[0]
|
||||
)
|
||||
|
||||
return cls._forecast_icons[time_of_day][selected_icon_name]
|
||||
|
After Width: | Height: | Size: 160 B |
|
After Width: | Height: | Size: 160 B |
|
After Width: | Height: | Size: 160 B |
|
After Width: | Height: | Size: 160 B |
|
After Width: | Height: | Size: 160 B |
|
After Width: | Height: | Size: 160 B |
|
After Width: | Height: | Size: 192 B |
|
After Width: | Height: | Size: 192 B |
|
After Width: | Height: | Size: 192 B |
|
After Width: | Height: | Size: 160 B |
|
After Width: | Height: | Size: 160 B |
|
After Width: | Height: | Size: 160 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 993 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 168 B |
|
After Width: | Height: | Size: 168 B |
|
After Width: | Height: | Size: 168 B |
|
After Width: | Height: | Size: 168 B |
|
After Width: | Height: | Size: 168 B |
|
After Width: | Height: | Size: 168 B |
|
After Width: | Height: | Size: 168 B |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 384 B |
|
After Width: | Height: | Size: 182 B |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 182 B |
|
After Width: | Height: | Size: 182 B |
|
After Width: | Height: | Size: 182 B |
|
After Width: | Height: | Size: 182 B |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 182 B |
|
After Width: | Height: | Size: 184 B |
|
After Width: | Height: | Size: 182 B |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 184 B |
|
After Width: | Height: | Size: 184 B |
|
After Width: | Height: | Size: 182 B |
|
After Width: | Height: | Size: 182 B |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,100 @@
|
||||
# main.py
|
||||
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import schedule
|
||||
from icmplib import ping as _ping
|
||||
|
||||
from tools.weather_logger import set_up_logger
|
||||
|
||||
set_up_logger()
|
||||
|
||||
from constants import MONTH_NAMES, WEEK_DAYS
|
||||
from display import Display
|
||||
from environment import Weather
|
||||
from tools.mqtt import MQTT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("Setting up components...")
|
||||
|
||||
weather = Weather()
|
||||
display = Display()
|
||||
mqtt = MQTT()
|
||||
|
||||
|
||||
def is_network_up(retries: int = 2, timeout: int = 2) -> bool:
|
||||
"""Checks if the internet is accessible by pinging Cloudflare."""
|
||||
try:
|
||||
host = _ping("1.1.1.1", count=retries, timeout=timeout, privileged=False)
|
||||
return host.is_alive
|
||||
except Exception as e:
|
||||
logger.warning(f"Ping failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def date_string(date: datetime) -> tuple[str, str]:
|
||||
"""Formats the date and time strings for the display."""
|
||||
date_line = (
|
||||
f"{WEEK_DAYS[date.weekday()]}, {date.day} {MONTH_NAMES[date.month]} {date.year}"
|
||||
)
|
||||
time_line = f"{date.hour:02d}:{date.minute:02d}"
|
||||
return date_line, time_line
|
||||
|
||||
|
||||
def update(first: bool = False) -> None:
|
||||
start_timestamp = time.time()
|
||||
logger.debug("Running scheduled update")
|
||||
date = datetime.now() if first else datetime.now() + timedelta(seconds=20)
|
||||
logger.debug(f"Target display time: {date}")
|
||||
|
||||
weather_data = weather.get(date)
|
||||
mqtt.send(weather_data)
|
||||
|
||||
str_date = date_string(date)
|
||||
|
||||
logger.debug("Constructing the Image...")
|
||||
image = display.image.make_image(str_date, weather_data, is_network_up(timeout=1))
|
||||
|
||||
logger.debug("Refreshing the E-ink display...")
|
||||
display.refresh(image, date, first)
|
||||
|
||||
logger.debug(
|
||||
f"Update completed in {round(time.time() - start_timestamp, 2)} seconds"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
logger.info("Checking network connection...")
|
||||
is_network_up()
|
||||
|
||||
try:
|
||||
logger.info("Starting weather station")
|
||||
update(first=True)
|
||||
schedule.every().minute.at(":40").do(update)
|
||||
logger.info("Schedule started")
|
||||
|
||||
while True:
|
||||
schedule.run_pending()
|
||||
time.sleep(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Manual exit requested (Ctrl+C). Shutting down...")
|
||||
except Exception as e:
|
||||
logger.error(f"Schedule failed - ERROR: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
finally:
|
||||
try:
|
||||
Display.exit()
|
||||
logger.info("E-ink display safely shut down.")
|
||||
except Exception as display_error:
|
||||
logger.error(
|
||||
f"Failed to shutdown the E-ink Display cleanly: {display_error}"
|
||||
)
|
||||
exit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,186 @@
|
||||
# tools/geometry.py
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Point:
|
||||
"""Represents a 2D point in a Cartesian coordinate system."""
|
||||
|
||||
x: float
|
||||
y: float
|
||||
|
||||
|
||||
class Line:
|
||||
"""Represents a finite line segment between two Points."""
|
||||
|
||||
def __init__(self, p1: Point, p2: Point) -> None:
|
||||
self.p1 = p1
|
||||
self.p2 = p2
|
||||
|
||||
def distance_to_point(self, point: Point) -> float:
|
||||
"""
|
||||
Calculates the shortest distance from this line segment to a given point.
|
||||
Uses vector projection to find the closest point on the segment.
|
||||
"""
|
||||
x1, y1 = self.p1.x, self.p1.y
|
||||
x2, y2 = self.p2.x, self.p2.y
|
||||
x3, y3 = point.x, point.y
|
||||
|
||||
# If the line is actually just a single point
|
||||
if x1 == x2 and y1 == y2:
|
||||
return ((x1 - x3) ** 2 + (y1 - y3) ** 2) ** 0.5
|
||||
|
||||
px, py = x2 - x1, y2 - y1
|
||||
norm = px * px + py * py
|
||||
|
||||
# Calculate the projection scalar (u) of the point onto the line
|
||||
u = ((x3 - x1) * px + (y3 - y1) * py) / float(norm)
|
||||
|
||||
# Clamp u to the [0, 1] range to ensure we stay on the line segment
|
||||
u = max(0.0, min(1.0, u))
|
||||
|
||||
# Find the exact coordinates of the closest point on the segment
|
||||
closest_x = x1 + u * px
|
||||
closest_y = y1 + u * py
|
||||
|
||||
# Return distance from the target point to the closest point
|
||||
dx, dy = closest_x - x3, closest_y - y3
|
||||
return (dx * dx + dy * dy) ** 0.5
|
||||
|
||||
|
||||
class LineString:
|
||||
"""Represents a path formed by a sequence of connected line segments."""
|
||||
|
||||
def __init__(self, lines: list[list[float]]) -> None:
|
||||
if len(lines) < 2:
|
||||
raise ValueError("A LineString requires at least 2 coordinate pairs.")
|
||||
|
||||
self.lineList: list[Line] = []
|
||||
for i in range(len(lines) - 1):
|
||||
p1 = Point(lines[i][0], lines[i][1])
|
||||
p2 = Point(lines[i + 1][0], lines[i + 1][1])
|
||||
self.lineList.append(Line(p1, p2))
|
||||
|
||||
def distance_to(self, point: Point) -> float:
|
||||
"""Calculates the minimum distance from the given point to the LineString."""
|
||||
# Cleanly check the distance to all segments and return the smallest one
|
||||
return min(line.distance_to_point(point) for line in self.lineList)
|
||||
|
||||
def is_intersecting(self, point: Point) -> bool:
|
||||
"""Checks if a given point lies exactly on the LineString."""
|
||||
return self.distance_to(point) == 0.0
|
||||
|
||||
|
||||
class Polygon:
|
||||
"""Represents a 2D shape enclosed by a series of connected points."""
|
||||
|
||||
def __init__(self, points: list[list[float]]) -> None:
|
||||
self.listPoints: list[Point] = [Point(p[0], p[1]) for p in points]
|
||||
|
||||
def on_line(self, l1: Line, p: Point) -> bool:
|
||||
"""Checks if collinear point 'p' lies strictly on the line segment 'l1'."""
|
||||
return min(l1.p1.x, l1.p2.x) <= p.x <= max(l1.p1.x, l1.p2.x) and min(
|
||||
l1.p1.y, l1.p2.y
|
||||
) <= p.y <= max(l1.p1.y, l1.p2.y)
|
||||
|
||||
def direction(self, a: Point, b: Point, c: Point) -> int:
|
||||
"""
|
||||
Finds the orientation of an ordered triplet (a, b, c).
|
||||
Returns:
|
||||
0 : Collinear
|
||||
1 : Clockwise
|
||||
2 : Counterclockwise
|
||||
"""
|
||||
val = (b.y - a.y) * (c.x - b.x) - (b.x - a.x) * (c.y - b.y)
|
||||
if val == 0:
|
||||
return 0
|
||||
return 2 if val < 0 else 1
|
||||
|
||||
def is_intersect(self, l1: Line, l2: Line) -> bool:
|
||||
"""Checks if line segment l1 intersects with line segment l2."""
|
||||
dir1 = self.direction(l1.p1, l1.p2, l2.p1)
|
||||
dir2 = self.direction(l1.p1, l1.p2, l2.p2)
|
||||
dir3 = self.direction(l2.p1, l2.p2, l1.p1)
|
||||
dir4 = self.direction(l2.p1, l2.p2, l1.p2)
|
||||
|
||||
# General case intersection
|
||||
if dir1 != dir2 and dir3 != dir4:
|
||||
return True
|
||||
|
||||
# Special collinear cases
|
||||
if dir1 == 0 and self.on_line(l1, l2.p1):
|
||||
return True
|
||||
if dir2 == 0 and self.on_line(l1, l2.p2):
|
||||
return True
|
||||
if dir3 == 0 and self.on_line(l2, l1.p1):
|
||||
return True
|
||||
if dir4 == 0 and self.on_line(l2, l1.p2):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def contains(self, p: Point) -> bool:
|
||||
"""
|
||||
Determines if a point is strictly inside the Polygon using the Ray-Casting algorithm.
|
||||
Draws a horizontal line to the right of the point and counts edge intersections.
|
||||
"""
|
||||
n = len(self.listPoints)
|
||||
if n < 3:
|
||||
return False
|
||||
|
||||
# Create a horizontal ray starting from the point and going infinitely right
|
||||
exline = Line(p, Point(99999.0, p.y))
|
||||
count = 0
|
||||
|
||||
for i in range(n):
|
||||
side = Line(self.listPoints[i], self.listPoints[(i + 1) % n])
|
||||
|
||||
if self.is_intersect(side, exline):
|
||||
# If the point is collinear with the side, check if it's strictly on the side
|
||||
if self.direction(side.p1, p, side.p2) == 0:
|
||||
return self.on_line(side, p)
|
||||
count += 1
|
||||
|
||||
# If the number of intersections is odd, the point is inside the polygon
|
||||
return bool(count & 1)
|
||||
|
||||
|
||||
class MultiPolygon:
|
||||
"""Represents a collection of multiple separate Polygons."""
|
||||
|
||||
def __init__(self, polygons: list) -> None:
|
||||
self.listPolygons: list[Polygon] = [Polygon(polygon[0]) for polygon in polygons]
|
||||
|
||||
def contains(self, p: Point) -> bool:
|
||||
"""Returns True if the point is inside ANY of the contained Polygons."""
|
||||
for polygon in self.listPolygons:
|
||||
if polygon.contains(p):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Coordinate:
|
||||
"""
|
||||
Custom type for the management of geographical coordinates.
|
||||
Provides utility to switch between lat/long and x/y point spaces.
|
||||
"""
|
||||
|
||||
lat: float
|
||||
long: float
|
||||
|
||||
@property
|
||||
def tuple(self) -> tuple[float, float]:
|
||||
"""Returns the coordinates as a (Latitude, Longitude) tuple."""
|
||||
return (self.lat, self.long)
|
||||
|
||||
def to_Point(self, reversed: bool = False) -> Point:
|
||||
"""
|
||||
Returns Coordinates mapped to a Cartesian Point.
|
||||
|
||||
Args:
|
||||
reversed (bool): If True, switches orientation so x=long, y=lat.
|
||||
Defaults to False (x=lat, y=long).
|
||||
"""
|
||||
return Point(self.long, self.lat) if reversed else Point(self.lat, self.long)
|
||||
@@ -0,0 +1,58 @@
|
||||
# tools/julian.py
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import math
|
||||
|
||||
|
||||
def julian_date(date: datetime | None = None):
|
||||
"""Given any date in the future or past, return julian date"""
|
||||
if date is None:
|
||||
date = datetime.now()
|
||||
|
||||
time = date.timestamp() * 1000
|
||||
|
||||
offset = date.utcoffset()
|
||||
tzoffset = offset.total_seconds() // 60 if offset is not None else 0
|
||||
|
||||
return (time / 86400000) - (tzoffset / 1440) + 2440587.5
|
||||
|
||||
|
||||
def CJDN(date: datetime | None = None):
|
||||
return round(julian_date(date))
|
||||
|
||||
|
||||
def current_julian_date():
|
||||
"""Returns current julian date"""
|
||||
return julian_date()
|
||||
|
||||
|
||||
def tomorrow_julian_date():
|
||||
"""returns tomorrow's julian date"""
|
||||
date = datetime.now() + timedelta(days=1)
|
||||
return julian_date(date)
|
||||
|
||||
|
||||
def future_julian_date(hoursadd=0, minutesadd=0, secondsadd=0):
|
||||
"""defaults to current if no specifications made. HOURS, MINUTES, AND SECONDS CANNOT BE NEGATIVE"""
|
||||
date = datetime.now() + timedelta(
|
||||
hours=hoursadd, minutes=minutesadd, seconds=secondsadd
|
||||
)
|
||||
return julian_date(date)
|
||||
|
||||
|
||||
def epoch_days(date: datetime | None = None):
|
||||
"""returns days since Jan 1st, 2000. Negative if before this date"""
|
||||
return julian_date(date) - 2451545
|
||||
|
||||
|
||||
def day_percent(date: datetime | None = None):
|
||||
"""Returns decimal portion of Julian Date"""
|
||||
whole = julian_date(date)
|
||||
return whole - math.floor(whole)
|
||||
|
||||
|
||||
def from_julian(j):
|
||||
"""Returns datetime.datetime object given julian date J"""
|
||||
J1970 = 2440588
|
||||
dayMs = 24 * 60 * 60 * 1000
|
||||
return datetime.fromtimestamp((j + 0.5 - J1970) * dayMs / 1000.0)
|
||||
@@ -0,0 +1,47 @@
|
||||
# tools/mqtt.py
|
||||
|
||||
import json
|
||||
import socket
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from paho.mqtt.publish import single as mqtt_publish
|
||||
|
||||
from constants import MQTT_USERNAME, MQTT_PASSWORD, MQTT_FALLBACK_IP
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MQTT:
|
||||
def __init__(self):
|
||||
self._username = MQTT_USERNAME
|
||||
self._password = MQTT_PASSWORD
|
||||
self.IP = self._resolve_ip()
|
||||
|
||||
def _resolve_ip(self) -> str:
|
||||
"""Attempts to resolve the Home Assistant IP, falls back to static on failure/timeout."""
|
||||
socket.setdefaulttimeout(2.0)
|
||||
try:
|
||||
return socket.gethostbyname("homeassistant.local")
|
||||
except (socket.gaierror, socket.timeout, OSError) as e:
|
||||
logger.warning(
|
||||
f"Could not resolve homeassistant.local ({e}). Falling back to static IP."
|
||||
)
|
||||
return MQTT_FALLBACK_IP
|
||||
finally:
|
||||
socket.setdefaulttimeout(None)
|
||||
|
||||
def send(self, data: dict[str, Any], topic: str = "weather_station") -> None:
|
||||
logger.debug("Sending MQTT payload to server...")
|
||||
try:
|
||||
mqtt_publish(
|
||||
topic,
|
||||
json.dumps(data),
|
||||
retain=True,
|
||||
hostname=self.IP,
|
||||
keepalive=120,
|
||||
client_id="weather-station",
|
||||
auth={"password": self._password, "username": self._username},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"MQTT data couldn't be sent: {e}")
|
||||
@@ -0,0 +1,44 @@
|
||||
# tools/weather_logger.py
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def set_up_logger():
|
||||
logger = logging.getLogger()
|
||||
|
||||
if logger.hasHandlers():
|
||||
return
|
||||
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logging.getLogger("PIL").setLevel(logging.WARNING)
|
||||
formatter = logging.Formatter(
|
||||
"{asctime} - {levelname:>7}: {message}", "%y-%m-%d %H:%M:%S", style="{"
|
||||
)
|
||||
log_dir = Path(__file__).parent.parent.joinpath("logs")
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
debugFileHandler = logging.handlers.RotatingFileHandler(
|
||||
filename=log_dir.joinpath("debug.log"),
|
||||
maxBytes=1_048_576,
|
||||
backupCount=5,
|
||||
)
|
||||
debugFileHandler.setFormatter(formatter)
|
||||
debugFileHandler.setLevel(logging.DEBUG)
|
||||
|
||||
infoFileHandler = logging.handlers.RotatingFileHandler(
|
||||
filename=log_dir.joinpath("info.log"),
|
||||
maxBytes=1_048_576,
|
||||
backupCount=3,
|
||||
)
|
||||
infoFileHandler.setFormatter(formatter)
|
||||
infoFileHandler.setLevel(logging.INFO)
|
||||
|
||||
console = logging.StreamHandler()
|
||||
console.setFormatter(formatter)
|
||||
console.setLevel(logging.INFO)
|
||||
|
||||
logger.addHandler(debugFileHandler)
|
||||
logger.addHandler(infoFileHandler)
|
||||
logger.addHandler(console)
|
||||