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)
|
||||||