Initial commit

This commit is contained in:
2026-03-19 17:30:04 +01:00 Verified
commit 87f585b6c0
98 changed files with 2746 additions and 0 deletions
+11
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
# Weather Station
Code for the indoor Weather Station
+46
View File
@@ -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.",
}
+49
View File
@@ -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}")
+1213
View File
File diff suppressed because it is too large Load Diff
+118
View File
@@ -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])
+290
View File
@@ -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
+35
View File
@@ -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()
)
+46
View File
@@ -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
+152
View File
@@ -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,
}
+163
View File
@@ -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
+42
View File
@@ -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 {}
+15
View File
@@ -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
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+128
View File
@@ -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]
Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 184 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 184 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

+100
View File
@@ -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()
+186
View File
@@ -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)
+58
View File
@@ -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)
+47
View File
@@ -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}")
+44
View File
@@ -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)