commit 87f585b6c0c7a5072512bd330684a5760639da19 Author: William Söderberg Date: Thu Mar 19 17:30:04 2026 +0100 Initial commit diff --git a/.env b/.env new file mode 100644 index 0000000..4407919 --- /dev/null +++ b/.env @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b086ce --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Weather Station + +Code for the indoor Weather Station diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..15d67ad --- /dev/null +++ b/constants.py @@ -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.", +} diff --git a/display/__init__.py b/display/__init__.py new file mode 100644 index 0000000..2c469a8 --- /dev/null +++ b/display/__init__.py @@ -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}") diff --git a/display/epd4in2.py b/display/epd4in2.py new file mode 100644 index 0000000..2ea8342 --- /dev/null +++ b/display/epd4in2.py @@ -0,0 +1,1213 @@ +# display/epd4in2.py + +from . import epdconfig + +GRAY1 = 0xFF # white +GRAY2 = 0xC0 +GRAY3 = 0x80 # gray +GRAY4 = 0x00 # Blackest + +# Display resolution +EPD_WIDTH = 400 +EPD_HEIGHT = 300 + + +class EPD: + def __init__(self): + self.reset_pin = epdconfig.RST_PIN + self.dc_pin = epdconfig.DC_PIN + self.busy_pin = epdconfig.BUSY_PIN + self.cs_pin = epdconfig.CS_PIN + self.width = EPD_WIDTH + self.height = EPD_HEIGHT + self.GRAY1 = GRAY1 # white + self.GRAY2 = GRAY2 + self.GRAY3 = GRAY3 # gray + self.GRAY4 = GRAY4 # Blackest + self.DATA = [0x00] * 15000 + + lut_vcom0 = [ + 0x00, + 0x08, + 0x08, + 0x00, + 0x00, + 0x02, + 0x00, + 0x0F, + 0x0F, + 0x00, + 0x00, + 0x01, + 0x00, + 0x08, + 0x08, + 0x00, + 0x00, + 0x02, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + lut_ww = [ + 0x50, + 0x08, + 0x08, + 0x00, + 0x00, + 0x02, + 0x90, + 0x0F, + 0x0F, + 0x00, + 0x00, + 0x01, + 0xA0, + 0x08, + 0x08, + 0x00, + 0x00, + 0x02, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + lut_bw = [ + 0x50, + 0x08, + 0x08, + 0x00, + 0x00, + 0x02, + 0x90, + 0x0F, + 0x0F, + 0x00, + 0x00, + 0x01, + 0xA0, + 0x08, + 0x08, + 0x00, + 0x00, + 0x02, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + lut_wb = [ + 0xA0, + 0x08, + 0x08, + 0x00, + 0x00, + 0x02, + 0x90, + 0x0F, + 0x0F, + 0x00, + 0x00, + 0x01, + 0x50, + 0x08, + 0x08, + 0x00, + 0x00, + 0x02, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + lut_bb = [ + 0x20, + 0x08, + 0x08, + 0x00, + 0x00, + 0x02, + 0x90, + 0x0F, + 0x0F, + 0x00, + 0x00, + 0x01, + 0x10, + 0x08, + 0x08, + 0x00, + 0x00, + 0x02, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + # ******************************partial screen update LUT*********************************/ + EPD_4IN2_Partial_lut_vcom1 = [ + 0x00, + 0x01, + 0x20, + 0x01, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + + EPD_4IN2_Partial_lut_ww1 = [ + 0x00, + 0x01, + 0x20, + 0x01, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + + EPD_4IN2_Partial_lut_bw1 = [ + 0x20, + 0x01, + 0x20, + 0x01, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + + EPD_4IN2_Partial_lut_wb1 = [ + 0x10, + 0x01, + 0x20, + 0x01, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + + EPD_4IN2_Partial_lut_bb1 = [ + 0x00, + 0x01, + 0x20, + 0x01, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + + # ******************************gray*********************************/ + # 0~3 gray + EPD_4IN2_4Gray_lut_vcom = [ + 0x00, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x01, + 0x60, + 0x14, + 0x14, + 0x00, + 0x00, + 0x01, + 0x00, + 0x14, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x13, + 0x0A, + 0x01, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + # R21 + EPD_4IN2_4Gray_lut_ww = [ + 0x40, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x01, + 0x90, + 0x14, + 0x14, + 0x00, + 0x00, + 0x01, + 0x10, + 0x14, + 0x0A, + 0x00, + 0x00, + 0x01, + 0xA0, + 0x13, + 0x01, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + # R22H r + EPD_4IN2_4Gray_lut_bw = [ + 0x40, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x01, + 0x90, + 0x14, + 0x14, + 0x00, + 0x00, + 0x01, + 0x00, + 0x14, + 0x0A, + 0x00, + 0x00, + 0x01, + 0x99, + 0x0C, + 0x01, + 0x03, + 0x04, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + # R23H w + EPD_4IN2_4Gray_lut_wb = [ + 0x40, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x01, + 0x90, + 0x14, + 0x14, + 0x00, + 0x00, + 0x01, + 0x00, + 0x14, + 0x0A, + 0x00, + 0x00, + 0x01, + 0x99, + 0x0B, + 0x04, + 0x04, + 0x01, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + # R24H b + EPD_4IN2_4Gray_lut_bb = [ + 0x80, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x01, + 0x90, + 0x14, + 0x14, + 0x00, + 0x00, + 0x01, + 0x20, + 0x14, + 0x0A, + 0x00, + 0x00, + 0x01, + 0x50, + 0x13, + 0x01, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] + + # Hardware reset + def reset(self): + epdconfig.digital_write(self.reset_pin, 1) + epdconfig.delay_ms(10) + epdconfig.digital_write(self.reset_pin, 0) + epdconfig.delay_ms(10) + epdconfig.digital_write(self.reset_pin, 1) + epdconfig.delay_ms(10) + epdconfig.digital_write(self.reset_pin, 0) + epdconfig.delay_ms(10) + epdconfig.digital_write(self.reset_pin, 1) + epdconfig.delay_ms(10) + epdconfig.digital_write(self.reset_pin, 0) + epdconfig.delay_ms(10) + epdconfig.digital_write(self.reset_pin, 1) + epdconfig.delay_ms(10) + + def send_command(self, command): + epdconfig.digital_write(self.dc_pin, 0) + epdconfig.digital_write(self.cs_pin, 0) + epdconfig.spi_writebyte([command]) + epdconfig.digital_write(self.cs_pin, 1) + + def send_data(self, data): + epdconfig.digital_write(self.dc_pin, 1) + epdconfig.digital_write(self.cs_pin, 0) + epdconfig.spi_writebyte([data]) + epdconfig.digital_write(self.cs_pin, 1) + + # send a lot of data + def send_data2(self, data): + epdconfig.digital_write(self.dc_pin, 1) + epdconfig.digital_write(self.cs_pin, 0) + epdconfig.spi_writebyte2(data) + epdconfig.digital_write(self.cs_pin, 1) + + def ReadBusy(self): + self.send_command(0x71) + while epdconfig.digital_read(self.busy_pin) == 0: # 0: idle, 1: busy + self.send_command(0x71) + epdconfig.delay_ms(100) + + def set_lut(self): + self.send_command(0x20) # vcom + self.send_data2(self.lut_vcom0) + + self.send_command(0x21) # ww -- + self.send_data2(self.lut_ww) + + self.send_command(0x22) # bw r + self.send_data2(self.lut_bw) + + self.send_command(0x23) # wb w + self.send_data2(self.lut_bb) + + self.send_command(0x24) # bb b + self.send_data2(self.lut_wb) + + def Partial_SetLut(self): + self.send_command(0x20) + self.send_data2(self.EPD_4IN2_Partial_lut_vcom1) + + self.send_command(0x21) + self.send_data2(self.EPD_4IN2_Partial_lut_ww1) + + self.send_command(0x22) + self.send_data2(self.EPD_4IN2_Partial_lut_bw1) + + self.send_command(0x23) + self.send_data2(self.EPD_4IN2_Partial_lut_wb1) + + self.send_command(0x24) + self.send_data2(self.EPD_4IN2_Partial_lut_bb1) + + def Gray_SetLut(self): + self.send_command(0x20) # vcom + self.send_data2(self.EPD_4IN2_4Gray_lut_vcom) + + self.send_command(0x21) # red not use + self.send_data2(self.EPD_4IN2_4Gray_lut_ww) + + self.send_command(0x22) # bw r + self.send_data2(self.EPD_4IN2_4Gray_lut_bw) + + self.send_command(0x23) # wb w + self.send_data2(self.EPD_4IN2_4Gray_lut_wb) + + self.send_command(0x24) # bb b + self.send_data2(self.EPD_4IN2_4Gray_lut_bb) + + self.send_command(0x25) # vcom + self.send_data2(self.EPD_4IN2_4Gray_lut_ww) + + def init(self): + if epdconfig.module_init() != 0: + return -1 + # EPD hardware init start + self.reset() + + self.send_command(0x01) # POWER SETTING + self.send_data(0x03) # VDS_EN, VDG_EN + self.send_data(0x00) # VCOM_HV, VGHL_LV[1], VGHL_LV[0] + self.send_data(0x2B) # VDH + self.send_data(0x2B) # VDL + + self.send_command(0x06) # boost soft start + self.send_data(0x17) + self.send_data(0x17) + self.send_data(0x17) + + self.send_command(0x04) # POWER_ON + self.ReadBusy() + + self.send_command(0x00) # panel setting + self.send_data(0xBF) # KW-BF KWR-AF BWROTP 0f + + self.send_command(0x30) # PLL setting + self.send_data(0x3C) # 3A 100HZ 29 150Hz 39 200HZ 31 171HZ + + self.send_command(0x61) # resolution setting + self.send_data(0x01) + self.send_data(0x90) # 128 + self.send_data(0x01) + self.send_data(0x2C) + + self.send_command(0x82) # vcom_DC setting + self.send_data(0x12) + + self.send_command(0x50) # VCOM AND DATA INTERVAL SETTING + self.send_data( + 0x97 + ) # 97white border 77black border VBDF 17|D7 VBDW 97 VBDB 57 VBDF F7 VBDW 77 VBDB 37 VBDR B7 + + self.set_lut() + # EPD hardware init end + return 0 + + def init_Partial(self): + if epdconfig.module_init() != 0: + return -1 + # EPD hardware init start + self.reset() + + self.send_command(0x01) # POWER SETTING + self.send_data(0x03) # VDS_EN, VDG_EN + self.send_data(0x00) # VCOM_HV, VGHL_LV[1], VGHL_LV[0] + self.send_data(0x2B) # VDH + self.send_data(0x2B) # VDL + + self.send_command(0x06) # boost soft start + self.send_data(0x17) + self.send_data(0x17) + self.send_data(0x17) + + self.send_command(0x04) # POWER_ON + self.ReadBusy() + + self.send_command(0x00) # panel setting + self.send_data(0xBF) # KW-BF KWR-AF BWROTP 0f + + self.send_command(0x30) # PLL setting + self.send_data(0x3C) # 3A 100HZ 29 150Hz 39 200HZ 31 171HZ + + self.send_command(0x61) # resolution setting + self.send_data(0x01) + self.send_data(0x90) # 128 + self.send_data(0x01) + self.send_data(0x2C) + + self.send_command(0x82) # vcom_DC setting + self.send_data(0x12) + + self.send_command(0x50) # VCOM AND DATA INTERVAL SETTING + self.send_data( + 0x07 + ) # 97white border 77black border VBDF 17|D7 VBDW 97 VBDB 57 VBDF F7 VBDW 77 VBDB 37 VBDR B7 + + self.Partial_SetLut() + # EPD hardware init end + return 0 + + def Init_4Gray(self): + if epdconfig.module_init() != 0: + return -1 + # EPD hardware init start + self.reset() + + self.send_command(0x01) # POWER SETTING + self.send_data(0x03) + self.send_data(0x00) # VGH=20V,VGL=-20V + self.send_data(0x2B) # VDH=15V + self.send_data(0x2B) # VDL=-15V + self.send_data(0x13) + + self.send_command(0x06) # booster soft start + self.send_data(0x17) # A + self.send_data(0x17) # B + self.send_data(0x17) # C + + self.send_command(0x04) + self.ReadBusy() + + self.send_command(0x00) # panel setting + self.send_data(0x3F) # KW-3f KWR-2F BWROTP 0f BWOTP 1f + + self.send_command(0x30) # PLL setting + self.send_data(0x3C) # 100hz + + self.send_command(0x61) # resolution setting + self.send_data(0x01) # 400 + self.send_data(0x90) + self.send_data(0x01) # 300 + self.send_data(0x2C) + + self.send_command(0x82) # vcom_DC setting + self.send_data(0x12) + + self.send_command(0x50) # VCOM AND DATA INTERVAL SETTING + self.send_data(0x97) + + def getbuffer(self, image): + # logger.debug("bufsiz = ",int(self.width/8) * self.height) + buf = [0xFF] * (int(self.width / 8) * self.height) + image_monocolor = image.convert("1") + imwidth, imheight = image_monocolor.size + pixels = image_monocolor.load() + # logger.debug("imwidth = %d, imheight = %d",imwidth,imheight) + if imwidth == self.width and imheight == self.height: + for y in range(imheight): + for x in range(imwidth): + # Set the bits for the column of pixels at the current position. + if pixels[x, y] == 0: + buf[int((x + y * self.width) / 8)] &= ~(0x80 >> (x % 8)) + elif imwidth == self.height and imheight == self.width: + for y in range(imheight): + for x in range(imwidth): + newx = y + newy = self.height - x - 1 + if pixels[x, y] == 0: + buf[int((newx + newy * self.width) / 8)] &= ~(0x80 >> (y % 8)) + return buf + + def getbuffer_4Gray(self, image): + # logger.debug("bufsiz = ",int(self.width/8) * self.height) + buf = [0xFF] * (int(self.width / 4) * self.height) + image_monocolor = image.convert("L") + imwidth, imheight = image_monocolor.size + pixels = image_monocolor.load() + i = 0 + # logger.debug("imwidth = %d, imheight = %d",imwidth,imheight) + if imwidth == self.width and imheight == self.height: + for y in range(imheight): + for x in range(imwidth): + # Set the bits for the column of pixels at the current position. + if pixels[x, y] == 0xC0: + pixels[x, y] = 0x80 + elif pixels[x, y] == 0x80: + pixels[x, y] = 0x40 + i = i + 1 + if i % 4 == 0: + buf[int((x + (y * self.width)) / 4)] = ( + (pixels[x - 3, y] & 0xC0) + | (pixels[x - 2, y] & 0xC0) >> 2 + | (pixels[x - 1, y] & 0xC0) >> 4 + | (pixels[x, y] & 0xC0) >> 6 + ) + + elif imwidth == self.height and imheight == self.width: + for x in range(imwidth): + for y in range(imheight): + newx = y + newy = x + if pixels[x, y] == 0xC0: + pixels[x, y] = 0x80 + elif pixels[x, y] == 0x80: + pixels[x, y] = 0x40 + i = i + 1 + if i % 4 == 0: + buf[int((newx + (newy * self.width)) / 4)] = ( + (pixels[x, y - 3] & 0xC0) + | (pixels[x, y - 2] & 0xC0) >> 2 + | (pixels[x, y - 1] & 0xC0) >> 4 + | (pixels[x, y] & 0xC0) >> 6 + ) + + return buf + + def display(self, image): + if self.width % 8 == 0: + linewidth = int(self.width / 8) + else: + linewidth = int(self.width / 8) + 1 + + self.send_command(0x92) + self.set_lut() + self.send_command(0x10) + self.send_data2([0xFF] * int(self.width * linewidth)) + + self.send_command(0x13) + self.send_data2(image) + + self.send_command(0x12) + self.ReadBusy() + + def EPD_4IN2_PartialDisplay(self, X_start, Y_start, X_end, Y_end, Image): + # EPD_WIDTH = 400 + # EPD_HEIGHT = 300 + + if EPD_WIDTH % 8 != 0: + Width = int(EPD_WIDTH / 8) + 1 + else: + Width = int(EPD_WIDTH / 8) + Height = EPD_HEIGHT + + if X_start % 8 != 0: + X_start = int(X_start / 8) + 1 + else: + X_start = int(X_start / 8) + if X_end % 8 != 0: + X_end = int(X_end / 8) + 1 + else: + X_end = int(X_end / 8) + + buf = [0x00] * (Y_end - Y_start) * (X_end - X_start) + + self.send_command(0x91) # This command makes the display enter partial mode + self.send_command(0x90) # resolution setting + self.send_data(int(X_start * 8 / 256)) + self.send_data(int(X_start * 8 % 256)) # x-start + + self.send_data(int(X_end * 8 / 256)) + self.send_data(int(X_end * 8 % 256) - 1) # x-end + + self.send_data(int(Y_start / 256)) + self.send_data(int(Y_start % 256)) # y-start + + self.send_data(int(Y_end / 256)) + self.send_data(int(Y_end % 256) - 1) # y-end + self.send_data(0x28) + + self.send_command(0x10) # writes Old data to SRAM for programming + for j in range(0, Y_end - Y_start): + for i in range(0, X_end - X_start): + buf[j * (X_end - X_start) + i] = self.DATA[ + (Y_start + j) * Width + X_start + i + ] + self.send_data2(buf) + + self.send_command(0x13) # writes New data to SRAM. + for j in range(0, Y_end - Y_start): + for i in range(0, X_end - X_start): + buf[j * (X_end - X_start) + i] = ~Image[ + (Y_start + j) * Width + X_start + i + ] + index123 = int((Y_start + j) * Width + X_start + i) + self.DATA[(Y_start + j) * Width + X_start + i] = ~Image[index123] + self.send_data2(buf) + + self.send_command(0x12) # DISPLAY REFRESH + epdconfig.delay_ms(200) # The delay here is necessary, 200uS at least!!! + self.ReadBusy() + + def display_4Gray(self, image): + self.send_command(0x92) + self.set_lut() + self.send_command(0x10) + + if self.width % 8 == 0: + linewidth = int(self.width / 8) + else: + linewidth = int(self.width / 8) + 1 + + buf = [0x00] * self.height * linewidth + + for i in range( + 0, int(EPD_WIDTH * EPD_HEIGHT / 8) + ): # EPD_WIDTH * EPD_HEIGHT / 4 + temp3 = 0 + for j in range(0, 2): + temp1 = image[i * 2 + j] + for k in range(0, 2): + temp2 = temp1 & 0xC0 + if temp2 == 0xC0: + temp3 |= 0x01 # white + elif temp2 == 0x00: + temp3 |= 0x00 # black + elif temp2 == 0x80: + temp3 |= 0x01 # gray1 + else: # 0x40 + temp3 |= 0x00 # gray2 + temp3 <<= 1 + + temp1 <<= 2 + temp2 = temp1 & 0xC0 + if temp2 == 0xC0: # white + temp3 |= 0x01 + elif temp2 == 0x00: # black + temp3 |= 0x00 + elif temp2 == 0x80: + temp3 |= 0x01 # gray1 + else: # 0x40 + temp3 |= 0x00 # gray2 + if j != 1 or k != 1: + temp3 <<= 1 + temp1 <<= 2 + buf[i] = temp3 + self.send_data2(buf) + + self.send_command(0x13) + + for i in range(0, int(EPD_WIDTH * EPD_HEIGHT / 8)): # 5808*4 46464 + temp3 = 0 + for j in range(0, 2): + temp1 = image[i * 2 + j] + for k in range(0, 2): + temp2 = temp1 & 0xC0 + if temp2 == 0xC0: + temp3 |= 0x01 # white + elif temp2 == 0x00: + temp3 |= 0x00 # black + elif temp2 == 0x80: + temp3 |= 0x00 # gray1 + else: # 0x40 + temp3 |= 0x01 # gray2 + temp3 <<= 1 + + temp1 <<= 2 + temp2 = temp1 & 0xC0 + if temp2 == 0xC0: # white + temp3 |= 0x01 + elif temp2 == 0x00: # black + temp3 |= 0x00 + elif temp2 == 0x80: + temp3 |= 0x00 # gray1 + else: # 0x40 + temp3 |= 0x01 # gray2 + if j != 1 or k != 1: + temp3 <<= 1 + temp1 <<= 2 + buf[i] = temp3 + self.send_data2(buf) + + self.Gray_SetLut() + self.send_command(0x12) + epdconfig.delay_ms(200) + self.ReadBusy() + # pass + + def Clear(self): + if self.width % 8 == 0: + linewidth = int(self.width / 8) + else: + linewidth = int(self.width / 8) + 1 + + self.send_command(0x10) + self.send_data2([0xFF] * int(self.height * linewidth)) + + self.send_command(0x13) + self.send_data2([0xFF] * int(self.height * linewidth)) + + self.send_command(0x12) + self.ReadBusy() + + def sleep(self): + self.send_command(0x02) # POWER_OFF + self.ReadBusy() + self.send_command(0x07) # DEEP_SLEEP + self.send_data(0xA5) + + epdconfig.delay_ms(2000) + epdconfig.module_exit() + + +### END OF FILE ### diff --git a/display/epdconfig.py b/display/epdconfig.py new file mode 100644 index 0000000..7292746 --- /dev/null +++ b/display/epdconfig.py @@ -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]) diff --git a/display/image_maker.py b/display/image_maker.py new file mode 100644 index 0000000..f83b92b --- /dev/null +++ b/display/image_maker.py @@ -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 diff --git a/environment/__init__.py b/environment/__init__.py new file mode 100644 index 0000000..4c919d0 --- /dev/null +++ b/environment/__init__.py @@ -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() + ) diff --git a/environment/local_sensors.py b/environment/local_sensors.py new file mode 100644 index 0000000..b3de71b --- /dev/null +++ b/environment/local_sensors.py @@ -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 diff --git a/environment/pysky.py b/environment/pysky.py new file mode 100644 index 0000000..6f9b6a1 --- /dev/null +++ b/environment/pysky.py @@ -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, + } diff --git a/environment/smhi.py b/environment/smhi.py new file mode 100644 index 0000000..d8433e3 --- /dev/null +++ b/environment/smhi.py @@ -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 diff --git a/environment/wireless_sensor.py b/environment/wireless_sensor.py new file mode 100644 index 0000000..4d147c6 --- /dev/null +++ b/environment/wireless_sensor.py @@ -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 {} diff --git a/fonts/__init__.py b/fonts/__init__.py new file mode 100644 index 0000000..5e7b38c --- /dev/null +++ b/fonts/__init__.py @@ -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 diff --git a/fonts/arial.otf b/fonts/arial.otf new file mode 100644 index 0000000..748e240 Binary files /dev/null and b/fonts/arial.otf differ diff --git a/fonts/chakra.ttf b/fonts/chakra.ttf new file mode 100644 index 0000000..20181b5 Binary files /dev/null and b/fonts/chakra.ttf differ diff --git a/icons/__init__.py b/icons/__init__.py new file mode 100644 index 0000000..d9a4f46 --- /dev/null +++ b/icons/__init__.py @@ -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] diff --git a/icons/default/dew.bmp b/icons/default/dew.bmp new file mode 100644 index 0000000..e3e3901 Binary files /dev/null and b/icons/default/dew.bmp differ diff --git a/icons/default/humidity.bmp b/icons/default/humidity.bmp new file mode 100644 index 0000000..bda633d Binary files /dev/null and b/icons/default/humidity.bmp differ diff --git a/icons/default/indoor.bmp b/icons/default/indoor.bmp new file mode 100644 index 0000000..16e2066 Binary files /dev/null and b/icons/default/indoor.bmp differ diff --git a/icons/default/pressure.bmp b/icons/default/pressure.bmp new file mode 100644 index 0000000..86f25b1 Binary files /dev/null and b/icons/default/pressure.bmp differ diff --git a/icons/default/sunrise.bmp b/icons/default/sunrise.bmp new file mode 100644 index 0000000..b7eeac2 Binary files /dev/null and b/icons/default/sunrise.bmp differ diff --git a/icons/default/sunset.bmp b/icons/default/sunset.bmp new file mode 100644 index 0000000..95dfde5 Binary files /dev/null and b/icons/default/sunset.bmp differ diff --git a/icons/default/temp_high.bmp b/icons/default/temp_high.bmp new file mode 100644 index 0000000..e5ca852 Binary files /dev/null and b/icons/default/temp_high.bmp differ diff --git a/icons/default/temp_low.bmp b/icons/default/temp_low.bmp new file mode 100644 index 0000000..595af74 Binary files /dev/null and b/icons/default/temp_low.bmp differ diff --git a/icons/default/temp_mid.bmp b/icons/default/temp_mid.bmp new file mode 100644 index 0000000..b5fb932 Binary files /dev/null and b/icons/default/temp_mid.bmp differ diff --git a/icons/default/uv.bmp b/icons/default/uv.bmp new file mode 100644 index 0000000..c294189 Binary files /dev/null and b/icons/default/uv.bmp differ diff --git a/icons/default/wind.bmp b/icons/default/wind.bmp new file mode 100644 index 0000000..6939e0d Binary files /dev/null and b/icons/default/wind.bmp differ diff --git a/icons/default/wind_gust.bmp b/icons/default/wind_gust.bmp new file mode 100644 index 0000000..edde830 Binary files /dev/null and b/icons/default/wind_gust.bmp differ diff --git a/icons/forecast/day/blizzard.png b/icons/forecast/day/blizzard.png new file mode 100644 index 0000000..b0fb7b5 Binary files /dev/null and b/icons/forecast/day/blizzard.png differ diff --git a/icons/forecast/day/breezy.png b/icons/forecast/day/breezy.png new file mode 100644 index 0000000..9fa9883 Binary files /dev/null and b/icons/forecast/day/breezy.png differ diff --git a/icons/forecast/day/breezy_snow.png b/icons/forecast/day/breezy_snow.png new file mode 100644 index 0000000..3720de2 Binary files /dev/null and b/icons/forecast/day/breezy_snow.png differ diff --git a/icons/forecast/day/clear.png b/icons/forecast/day/clear.png new file mode 100644 index 0000000..cbf8449 Binary files /dev/null and b/icons/forecast/day/clear.png differ diff --git a/icons/forecast/day/drizzle.png b/icons/forecast/day/drizzle.png new file mode 100644 index 0000000..e0f4668 Binary files /dev/null and b/icons/forecast/day/drizzle.png differ diff --git a/icons/forecast/day/fog.png b/icons/forecast/day/fog.png new file mode 100644 index 0000000..4a38813 Binary files /dev/null and b/icons/forecast/day/fog.png differ diff --git a/icons/forecast/day/heavy_rain.png b/icons/forecast/day/heavy_rain.png new file mode 100644 index 0000000..07fc505 Binary files /dev/null and b/icons/forecast/day/heavy_rain.png differ diff --git a/icons/forecast/day/mix_rainfall.png b/icons/forecast/day/mix_rainfall.png new file mode 100644 index 0000000..23946a2 Binary files /dev/null and b/icons/forecast/day/mix_rainfall.png differ diff --git a/icons/forecast/day/mostly_cloudy.png b/icons/forecast/day/mostly_cloudy.png new file mode 100644 index 0000000..c0b963f Binary files /dev/null and b/icons/forecast/day/mostly_cloudy.png differ diff --git a/icons/forecast/day/partly_cloudy.png b/icons/forecast/day/partly_cloudy.png new file mode 100644 index 0000000..c2dd46d Binary files /dev/null and b/icons/forecast/day/partly_cloudy.png differ diff --git a/icons/forecast/day/rain.png b/icons/forecast/day/rain.png new file mode 100644 index 0000000..39139f2 Binary files /dev/null and b/icons/forecast/day/rain.png differ diff --git a/icons/forecast/day/scattered_showers.png b/icons/forecast/day/scattered_showers.png new file mode 100644 index 0000000..d7a60c2 Binary files /dev/null and b/icons/forecast/day/scattered_showers.png differ diff --git a/icons/forecast/day/scattered_thunderstorm.png b/icons/forecast/day/scattered_thunderstorm.png new file mode 100644 index 0000000..755481c Binary files /dev/null and b/icons/forecast/day/scattered_thunderstorm.png differ diff --git a/icons/forecast/day/severe_thunderstorm.png b/icons/forecast/day/severe_thunderstorm.png new file mode 100644 index 0000000..cc66ed9 Binary files /dev/null and b/icons/forecast/day/severe_thunderstorm.png differ diff --git a/icons/forecast/day/sleet.png b/icons/forecast/day/sleet.png new file mode 100644 index 0000000..873e90e Binary files /dev/null and b/icons/forecast/day/sleet.png differ diff --git a/icons/forecast/day/snow.png b/icons/forecast/day/snow.png new file mode 100644 index 0000000..31759c4 Binary files /dev/null and b/icons/forecast/day/snow.png differ diff --git a/icons/forecast/day/windy_mostly_cloudy.png b/icons/forecast/day/windy_mostly_cloudy.png new file mode 100644 index 0000000..5caaed1 Binary files /dev/null and b/icons/forecast/day/windy_mostly_cloudy.png differ diff --git a/icons/forecast/night/blizzard.png b/icons/forecast/night/blizzard.png new file mode 100644 index 0000000..37923dd Binary files /dev/null and b/icons/forecast/night/blizzard.png differ diff --git a/icons/forecast/night/breezy.png b/icons/forecast/night/breezy.png new file mode 100644 index 0000000..9fa9883 Binary files /dev/null and b/icons/forecast/night/breezy.png differ diff --git a/icons/forecast/night/breezy_snow.png b/icons/forecast/night/breezy_snow.png new file mode 100644 index 0000000..3720de2 Binary files /dev/null and b/icons/forecast/night/breezy_snow.png differ diff --git a/icons/forecast/night/clear.png b/icons/forecast/night/clear.png new file mode 100644 index 0000000..735bcae Binary files /dev/null and b/icons/forecast/night/clear.png differ diff --git a/icons/forecast/night/drizzle.png b/icons/forecast/night/drizzle.png new file mode 100644 index 0000000..55b5e95 Binary files /dev/null and b/icons/forecast/night/drizzle.png differ diff --git a/icons/forecast/night/fog.png b/icons/forecast/night/fog.png new file mode 100644 index 0000000..0be9913 Binary files /dev/null and b/icons/forecast/night/fog.png differ diff --git a/icons/forecast/night/heavy_rain.png b/icons/forecast/night/heavy_rain.png new file mode 100644 index 0000000..6377d43 Binary files /dev/null and b/icons/forecast/night/heavy_rain.png differ diff --git a/icons/forecast/night/mix_rainfall.png b/icons/forecast/night/mix_rainfall.png new file mode 100644 index 0000000..b64bc10 Binary files /dev/null and b/icons/forecast/night/mix_rainfall.png differ diff --git a/icons/forecast/night/mostly_cloudy.png b/icons/forecast/night/mostly_cloudy.png new file mode 100644 index 0000000..0270da6 Binary files /dev/null and b/icons/forecast/night/mostly_cloudy.png differ diff --git a/icons/forecast/night/partly_cloudy.png b/icons/forecast/night/partly_cloudy.png new file mode 100644 index 0000000..6ff7209 Binary files /dev/null and b/icons/forecast/night/partly_cloudy.png differ diff --git a/icons/forecast/night/rain.png b/icons/forecast/night/rain.png new file mode 100644 index 0000000..4b78f1d Binary files /dev/null and b/icons/forecast/night/rain.png differ diff --git a/icons/forecast/night/scattered_showers.png b/icons/forecast/night/scattered_showers.png new file mode 100644 index 0000000..64da1b9 Binary files /dev/null and b/icons/forecast/night/scattered_showers.png differ diff --git a/icons/forecast/night/scattered_thunderstorm.png b/icons/forecast/night/scattered_thunderstorm.png new file mode 100644 index 0000000..771d1ab Binary files /dev/null and b/icons/forecast/night/scattered_thunderstorm.png differ diff --git a/icons/forecast/night/severe_thunderstorm.png b/icons/forecast/night/severe_thunderstorm.png new file mode 100644 index 0000000..403ae0e Binary files /dev/null and b/icons/forecast/night/severe_thunderstorm.png differ diff --git a/icons/forecast/night/sleet.png b/icons/forecast/night/sleet.png new file mode 100644 index 0000000..eaace76 Binary files /dev/null and b/icons/forecast/night/sleet.png differ diff --git a/icons/forecast/night/snow.png b/icons/forecast/night/snow.png new file mode 100644 index 0000000..4a6d6d8 Binary files /dev/null and b/icons/forecast/night/snow.png differ diff --git a/icons/forecast/night/windy_mostly_cloudy.png b/icons/forecast/night/windy_mostly_cloudy.png new file mode 100644 index 0000000..65d829d Binary files /dev/null and b/icons/forecast/night/windy_mostly_cloudy.png differ diff --git a/icons/moon/1.bmp b/icons/moon/1.bmp new file mode 100644 index 0000000..b0766d0 Binary files /dev/null and b/icons/moon/1.bmp differ diff --git a/icons/moon/2.bmp b/icons/moon/2.bmp new file mode 100644 index 0000000..cf46edd Binary files /dev/null and b/icons/moon/2.bmp differ diff --git a/icons/moon/3.bmp b/icons/moon/3.bmp new file mode 100644 index 0000000..2c80cd7 Binary files /dev/null and b/icons/moon/3.bmp differ diff --git a/icons/moon/4.bmp b/icons/moon/4.bmp new file mode 100644 index 0000000..6e37599 Binary files /dev/null and b/icons/moon/4.bmp differ diff --git a/icons/moon/5.bmp b/icons/moon/5.bmp new file mode 100644 index 0000000..e05e7e0 Binary files /dev/null and b/icons/moon/5.bmp differ diff --git a/icons/moon/6.bmp b/icons/moon/6.bmp new file mode 100644 index 0000000..aebdfdf Binary files /dev/null and b/icons/moon/6.bmp differ diff --git a/icons/moon/7.bmp b/icons/moon/7.bmp new file mode 100644 index 0000000..a168647 Binary files /dev/null and b/icons/moon/7.bmp differ diff --git a/icons/warnings/ORANGE.png b/icons/warnings/ORANGE.png new file mode 100644 index 0000000..af7bd10 Binary files /dev/null and b/icons/warnings/ORANGE.png differ diff --git a/icons/warnings/RED.png b/icons/warnings/RED.png new file mode 100644 index 0000000..7e1efcd Binary files /dev/null and b/icons/warnings/RED.png differ diff --git a/icons/warnings/YELLOW.png b/icons/warnings/YELLOW.png new file mode 100644 index 0000000..3fb97f2 Binary files /dev/null and b/icons/warnings/YELLOW.png differ diff --git a/icons/warnings/fire.png b/icons/warnings/fire.png new file mode 100644 index 0000000..d199a68 Binary files /dev/null and b/icons/warnings/fire.png differ diff --git a/icons/warnings/no_wifi.bmp b/icons/warnings/no_wifi.bmp new file mode 100644 index 0000000..5073745 Binary files /dev/null and b/icons/warnings/no_wifi.bmp differ diff --git a/icons/warnings/temperature_high.png b/icons/warnings/temperature_high.png new file mode 100644 index 0000000..21d9d5a Binary files /dev/null and b/icons/warnings/temperature_high.png differ diff --git a/icons/warnings/water_shortage.png b/icons/warnings/water_shortage.png new file mode 100644 index 0000000..cb5456a Binary files /dev/null and b/icons/warnings/water_shortage.png differ diff --git a/icons/warnings/wind.bmp b/icons/warnings/wind.bmp new file mode 100644 index 0000000..9dddfe8 Binary files /dev/null and b/icons/warnings/wind.bmp differ diff --git a/icons/wind/E.bmp b/icons/wind/E.bmp new file mode 100644 index 0000000..ac7b387 Binary files /dev/null and b/icons/wind/E.bmp differ diff --git a/icons/wind/ENE.bmp b/icons/wind/ENE.bmp new file mode 100644 index 0000000..df65335 Binary files /dev/null and b/icons/wind/ENE.bmp differ diff --git a/icons/wind/ESE.bmp b/icons/wind/ESE.bmp new file mode 100644 index 0000000..6dd1978 Binary files /dev/null and b/icons/wind/ESE.bmp differ diff --git a/icons/wind/N.bmp b/icons/wind/N.bmp new file mode 100644 index 0000000..1563521 Binary files /dev/null and b/icons/wind/N.bmp differ diff --git a/icons/wind/NE.bmp b/icons/wind/NE.bmp new file mode 100644 index 0000000..010e3a1 Binary files /dev/null and b/icons/wind/NE.bmp differ diff --git a/icons/wind/NNE.bmp b/icons/wind/NNE.bmp new file mode 100644 index 0000000..bf23875 Binary files /dev/null and b/icons/wind/NNE.bmp differ diff --git a/icons/wind/NNW.bmp b/icons/wind/NNW.bmp new file mode 100644 index 0000000..5bd7626 Binary files /dev/null and b/icons/wind/NNW.bmp differ diff --git a/icons/wind/NW.bmp b/icons/wind/NW.bmp new file mode 100644 index 0000000..741f2a7 Binary files /dev/null and b/icons/wind/NW.bmp differ diff --git a/icons/wind/S.bmp b/icons/wind/S.bmp new file mode 100644 index 0000000..78ba4b6 Binary files /dev/null and b/icons/wind/S.bmp differ diff --git a/icons/wind/SE.bmp b/icons/wind/SE.bmp new file mode 100644 index 0000000..1d4b913 Binary files /dev/null and b/icons/wind/SE.bmp differ diff --git a/icons/wind/SSE.bmp b/icons/wind/SSE.bmp new file mode 100644 index 0000000..9988565 Binary files /dev/null and b/icons/wind/SSE.bmp differ diff --git a/icons/wind/SSW.bmp b/icons/wind/SSW.bmp new file mode 100644 index 0000000..7e7802f Binary files /dev/null and b/icons/wind/SSW.bmp differ diff --git a/icons/wind/SW.bmp b/icons/wind/SW.bmp new file mode 100644 index 0000000..231f9a3 Binary files /dev/null and b/icons/wind/SW.bmp differ diff --git a/icons/wind/W.bmp b/icons/wind/W.bmp new file mode 100644 index 0000000..1d71590 Binary files /dev/null and b/icons/wind/W.bmp differ diff --git a/icons/wind/WNW.bmp b/icons/wind/WNW.bmp new file mode 100644 index 0000000..5406986 Binary files /dev/null and b/icons/wind/WNW.bmp differ diff --git a/icons/wind/WSW.bmp b/icons/wind/WSW.bmp new file mode 100644 index 0000000..bc5fb75 Binary files /dev/null and b/icons/wind/WSW.bmp differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..977a28e --- /dev/null +++ b/main.py @@ -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() diff --git a/tools/geometry.py b/tools/geometry.py new file mode 100644 index 0000000..5d2f8cc --- /dev/null +++ b/tools/geometry.py @@ -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) diff --git a/tools/julian.py b/tools/julian.py new file mode 100644 index 0000000..7f0d9be --- /dev/null +++ b/tools/julian.py @@ -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) diff --git a/tools/mqtt.py b/tools/mqtt.py new file mode 100644 index 0000000..82a5409 --- /dev/null +++ b/tools/mqtt.py @@ -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}") diff --git a/tools/weather_logger.py b/tools/weather_logger.py new file mode 100644 index 0000000..4e2205e --- /dev/null +++ b/tools/weather_logger.py @@ -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)