Netgate Discussion Forum
    • Categories
    • Recent
    • Tags
    • Popular
    • Users
    • Search
    • Register
    • Login

    UNOFFICIAL GUIDE: Squid external Raspberry PI WPAD lighttpd server Guide with Raspberry Zero LCD HAT code.

    Scheduled Pinned Locked Moved Cache/Proxy
    wpadproxysquidsquid-proxy
    18 Posts 1 Posters 1.8k Views
    Loading More Posts
    • Oldest to Newest
    • Newest to Oldest
    • Most Votes
    Reply
    • Reply as topic
    Log in to reply
    This topic has been deleted. Only users with topic management privileges can see it.
    • JonathanLeeJ
      JonathanLee
      last edited by JonathanLee

      Continued additional security for your WPAD:

      I have added an LCD hat to the Zero Pi that uses an adapted vendor provided example python code file. This is so that the screen will also show if any users are logged into the WPAD server. Again, this is very simple to get you into programing a display, it is simple we are adding a string for users logged in to display and adding a service.

      Here is a how to guide for this also now that your WPAD is configured and running let us add the LCD hat.

      Supplies:
      Zero LCD HAT (A)

      https://www.amazon.com/LCD-HAT-Secondary-Compatible-Zero/dp/B0DKF8L7VF

      You will have to add the GPIO standoff as it is not included in the Zero 2 W you have to manually add the GPIO header so the LCD hat can be plugged in first.

      Ok so you got it connected follow the guide for configuration of this.
      https://www.waveshare.com/wiki/Zero_LCD_HAT_(A)
      I have included the code here also with my adapted steps.

      #Enable SPI
      sudo raspi-config nonint do_spi 0
      #Install Python3 
      sudo apt-get update
      sudo apt-get install python3-pip
      sudo apt-get install python3-pil
      sudo apt-get install python3-numpy
      sudo apt install python3-luma.oled
      sudo apt install python3-luma.lcd
      sudo apt install python3-RPi.GPIO
      sudo apt install python3-spidev
      #Get the Demo Files
      sudo apt-get install unzip -y
      sudo wget https://files.waveshare.com/wiki/Zero-LCD-HAT-A/Zero_LCD_HAT_A_Demo.zip
      sudo unzip ./Zero_LCD_HAT_A_Demo.zip
      cd Zero_LCD_HAT_A_Demo
      
      you need to add "dtoverlay=spi1-1cs" to the config.txt file for opening the SPI device.
      
      sudo nano /boot/firmware/config.txt
      
      
      #adapt the demo file 
      sudo cp CPU.py CPU2.py
      sudo nano CPU2.py
      

      Adapt the CPU2.py file to activate the middle screen and add the users section.

      This is a simple addition do not reinvent the program just activate the new screen with the code given add the string for the output of "w" so that you can always see the current users logged in.

      #!/usr/bin/python
      # -*- coding: UTF-8 -*-
      #import chardet
      import os
      import sys
      import time
      import logging
      import spidev as SPI
      import subprocess
      sys.path.append("..")
      from lib import LCD_0inch96
      from lib import LCD_1inch3
      from lib import Gain_Param
      from PIL import Image,ImageDraw,ImageFont
      import RPi.GPIO as GPIO
      import re
      import math
      
      # Raspberry Pi pin configuration:
      RST_0 =24
      DC_0 = 4
      BL_0 = 13
      bus_0 = 0
      device_0 = 0
      
      RST_1 =23
      DC_1 = 5
      BL_1 = 12
      bus_1 = 0
      device_1 = 1
      
      RST = 27
      DC = 22
      BL = 19
      bus = 1
      device = 0
      
      logging.basicConfig(level=logging.DEBUG)
      
      try:
          disp_0 = LCD_0inch96.LCD_0inch96(spi=SPI.SpiDev(bus_0, device_0),spi_freq=10000000,rst=RST_0,dc=DC_0,bl=BL_0,bl_freq=1000)
          disp_1 = LCD_0inch96.LCD_0inch96(spi=SPI.SpiDev(bus_1, device_1),spi_freq=10000000,rst=RST_1,dc=DC_1,bl=BL_1,bl_freq=1000)
          disp = LCD_1inch3.LCD_1inch3(spi=SPI.SpiDev(bus, device),spi_freq=10000000,rst=RST,dc=DC,bl=BL)
      
          gain = Gain_Param.Gain_Param()
      
          disp.Init()
          disp_0.Init()
          disp_1.Init()
      
          disp.clear()
          disp_0.clear()
          disp_1.clear()
      
          disp.bl_DutyCycle(100)
          disp_0.bl_DutyCycle(100)
          disp_1.bl_DutyCycle(100)
      
          image1 = Image.new("RGB", (disp_0.width, disp_0.height), "WHITE")
          draw = ImageDraw.Draw(image1)
          Font2 = ImageFont.truetype("../Font/Font00.ttf",7)
          while True:
              #IP
              ip = gain.GET_IP()
              Font1 = ImageFont.truetype("../Font/Font00.ttf",15)
              draw.text((5, 0), 'IP : '+ip, fill = 0x3cbdc4,font=Font1)
      
              #time
              time_t = time.strftime("%H:%M:%S", time.localtime())
              time_D = time.strftime("%Y-%m-%d ", time.localtime())
              draw.text((5, 25), "Date: "+time_D, fill = 0x46D017,font=Font1)
              draw.text((5, 50), "Time: "+time_t, fill = 0xf7ba47,font=Font1)
              disp_0.ShowImage(image1)
              draw.rectangle((0,0,disp_0.width,disp_0.height),fill = "WHITE") #Cache area covered with white
      
              #CPU usage
              CPU_usage= os.popen('top -bi -n 2 -d 0.02').read().split('\n\n\n')[0].split('\n')[2]
              CPU_usage= re.sub('[a-zA-z%(): ]','',CPU_usage)
              CPU_usage= CPU_usage.split(',')
              CPU_usagex =100 - eval(CPU_usage[3])
              draw.text((5, 0), "CPU Usage: " + str(math.floor(CPU_usagex))+'%', fill = 0x0b46e3,font=Font1,)
      
              #Message
              user = subprocess.run(['w'], stdout=subprocess.PIPE).stdout.decode('utf-8')
              image3 = Image.new("RGB", (disp.width, disp.height), "WHITE")
              draw1 = ImageDraw.Draw(image3)
              draw1.text((5, 0), "WPAD SERVER: \n\n"+user, fill = 0x3cbdc4, font=Font2)
              disp.ShowImage(image3)
              draw1.rectangle((0,0,disp.width, disp.height), "WHITE")
      
              #TEMP
              temp_t = gain.GET_Temp()
              draw.text((5, 25), "Temp: "+str(math.floor(temp_t))+'℃', fill = 0x0088ff,font=Font1)
      
              #System disk usage
              x = os.popen('df -h /')
              i2 = 0
              while 1:
                  i2 = i2 + 1
                  line = x.readline()
                  if i2==2:
                      Capacity_usage = line.split()[4] # Memory usage (%)
                      Hard_capacity = int(re.sub('[%]','',Capacity_usage))
                      break
      
              draw.text((5, 50), "Disk Usage: "+str(math.floor(Hard_capacity))+'%', fill = 0x986DFC,font=Font1) # BGR
      
              disp_1.ShowImage(image1)
              draw.rectangle((0,0,disp_0.width,disp_0.height),fill = "WHITE")
              time.sleep(0.01)
      
      
          disp_0.module_exit()
          disp_1.module_exit()
          logging.info("quit:")
      except IOError as e:
          logging.info(e)
      except KeyboardInterrupt:
          disp_0.module_exit()
          disp_1.module_exit()
          logging.info("quit:")
          exit()
      
      

      save your adapted CPU2.py example file.

      Test with

      sudo python CPU2.py
      

      It should run on and run, if not check connections.

      Now that it is working, we need to create a service so that it will run every time you reboot or turn on your Raspberry pi.
      You might have to add sudo chmod to the .py file to add +x to it to make it executable.

      #Navigate to:
      cd /lib/systemd/system/
      #create a new service
      sudo nano display.service
      #add the following into the service file. Make sure your working directory is where the python code is.
      
      [Unit]
      Description=Display
      After=network.target
      
      [Service]
      User=root
      WorkingDirectory=/usr/Zero_LCD_HAT_A_Demo/python/example/
      ExecStart=/usr/bin/python3 /usr/Zero_LCD_HAT_A_Demo/python/example/CPU2.py
      Restart=always
      RestartSec=3
      StandardOutput=file:/var/log/example_service_output.log
      StandardError=file:/var/log/example_service_error.log
      
      [Install]
      WantedBy=multi-user.target
      
      #save this ctl x
      
      #Run the following 
      sudo systemctl daemon-reload
      sudo systemctl enable display.service
      sudo systemctl start display.service
      #check for errors
      sudo systemctl status display.service
      #It should display this if it works:
      ● display.service - Display
           Loaded: loaded (/lib/systemd/system/display.service; enabled; preset: enabled)
           Active: active (running) since Wed 2024-12-18 06:45:23 PST; 6h ago
         Main PID: 507 (python3)
            Tasks: 6 (limit: 178)
              CPU: 2h 18min 29.878s
           CGroup: /system.slice/display.service
                   └─507 /usr/bin/python3 /usr/Zero_LCD_HAT_A_Demo/python/example/CPU2.py
      
      Dec 18 06:45:23 Zero systemd[1]: Started display.service - Display.
      
      #Once it says enabled run
      sudo reboot
      #the dispaly should always run now.
      
      

      That is it you should now know who is logged into the WPAD at all times, if your not in a SSH session into it the screen show should no users. Meaning that it will only change when someone is on a terminal session of your pi. The screen file is very basic but it can help push you to customize it yourself.

      1 Reply Last reply Reply Quote 0
      • JonathanLeeJ
        JonathanLee
        last edited by JonathanLee

        Keep in mind you will have to install an SSL certificate on the Raspberry PI to download the GIT hub file and program. You have to copy the SSL file over to the pi by way of a USB drive and reconfigure your ca certificates for that, or just connect the pi to a non-proxied system to get your files after set it back. If you are like me you just add the certificate so you can access the proxy.

        To add the firewalls certificate used with the proxy first download it directly from the firewall so you know it is yours.
        after

        sudo mkdir /media/usb
        sudo mount /dev/sda2 /media/usb -o umask=000
        cd /media/usb 
        #find your file from the flash drive lets call it CA-Cert.crt
        #copy it to the locations is it needed at
        sudo cp CA-Cert.crt /etc/ssl/certs
        sudo cp CA-Cert.crt /usr/share/ca-certificates/
        sudo cp CA-Cert.crt /usr/local/share/ca-certificates/
        sudo update-ca-certificates
        

        This should fix your certificate issue to use wget while in the proxy.

        Final step we need to make sure that once inside the proxy that you can't call the wpad, meaning that the WPAD is a one-way ticket that the proxy or it's cache cannot access it for added security.

        Add this to custom options on the Squid configuration, it is not really needed I add this for fear of container issues,

        acl wpad urlpath_regex ^/wpad.dat$
        acl wpad urlpath_regex ^/proxy.pac$
        acl wpad urlpath_regex ^/wpad.da$
        #deny_info 200:/etc/squid/wpad.dat wpad # this is if you manage wpad from squid not needed here
        reply_header_access Content-Type deny wpad
        
        1 Reply Last reply Reply Quote 0
        • JonathanLeeJ
          JonathanLee
          last edited by

          Keep in mind that you also might want to disable bluetooth services as they are not needed, and or disable the bluetooth stack so that if a bluetooth usb is plugged into the port it won't work also.

          1 Reply Last reply Reply Quote 0
          • JonathanLeeJ
            JonathanLee
            last edited by

            Update: Set your SSH on the wpad to only allow access during business hours. This can be done with the PAM

            edit the following file

            /etc/security/time.conf
            

            add:

            sshd;*;*;AL0500-2300
            

            Meaning I can only access ssh into my wpad durring 5-2300

            After adapt /etc/ssh/sshd_config

            make sure your listenaddress is the ip of the wpad set your AllowUsers to your login

            Example

            Port 8085 #change port if needed
            AddressFamily inet #ipv4 only
            ListenAddress 192.168.1.6 #address of wpad
            AllowUsers Jonathan@192.168.1.* # any device that is 192.168.1.X
            

            Change

            PermitRootLogin no #no ssh login for root
            UsePam yes # turn on pam for use with time restrictions
            

            after adapt
            /etc/passwd

            for added security also change your login to use the shell rbash and lock down the wad.

            Also if you use ipv6 and ipv4 you will have a race condition and sshd will not start on reboots you must also adapt

            sudo -i
            systemctl edit --full sshd.service
            

            under [unit] add

            Requires=network-only.target
            After=network-only.taget
            

            This will only start sshd once the network target is running in my example 192.168.1.6 I also have ipv6 running so it would cause issues unless I changed this. If you do not use ipv4 forget about this.

            1 Reply Last reply Reply Quote 0
            • JonathanLeeJ JonathanLee referenced this topic on
            • JonathanLeeJ
              JonathanLee
              last edited by

              This post is deleted!
              1 Reply Last reply Reply Quote 0
              • JonathanLeeJ
                JonathanLee
                last edited by

                This post is deleted!
                1 Reply Last reply Reply Quote 0
                • JonathanLeeJ
                  JonathanLee
                  last edited by

                  This post is deleted!
                  1 Reply Last reply Reply Quote 0
                  • JonathanLeeJ
                    JonathanLee
                    last edited by

                    ok here is the updated code man I got to tell you ChatGPT helped me with 90% of this. I am amazed. Of course it took a lot of trial and errors with testing and implementation of the code along side the level of modularization involved. This version has the accurate moon too. Keep in mind this still runs the wpad it is just less boring

                    import os
                    import sys
                    import time
                    import psutil
                    import subprocess
                    import threading
                    import gpiozero  # Import once at the top
                    from PIL import Image, ImageDraw, ImageFont
                    import spidev as SPI
                    import requests
                    from io import BytesIO  # <-- Needed for icon image loading
                    
                    # Add parent directory to path to locate 'lib'
                    sys.path.append("..")
                    from lib import LCD_0inch96, LCD_1inch3, Gain_Param
                    
                    # Raspberry Pi pin config
                    RST_0 = 24
                    DC_0 = 4
                    BL_0 = 13
                    BUS_0 = 0
                    DEVICE_0 = 0
                    
                    RST_1 = 23
                    DC_1 = 5
                    BL_1 = 12
                    BUS_1 = 0
                    DEVICE_1 = 1
                    
                    RST = 27
                    DC = 22
                    BL = 19
                    BUS = 1
                    DEVICE = 0
                    state_lock = threading.Lock()
                    debounce_timer = None
                    DEBOUNCE_DELAY = 0.3  # seconds debounce delay to batch rapid presses
                    
                    # Proxy setup (if needed, else remove or set to None)
                    proxies = {
                        "http": "http://192.168.1.1:3128",
                        "https": "http://192.168.1.1:3128",
                    }
                    
                    # OpenWeatherMap API details
                    API_KEY = "get your own api key"
                    ZIP_CODE = "zip code needed"
                    UNITS = "imperial"
                    
                    # Weather cache to reduce API calls
                    weather_cache = {
                        "data": None,
                        "last_updated": 0,  # Unix timestamp
                        "icon_code": None,
                        "icon_image": None
                    }
                    
                    # Doppler radar cache
                    doppler_cache = {
                        "image": None,
                        "last_updated": 0
                    }
                    doppler_frame_index = 0  # Track which frame to show
                    
                    # Frame caches and timers
                    doppler_frames = []
                    last_doppler_fetch = 0
                    DOPPLER_URL = "https://radar.weather.gov/ridge/standard/KMUX_loop.gif"
                    # GOES-18 product definitions
                    GOES_PRODUCTS = [
                        {
                            "name": "GeoColor",
                            "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/GEOCOLOR/GOES18-PSW-GEOCOLOR-600x600.gif"
                        },
                        {
                            "name": "Air Mass RGB",
                            "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/AirMass/GOES18-PSW-AirMass-600x600.gif"
                        },
                        {
                            "name": "Sandwich RGB",
                            "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/Sandwich/GOES18-PSW-Sandwich-600x600.gif"
                        },
                        {
                            "name": "Day/Night Cloud",
                            "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/DayNightCloudMicroCombo/GOES18-PSW-DayNightCloudMicroCombo-600x600.gif"
                        },
                        {
                            "name": "Fire Temperature",
                            "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/FireTemperature/GOES18-PSW-FireTemperature-600x600.gif"
                        },
                        {
                            "name": "Band 02 - Red Visible",
                            "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/02/GOES18-PSW-02-600x600.gif"
                        },
                        {
                            "name": "Band 07 - Shortwave IR",
                            "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/07/GOES18-PSW-07-600x600.gif"
                        },
                        {
                            "name": "Band 08 - Water Vapor",
                            "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/08/GOES18-PSW-08-600x600.gif"
                        },
                        {
                            "name": "Band 13 - Clean IR",
                            "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/13/GOES18-PSW-13-600x600.gif"
                        },
                        {
                            "name": "Band 14 - IR Window",
                            "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/14/GOES18-PSW-14-600x600.gif"
                        },
                        {
                            "name": "SOHO EIT 171",
                            "url": "https://soho.nascom.nasa.gov/data/LATEST/current_eit_171.gif"
                        },
                        {
                            "name": "SOHO EIT 304",
                            "url": "https://soho.nascom.nasa.gov/data/LATEST/current_eit_304.gif"
                        }
                    ]
                    # GOES-18 GIF cache containers (one per product)
                    goes_dynamic_cache = [{} for _ in GOES_PRODUCTS]
                    goes_dynamic_frame_index = [0 for _ in GOES_PRODUCTS]
                    
                    FRAME_DELAYS = {
                        "default": 1,
                        "animation": 0.033
                    }
                    
                    
                    # Get IPv6 address
                    def get_ipv6_address():
                        try:
                            output = subprocess.check_output("ip -6 addr", shell=True).decode()
                            for line in output.splitlines():
                                line = line.strip()
                                if line.startswith("inet6") and "scope global" in line:
                                    return line.split()[1].split("/")[0]
                        except Exception:
                            pass
                        return "No IPv6"
                    
                    # Fetch detailed weather info (dict with multiple fields)
                    def get_detailed_weather():
                        now = time.time()
                        if weather_cache["data"] and (now - weather_cache["last_updated"] < 300):
                            return weather_cache["data"]
                        try:
                            response = requests.get(
                                f"http://api.openweathermap.org/data/2.5/weather?zip={ZIP_CODE},us&appid={API_KEY}&units={UNITS}",
                                timeout=5,
                                proxies=proxies
                            )
                            if response.status_code == 200:
                                data = response.json()
                                weather_cache["data"] = data
                                weather_cache["last_updated"] = now
                    
                                # Download and cache icon if changed
                                icon_code = data["weather"][0]["icon"]
                                if icon_code != weather_cache["icon_code"]:
                                    icon_url = f"https://openweathermap.org/img/wn/{icon_code}@2x.png"
                                    try:
                                        icon_response = requests.get(icon_url, timeout=5, proxies=proxies)
                                        if icon_response.status_code == 200:
                                            icon_image = Image.open(BytesIO(icon_response.content)).convert("RGBA")
                                            weather_cache["icon_image"] = icon_image
                                            weather_cache["icon_code"] = icon_code
                                        else:
                                            weather_cache["icon_image"] = None
                                            weather_cache["icon_code"] = None
                                    except Exception:
                                        weather_cache["icon_image"] = None
                                        weather_cache["icon_code"] = None
                    
                                return data
                            else:
                                return None
                        except Exception:
                            return None
                    
                    # Fetch Doppler radar image, cached for 5 minutes
                    def get_doppler_image():
                        now = time.time()
                        if doppler_cache["image"] and (now - doppler_cache["last_updated"] < 300):
                            return doppler_cache["image"]
                    
                        try:
                            response = requests.get(DOPPLER_URL, timeout=5, proxies=proxies)
                            if response.status_code == 200:
                                gif = Image.open(BytesIO(response.content))
                                frames = []
                                try:
                                    while True:
                                        # Resize here once on load
                                        frame = gif.copy().convert("RGBA").resize((disp.width, disp.height), Image.ANTIALIAS)
                                        frames.append(frame)
                                        gif.seek(gif.tell() + 1)
                                except EOFError:
                                    pass  # End of frames
                    
                                doppler_cache["image"] = frames
                                doppler_cache["last_updated"] = now
                                return frames
                        except Exception:
                            pass
                        return None
                    
                    # --- Moon phase logic ---
                    
                    from datetime import datetime
                    import math
                    
                    def get_moon_phase():
                        # Known new moon reference date (UTC)
                        # Jan 6, 2000 at 18:14 UTC is a commonly used epoch for moon phase calc
                        known_new_moon = datetime(2000, 1, 6, 18, 14)
                    
                        now = datetime.utcnow()
                        diff = now - known_new_moon
                        days_since_new = diff.total_seconds() / 86400.0  # days elapsed
                    
                        lunar_cycle = 29.53058867  # length of lunar cycle in days
                        age = days_since_new % lunar_cycle
                    
                        # Approximate illumination calculation
                        # phase as fraction of cycle (0 to 1)
                        phase = age / lunar_cycle
                        illumination = int((1 - abs(phase * 2 - 1)) * 100)
                    
                        # Determine phase name based on age in days
                        if age < 1.84566:
                            phase_name = "New Moon"
                        elif age < 5.53699:
                            phase_name = "Waxing Crescent"
                        elif age < 9.22831:
                            phase_name = "First Quarter"
                        elif age < 12.91963:
                            phase_name = "Waxing Gibbous"
                        elif age < 16.61096:
                            phase_name = "Full Moon"
                        elif age < 20.30228:
                            phase_name = "Waning Gibbous"
                        elif age < 23.99361:
                            phase_name = "Last Quarter"
                        elif age < 27.68493:
                            phase_name = "Waning Crescent"
                        else:
                            phase_name = "New Moon"
                    
                        return phase_name, illumination
                    
                    def draw_moon_icon(draw, center_x, center_y, radius, illumination):
                        # Draw full moon base
                        draw.ellipse(
                            (center_x - radius, center_y - radius, center_x + radius, center_y + radius),
                            fill=(220, 220, 220),
                        )
                    
                        shadow_color = (0, 0, 0)
                    
                        if illumination == 0:
                            # New moon — fully shadowed
                            draw.ellipse(
                                (center_x - radius, center_y - radius, center_x + radius, center_y + radius),
                                fill=shadow_color,
                            )
                        elif illumination == 100:
                            # Full moon — no shadow needed
                            pass
                        else:
                            dark_frac = 1 - (illumination / 100.0)
                            offset = radius * 2 * dark_frac
                    
                            if illumination < 50:
                                # Crescent — shadow on right side
                                draw.ellipse(
                                    (
                                        center_x + radius - offset,
                                        center_y - radius,
                                        center_x + radius + offset,
                                        center_y + radius,
                                    ),
                                    fill=shadow_color,
                                )
                            else:
                                # Gibbous — shadow on left side
                                draw.ellipse(
                                    (
                                        center_x - radius - offset,
                                        center_y - radius,
                                        center_x - radius + offset,
                                        center_y + radius,
                                    ),
                                    fill=shadow_color,
                                )
                    
                    def get_goes_gif(index):
                        now = time.time()
                        product = GOES_PRODUCTS[index]
                        cache = goes_dynamic_cache[index]
                    
                        if "image" in cache and (now - cache.get("last_updated", 0) < 300):
                            return cache["image"]
                    
                        try:
                            response = requests.get(product["url"], timeout=10, proxies=proxies)
                            if response.status_code == 200:
                                gif = Image.open(BytesIO(response.content))
                                frames = []
                                try:
                                    while True:
                                        # Resize frames once on load here
                                        frame = gif.copy().convert("RGBA").resize((disp.width, disp.height), Image.ANTIALIAS)
                                        frames.append(frame)
                                        gif.seek(gif.tell() + 1)
                                except EOFError:
                                    pass
                    
                                cache["image"] = frames
                                cache["last_updated"] = now
                                return frames
                        except Exception as e:
                            print(f"Error fetching GOES [{product['name']}] GIF: {e}")
                        return None
                    
                    # Setup displays
                    gain = Gain_Param.Gain_Param()
                    spi0 = SPI.SpiDev()
                    spi0.open(BUS_0, DEVICE_0)
                    spi0.max_speed_hz = 10000000
                    spi1 = SPI.SpiDev()
                    spi1.open(BUS_1, DEVICE_1)
                    spi1.max_speed_hz = 10000000
                    spi_main = SPI.SpiDev()
                    spi_main.open(BUS, DEVICE)
                    spi_main.max_speed_hz = 10000000
                    disp_0 = LCD_0inch96.LCD_0inch96(spi=spi0, spi_freq=10000000, rst=RST_0, dc=DC_0, bl=BL_0, bl_freq=1000)
                    disp_1 = LCD_0inch96.LCD_0inch96(spi=spi1, spi_freq=10000000, rst=RST_1, dc=DC_1, bl=BL_1, bl_freq=1000)
                    disp = LCD_1inch3.LCD_1inch3(spi=spi_main, spi_freq=10000000, rst=RST, dc=DC, bl=BL)
                    disp.Init()
                    disp_0.Init()
                    disp_1.Init()
                    disp.clear()
                    disp_0.clear()
                    disp_1.clear()
                    disp.bl_DutyCycle(100)
                    disp_0.bl_DutyCycle(100)
                    disp_1.bl_DutyCycle(100)
                    
                    # Fonts
                    FontXL = ImageFont.truetype("../Font/Font00.ttf", 28)
                    FontLarge = ImageFont.truetype("../Font/Font00.ttf", 24)
                    FontMid = ImageFont.truetype("../Font/Font00.ttf", 22)
                    FontSmall = ImageFont.truetype("../Font/Font00.ttf", 16)
                    FontTiny = ImageFont.truetype("../Font/Font00.ttf", 12)
                    
                    # Colors (RGB tuples)
                    COLOR_IP = (0, 204, 204)           
                    COLOR_IPV6 = (102, 153, 204)       
                    COLOR_DATE = (76, 175, 80)         
                    COLOR_TIME = (255, 193, 7)         
                    
                    COLOR_HEADER = (0, 153, 153)       
                    COLOR_WEATHER = (153, 204, 255)    
                    
                    COLOR_CPU = (33, 150, 243)         
                    COLOR_TEMP = (100, 181, 246)       
                    COLOR_RAM = (255, 193, 7)          
                    COLOR_DISK = (156, 39, 176)        
                    COLOR_UP = (0, 188, 212)           
                    COLOR_DN = (77, 182, 172)          
                    
                    COLOR_WPAD_HEADER = (0, 153, 153)  
                    COLOR_WPAD_USERS = (244, 67, 54)   
                    COLOR_WPAD_NONE = (117, 117, 117)  
                    
                    # Button GPIO pins (adjust if needed)
                    KEY1_PIN = 25
                    KEY2_PIN = 26
                    
                    key1 = gpiozero.Button(KEY1_PIN)
                    key2 = gpiozero.Button(KEY2_PIN)
                    
                    # Initial brightness percentage (10 to 100)
                    brightness = 100
                    
                    def update_brightness(new_brightness):
                        global brightness
                        brightness = max(10, min(100, new_brightness))  # clamp between 10 and 100
                        disp.bl_DutyCycle(brightness)
                        disp_0.bl_DutyCycle(brightness)
                        disp_1.bl_DutyCycle(brightness)
                        print(f"Brightness set to {brightness}%")
                    
                    def key2_callback():
                        new_bright = brightness + 10
                        if new_bright > 100:
                            new_bright = 10
                        update_brightness(new_bright)
                    
                    key2.when_pressed = key2_callback
                    
                    # State tracking for pages: 0=original,1=weather,2=moon,3=doppler
                    current_page = 0
                    NUM_PAGES = 4 + len(GOES_PRODUCTS)  # 4 original + new GOES pages
                    
                    key1_hold_timer = None
                    key1_held = False
                    HOLD_TIME_SECONDS = 3
                    
                    def key1_hold_action():
                        global current_page, key1_held
                        with state_lock:
                            key1_held = True
                            current_page = 0
                        print("Button held for 3 seconds: returning to page 0")
                    
                    
                    def key1_pressed():
                        global key1_hold_timer, key1_held
                        with state_lock:
                            key1_held = False
                            key1_hold_timer = threading.Timer(HOLD_TIME_SECONDS, key1_hold_action)
                            key1_hold_timer.start()
                    
                    
                    def update_display_after_debounce():
                        global debounce_timer, current_page
                        with state_lock:
                            debounce_timer = None
                            # Usually here you might want to trigger a redraw or notify main loop
                            # You print info for debugging only
                            print(f"Debounced update: Showing page {current_page}")
                    
                    
                    def key1_released():
                        global key1_hold_timer, key1_held, current_page, debounce_timer
                        with state_lock:
                            if key1_hold_timer:
                                key1_hold_timer.cancel()
                                key1_hold_timer = None
                    
                            if not key1_held:
                                current_page = (current_page + 1) % NUM_PAGES
                                print(f"Button released: page now {current_page}")
                    
                                if debounce_timer:
                                    debounce_timer.cancel()
                    
                                debounce_timer = threading.Timer(DEBOUNCE_DELAY, update_display_after_debounce)
                                debounce_timer.start()
                    
                    key1.when_pressed = key1_pressed
                    key1.when_released = key1_released
                    
                    # --- Original display functions ---
                    
                    def draw_top_screen(draw):
                        ip = gain.GET_IP()
                        ipv6 = get_ipv6_address()
                        now = time.localtime()
                        time_t = time.strftime("%H:%M:%S", now)
                        time_D = time.strftime("%Y-%m-%d", now)
                    
                        draw.text((2, 0), 'IP: ' + ip, fill=COLOR_IP, font=FontSmall)
                        draw.text((2, 18), ipv6, fill=COLOR_IPV6, font=FontTiny)
                        draw.text((2, 36), 'DATE: ' + time_D, fill=COLOR_DATE, font=FontSmall)
                        draw.text((2, 54), 'TIME: ' + time_t, fill=COLOR_TIME, font=FontSmall)
                    
                    def draw_middle_screen(draw, last_bytes):
                        y = 0
                        spacing = 26
                    
                        header_text = "ROSEVILLE   WEATHER"
                        w, _ = draw.textsize(header_text, font=FontSmall)
                        draw.text(((disp.width - w) // 2, y), header_text, fill=COLOR_HEADER, font=FontSmall)
                        y += spacing
                    
                        weather = get_detailed_weather()
                        if weather:
                            temp_f = int(round(weather["main"]["temp"]))
                            humidity = int(weather["main"]["humidity"])
                            pressure_pa = weather["main"]["pressure"] * 100
                            pressure_inhg = pressure_pa / 3386.39
                            summary = f"{temp_f}°{humidity}%  {pressure_inhg:.2f} inHg"
                        else:
                            summary = "Weather: Unavailable"
                        w, _ = draw.textsize(summary, font=FontMid)
                        draw.text(((disp.width - w) // 2, y), summary, fill=COLOR_WEATHER, font=FontMid)
                        y += spacing
                    
                        wpad_header = "WPAD   HEALTH"
                        w, _ = draw.textsize(wpad_header, font=FontSmall)
                        draw.text(((disp.width - w) // 2, y), wpad_header, fill=COLOR_HEADER, font=FontSmall)
                        y += spacing
                    
                        cpu = int(psutil.cpu_percent(interval=None))
                        draw.text((2, y), f"CPU: {cpu}%", fill=COLOR_CPU, font=FontLarge)
                        y += spacing
                    
                        temp = int(gain.GET_Temp())
                        draw.text((2, y), f"Temp: {temp}℃", fill=COLOR_TEMP, font=FontLarge)
                        y += spacing
                    
                        ram = int(psutil.virtual_memory().percent)
                        draw.text((2, y), f"RAM: {ram}%", fill=COLOR_RAM, font=FontLarge)
                        y += spacing
                    
                        disk = int(psutil.disk_usage('/').percent)
                        draw.text((2, y), f"Disk: {disk}%", fill=COLOR_DISK, font=FontLarge)
                        y += spacing
                    
                        counters = psutil.net_io_counters()
                        new_sent = counters.bytes_sent
                        new_recv = counters.bytes_recv
                        up = (new_sent - last_bytes[0]) * 8 / 1_000_000
                        dn = (new_recv - last_bytes[1]) * 8 / 1_000_000
                        draw.text((2, y), f"UP: {up:.2f}Mbps", fill=COLOR_UP, font=FontLarge)
                        y += spacing
                        draw.text((2, y), f"DN: {dn:.2f}Mbps", fill=COLOR_DN, font=FontLarge)
                        return new_sent, new_recv
                    
                    def draw_wpad_status(draw):
                        draw.text((2, 0), "WPAD Users:", fill=COLOR_WPAD_HEADER, font=FontMid)
                        try:
                            users = subprocess.check_output(['who']).decode().splitlines()
                            usernames = sorted(set(line.split()[0] for line in users if line.strip()))
                            display = ', '.join(usernames) if usernames else "None"
                        except Exception as e:
                            display = f"Error: {e}"
                    
                        color = COLOR_WPAD_USERS if display != "None" else COLOR_WPAD_NONE
                        draw.text((2, 20), display, fill=color, font=FontMid)
                    
                    # --- Weather page drawing functions ---
                    
                    def draw_weather_top(draw):
                        now = time.localtime()
                        time_t = time.strftime("%H:%M:%S", now)
                        date_t = time.strftime("%A, %b %d %Y", now)
                        ip = gain.GET_IP()
                        ipv6 = get_ipv6_address()
                    
                        draw.text((2, 0), "Detailed Weather", fill=COLOR_HEADER, font=FontSmall)
                        draw.text((2, 18), f"IP: {ip}", fill=COLOR_IP, font=FontTiny)
                        draw.text((2, 30), f"IPv6: {ipv6}", fill=COLOR_IPV6, font=FontTiny)
                        draw.text((2, 44), date_t, fill=COLOR_DATE, font=FontTiny)
                        draw.text((2, 58), time_t, fill=COLOR_TIME, font=FontTiny)
                    
                    def draw_weather_middle(draw):
                        weather = get_detailed_weather()
                        y = 0
                        spacing = 20
                    
                        if weather:
                            main = weather["main"]
                            wind = weather.get("wind", {})
                            weather_desc = weather["weather"][0]["description"].capitalize()
                    
                            temp = main["temp"]
                            feels_like = main.get("feels_like", temp)
                            humidity = main["humidity"]
                            pressure_pa = main["pressure"] * 100
                            pressure_inhg = pressure_pa / 3386.39
                            wind_speed = wind.get("speed", 0)
                            wind_deg = wind.get("deg", 0)
                    
                            draw.text((2, y), f"Temp: {temp:.1f}°F", fill=COLOR_WEATHER, font=FontMid)
                            y += spacing
                            draw.text((2, y), f"Feels Like: {feels_like:.1f}°F", fill=COLOR_WEATHER, font=FontMid)
                            y += spacing
                            draw.text((2, y), f"Humidity: {humidity}%", fill=COLOR_WEATHER, font=FontMid)
                            y += spacing
                            draw.text((2, y), f"Pressure: {pressure_inhg:.2f} inHg", fill=COLOR_WEATHER, font=FontMid)
                            y += spacing
                            draw.text((2, y), f"Wind: {wind_speed} mph @ {wind_deg}°", fill=COLOR_WEATHER, font=FontMid)
                            y += spacing
                            draw.text((2, y), f"Condition: {weather_desc}", fill=COLOR_WEATHER, font=FontMid)
                        else:
                            draw.text((2, y), "Weather data unavailable", fill=COLOR_WPAD_NONE, font=FontMid)
                    
                    def draw_weather_bottom(image):
                        draw = ImageDraw.Draw(image)
                        weather = get_detailed_weather()
                        if weather and "weather" in weather and len(weather["weather"]) > 0:
                            condition = weather["weather"][0]["description"].capitalize()
                            if weather_cache["icon_image"]:
                                try:
                                    icon_img = weather_cache["icon_image"].resize((64, 64), Image.ANTIALIAS)
                                    x = (image.width - icon_img.width) // 2
                                    y = (image.height - icon_img.height) // 2 - 10
                                    image.paste(icon_img, (x, y), icon_img)
                    
                                    font = FontTiny
                                    w, h = draw.textsize(condition, font=font)
                                    text_x = (image.width - w) // 2
                                    text_y = y + icon_img.height + 2
                                    draw.text((text_x, text_y), condition, fill=COLOR_WEATHER, font=font)
                                except Exception:
                                    draw.text((10, 10), condition, fill=COLOR_WPAD_NONE, font=FontMid)
                            else:
                                draw.text((10, 10), condition, fill=COLOR_WPAD_NONE, font=FontMid)
                        else:
                            draw.text((10, 10), "No icon data", fill=COLOR_WPAD_NONE, font=FontMid)
                    
                    # --- Moon phase page ---
                    
                    def draw_moon_top(draw):
                        now = time.localtime()
                        time_t = time.strftime("%H:%M:%S", now)
                        date_t = time.strftime("%A, %b %d %Y", now)
                        ip = gain.GET_IP()
                        ipv6 = get_ipv6_address()
                    
                        draw.text((2, 0), "Moon Phase", fill=COLOR_HEADER, font=FontSmall)
                        draw.text((2, 18), f"IP: {ip}", fill=COLOR_IP, font=FontTiny)
                        draw.text((2, 30), f"IPv6: {ipv6}", fill=COLOR_IPV6, font=FontTiny)
                        draw.text((2, 44), date_t, fill=COLOR_DATE, font=FontTiny)
                        draw.text((2, 58), time_t, fill=COLOR_TIME, font=FontTiny)
                    
                    def draw_moon_middle(draw):
                        phase_name, illumination = get_moon_phase()
                        w, h = draw.textsize(phase_name, font=FontLarge)
                        draw.text(((disp.width - w) // 2, 10), phase_name, fill=COLOR_WEATHER, font=FontLarge)
                        draw_moon_icon(draw, disp.width // 2, 80, 40, illumination)
                    
                    def draw_moon_bottom(draw):
                        draw.text((2, 0), "Lunar illumination %", fill=COLOR_WPAD_HEADER, font=FontSmall)
                        phase_name, illumination = get_moon_phase()
                        draw.text((2, 18), f"{illumination}%", fill=COLOR_WEATHER, font=FontLarge)
                    
                    # --- Doppler radar page ---
                    
                    def draw_doppler_top(draw):
                        now = time.localtime()
                        date_str = time.strftime("%A, %b %d %Y", now)
                        time_str = time.strftime("%H:%M:%S", now)
                        ip = gain.GET_IP()
                        ipv6 = get_ipv6_address()
                    
                        draw.text((2, 0), "Doppler Radar", fill=COLOR_HEADER, font=FontSmall)
                        draw.text((2, 18), f"IP: {ip}", fill=COLOR_IP, font=FontTiny)
                        draw.text((2, 30), f"IPv6: {ipv6}", fill=COLOR_IPV6, font=FontTiny)
                        draw.text((2, 44), date_str, fill=COLOR_DATE, font=FontTiny)
                        draw.text((2, 58), time_str, fill=COLOR_TIME, font=FontTiny)
                    
                    doppler_frame_index = 0  # Add this at the top level outside the loop
                    
                    #---GOES page-------
                    
                    def draw_doppler_middle(image):
                        global doppler_frame_index
                        frames = get_doppler_image()
                        if frames:
                            frame = frames[doppler_frame_index % len(frames)]
                            resized = frame.resize((image.width, image.height), Image.ANTIALIAS)
                            image.paste(resized, (0, 0), resized)
                            doppler_frame_index += 1
                        else:
                            draw = ImageDraw.Draw(image)
                            msg = "Radar Image\nUnavailable"
                            w, h = draw.textsize(msg, font=FontLarge)
                            draw.text(((image.width - w) // 2, (image.height - h) // 2), msg, fill=COLOR_WPAD_NONE, font=FontLarge)
                    
                    def draw_doppler_bottom(draw):
                        draw.text((2, 2), "Radar updated every 5 min", fill=COLOR_WPAD_NONE, font=FontTiny)
                    
                    def draw_goes_top(draw, label="GOES-18 Product"):
                        now = time.localtime()
                        date_str = time.strftime("%A, %b %d %Y", now)
                        time_str = time.strftime("%H:%M:%S", now)
                        ip = gain.GET_IP()
                        ipv6 = get_ipv6_address()
                    
                        draw.text((2, 0), label, fill=COLOR_HEADER, font=FontSmall)
                        draw.text((2, 18), f"IP: {ip}", fill=COLOR_IP, font=FontTiny)
                        draw.text((2, 30), f"IPv6: {ipv6}", fill=COLOR_IPV6, font=FontTiny)
                        draw.text((2, 44), date_str, fill=COLOR_DATE, font=FontTiny)
                        draw.text((2, 58), time_str, fill=COLOR_TIME, font=FontTiny)
                    
                    def draw_goes_middle(image, index):
                        global goes_dynamic_frame_index
                        frames = get_goes_gif(index)
                        if frames:
                            frame = frames[goes_dynamic_frame_index[index] % len(frames)]
                            resized = frame.resize((image.width, image.height), Image.ANTIALIAS)
                            image.paste(resized, (0, 0), resized)
                            goes_dynamic_frame_index[index] += 1
                        else:
                            draw = ImageDraw.Draw(image)
                            msg = "GOES GIF\nUnavailable"
                            w, h = draw.textsize(msg, font=FontLarge)
                            draw.text(((image.width - w) // 2, (image.height - h) // 2), msg, fill=COLOR_WPAD_NONE, font=FontLarge)
                    
                    def draw_goes_bottom(draw, product_name=None):
                        text = "Updated every 5 min"
                        if product_name:
                            text = f"{product_name} - {text}"
                        draw.text((2, 2), text, fill=COLOR_WPAD_NONE, font=FontTiny)
                    
                    
                    # --- Main loop ---
                    
                    last_bytes_sent = 0
                    last_bytes_recv = 0
                    
                    try:
                        last_page = None  # Track the previous page to detect transitions
                    
                        while True:
                            with state_lock:
                                page = current_page  # safely snapshot current page under lock
                    
                            # Reset GOES animation frame index if just switched from Doppler (page 3)
                            if (4 <= page < 4 + len(GOES_PRODUCTS)) and (last_page == 3):
                                goes_index = page - 4
                                goes_dynamic_frame_index[goes_index] = 0
                    
                            if page == 0:
                                image0 = Image.new("RGB", (disp_0.width, disp_0.height), (0, 0, 0))
                                draw0 = ImageDraw.Draw(image0)
                                draw_top_screen(draw0)
                                disp_0.ShowImage(image0)
                    
                                image = Image.new("RGB", (disp.width, disp.height), (0, 0, 0))
                                draw = ImageDraw.Draw(image)
                                last_bytes_sent, last_bytes_recv = draw_middle_screen(draw, (last_bytes_sent, last_bytes_recv))
                                disp.ShowImage(image)
                    
                                image1 = Image.new("RGB", (disp_1.width, disp_1.height), (0, 0, 0))
                                draw1 = ImageDraw.Draw(image1)
                                draw_wpad_status(draw1)
                                disp_1.ShowImage(image1)
                    
                            elif page == 1:
                                image0 = Image.new("RGB", (disp_0.width, disp_0.height), (0, 0, 0))
                                draw0 = ImageDraw.Draw(image0)
                                draw_weather_top(draw0)
                                disp_0.ShowImage(image0)
                    
                                image = Image.new("RGB", (disp.width, disp.height), (0, 0, 0))
                                draw = ImageDraw.Draw(image)
                                draw_weather_middle(draw)
                                disp.ShowImage(image)
                    
                                image1 = Image.new("RGB", (disp_1.width, disp_1.height), (0, 0, 0))
                                draw_weather_bottom(image1)
                                disp_1.ShowImage(image1)
                    
                            elif page == 2:
                                image0 = Image.new("RGB", (disp_0.width, disp_0.height), (0, 0, 0))
                                draw0 = ImageDraw.Draw(image0)
                                draw_moon_top(draw0)
                                disp_0.ShowImage(image0)
                    
                                image = Image.new("RGB", (disp.width, disp.height), (0, 0, 0))
                                draw = ImageDraw.Draw(image)
                                draw_moon_middle(draw)
                                disp.ShowImage(image)
                    
                                image1 = Image.new("RGB", (disp_1.width, disp_1.height), (0, 0, 0))
                                draw1 = ImageDraw.Draw(image1)
                                draw_moon_bottom(draw1)
                                disp_1.ShowImage(image1)
                    
                            elif page == 3:
                                if time.time() - last_doppler_fetch > 300 or not doppler_frames:
                                    doppler_frames = get_doppler_image()
                                    last_doppler_fetch = time.time()
                                    doppler_frame_index = 0
                    
                                image0 = Image.new("RGB", (disp_0.width, disp_0.height), (0, 0, 0))
                                draw0 = ImageDraw.Draw(image0)
                                draw_doppler_top(draw0)
                                disp_0.ShowImage(image0)
                    
                                image = Image.new("RGBA", (disp.width, disp.height), (0, 0, 0, 0))
                                if doppler_frames:
                                    frame = doppler_frames[doppler_frame_index % len(doppler_frames)]
                                    image.paste(frame, (0, 0), frame)
                                    doppler_frame_index += 1
                                disp.ShowImage(image.convert("RGB"))
                    
                                image1 = Image.new("RGB", (disp_1.width, disp_1.height), (0, 0, 0))
                                draw1 = ImageDraw.Draw(image1)
                                draw_doppler_bottom(draw1)
                                disp_1.ShowImage(image1)
                    
                            elif 4 <= page < 4 + len(GOES_PRODUCTS):
                                goes_index = page - 4
                                now = time.time()
                                cache = goes_dynamic_cache[goes_index]
                    
                                if "last_updated" not in cache or (now - cache["last_updated"]) > 300:
                                    frames = get_goes_gif(goes_index)
                                    if frames:
                                        cache["image"] = frames
                                        cache["last_updated"] = now
                                        # We do NOT reset goes_dynamic_frame_index here; it's reset on page transition
                    
                                image0 = Image.new("RGB", (disp_0.width, disp_0.height), (0, 0, 0))
                                draw0 = ImageDraw.Draw(image0)
                                draw_goes_top(draw0, GOES_PRODUCTS[goes_index]["name"])
                                disp_0.ShowImage(image0)
                    
                                image = Image.new("RGBA", (disp.width, disp.height), (0, 0, 0, 0))
                                frames = cache.get("image")
                                if frames:
                                    frame = frames[goes_dynamic_frame_index[goes_index] % len(frames)]
                                    image.paste(frame, (0, 0), frame)
                                    goes_dynamic_frame_index[goes_index] += 1
                                else:
                                    draw = ImageDraw.Draw(image)
                                    msg = "GOES GIF\nUnavailable"
                                    w, h = draw.textsize(msg, font=FontLarge)
                                    draw.text(((image.width - w) // 2, (image.height - h) // 2), msg, fill=COLOR_WPAD_NONE, font=FontLarge)
                    
                                disp.ShowImage(image.convert("RGB"))
                    
                                image1 = Image.new("RGB", (disp_1.width, disp_1.height), (0, 0, 0))
                                draw1 = ImageDraw.Draw(image1)
                                draw_goes_bottom(draw1, GOES_PRODUCTS[goes_index]["name"])
                                disp_1.ShowImage(image1)
                    
                            # Sleep delays based on page type
                            if page in [3] + list(range(4, 4 + len(GOES_PRODUCTS))):
                                time.sleep(FRAME_DELAYS["animation"])
                            else:
                                time.sleep(FRAME_DELAYS["default"])
                    
                            last_page = page  # Update last_page after processing
                    
                    except KeyboardInterrupt:
                        print("Exiting...")
                        disp_0.clear()
                        disp_1.clear()
                        disp.clear()
                        sys.exit()
                    
                    
                    1 Reply Last reply Reply Quote 0
                    • JonathanLeeJ
                      JonathanLee
                      last edited by

                      This post is deleted!
                      1 Reply Last reply Reply Quote 0
                      • JonathanLeeJ
                        JonathanLee
                        last edited by

                        This post is deleted!
                        1 Reply Last reply Reply Quote 0
                        • JonathanLeeJ
                          JonathanLee
                          last edited by JonathanLee

                          This is a better code for the screen if you have one

                          This code is even better it uses moon images and has corrections to the moon functionality

                          import os
                          import sys
                          import time
                          import math
                          from datetime import datetime
                          import psutil
                          import subprocess
                          import threading
                          import gpiozero  # Import once at the top
                          from PIL import Image, ImageDraw, ImageFont
                          import spidev as SPI
                          import requests
                          from io import BytesIO  # <-- Needed for icon image loading
                          
                          # Add parent directory to path to locate 'lib'
                          sys.path.append("..")
                          from lib import LCD_0inch96, LCD_1inch3, Gain_Param
                          
                          # Raspberry Pi pin config
                          RST_0 = 24
                          DC_0 = 4
                          BL_0 = 13
                          BUS_0 = 0
                          DEVICE_0 = 0
                          
                          RST_1 = 23
                          DC_1 = 5
                          BL_1 = 12
                          BUS_1 = 0
                          DEVICE_1 = 1
                          
                          RST = 27
                          DC = 22
                          BL = 19
                          BUS = 1
                          DEVICE = 0
                          state_lock = threading.Lock()
                          debounce_timer = None
                          DEBOUNCE_DELAY = 0.3  # seconds debounce delay to batch rapid presses
                          
                          # Proxy setup (if needed, else remove or set to None)
                          proxies = {
                              "http": "http://192.168.1.1:3128",
                              "https": "http://192.168.1.1:3128",
                          }
                          
                          # OpenWeatherMap API details
                          API_KEY = "API KEY HERE"
                          ZIP_CODE = "ZIP CODE HERE"
                          UNITS = "imperial"
                          
                          # Weather cache to reduce API calls
                          weather_cache = {
                              "data": None,
                              "last_updated": 0,  # Unix timestamp
                              "icon_code": None,
                              "icon_image": None
                          }
                          
                          # Doppler radar cache
                          doppler_cache = {
                              "image": None,
                              "last_updated": 0
                          }
                          doppler_frame_index = 0  # Track which frame to show
                          
                          # Frame caches and timers
                          doppler_frames = []
                          last_doppler_fetch = 0
                          DOPPLER_URL = "https://radar.weather.gov/ridge/standard/KMUX_loop.gif"
                          # GOES-18 product definitions
                          GOES_PRODUCTS = [
                              {
                                  "name": "GeoColor",
                                  "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/GEOCOLOR/GOES18-PSW-GEOCOLOR-600x600.gif"
                              },
                              {
                                  "name": "Air Mass RGB",
                                  "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/AirMass/GOES18-PSW-AirMass-600x600.gif"
                              },
                              {
                                  "name": "Sandwich RGB",
                                  "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/Sandwich/GOES18-PSW-Sandwich-600x600.gif"
                              },
                              {
                                  "name": "Day/Night Cloud",
                                  "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/DayNightCloudMicroCombo/GOES18-PSW-DayNightCloudMicroCombo-600x600.gif"
                              },
                              {
                                  "name": "Fire Temperature",
                                  "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/FireTemperature/GOES18-PSW-FireTemperature-600x600.gif"
                              },
                              {
                                  "name": "Band 02 - Red Visible",
                                  "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/02/GOES18-PSW-02-600x600.gif"
                              },
                              {
                                  "name": "Band 07 - Shortwave IR",
                                  "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/07/GOES18-PSW-07-600x600.gif"
                              },
                              {
                                  "name": "Band 08 - Water Vapor",
                                  "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/08/GOES18-PSW-08-600x600.gif"
                              },
                              {
                                  "name": "Band 13 - Clean IR",
                                  "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/13/GOES18-PSW-13-600x600.gif"
                              },
                              {
                                  "name": "Band 14 - IR Window",
                                  "url": "https://cdn.star.nesdis.noaa.gov/GOES18/ABI/SECTOR/psw/14/GOES18-PSW-14-600x600.gif"
                              },
                              {
                                  "name": "SOHO EIT 171",
                                  "url": "https://soho.nascom.nasa.gov/data/LATEST/current_eit_171.gif"
                              },
                              {
                                  "name": "SOHO EIT 304",
                                  "url": "https://soho.nascom.nasa.gov/data/LATEST/current_eit_304.gif"
                              }
                          ]
                          # GOES-18 GIF cache containers (one per product)
                          goes_dynamic_cache = [{} for _ in GOES_PRODUCTS]
                          goes_dynamic_frame_index = [0 for _ in GOES_PRODUCTS]
                          
                          FRAME_DELAYS = {
                              "default": 1,
                              "animation": 0.033
                          }
                          
                          
                          # Get IPv6 address
                          def get_ipv6_address():
                              try:
                                  output = subprocess.check_output("ip -6 addr", shell=True).decode()
                                  for line in output.splitlines():
                                      line = line.strip()
                                      if line.startswith("inet6") and "scope global" in line:
                                          return line.split()[1].split("/")[0]
                              except Exception:
                                  pass
                              return "No IPv6"
                          
                          # Fetch detailed weather info (dict with multiple fields)
                          def get_detailed_weather():
                              now = time.time()
                              if weather_cache["data"] and (now - weather_cache["last_updated"] < 300):
                                  return weather_cache["data"]
                              try:
                                  response = requests.get(
                                      f"http://api.openweathermap.org/data/2.5/weather?zip={ZIP_CODE},us&appid={API_KEY}&units={UNITS}",
                                      timeout=5,
                                      proxies=proxies
                                  )
                                  if response.status_code == 200:
                                      data = response.json()
                                      weather_cache["data"] = data
                                      weather_cache["last_updated"] = now
                          
                                      # Download and cache icon if changed
                                      icon_code = data["weather"][0]["icon"]
                                      if icon_code != weather_cache["icon_code"]:
                                          icon_url = f"https://openweathermap.org/img/wn/{icon_code}@2x.png"
                                          try:
                                              icon_response = requests.get(icon_url, timeout=5, proxies=proxies)
                                              if icon_response.status_code == 200:
                                                  icon_image = Image.open(BytesIO(icon_response.content)).convert("RGBA")
                                                  weather_cache["icon_image"] = icon_image
                                                  weather_cache["icon_code"] = icon_code
                                              else:
                                                  weather_cache["icon_image"] = None
                                                  weather_cache["icon_code"] = None
                                          except Exception:
                                              weather_cache["icon_image"] = None
                                              weather_cache["icon_code"] = None
                          
                                      return data
                                  else:
                                      return None
                              except Exception:
                                  return None
                          
                          # Fetch Doppler radar image, cached for 5 minutes
                          def get_doppler_image():
                              now = time.time()
                              if doppler_cache["image"] and (now - doppler_cache["last_updated"] < 300):
                                  return doppler_cache["image"]
                          
                              try:
                                  response = requests.get(DOPPLER_URL, timeout=5, proxies=proxies)
                                  if response.status_code == 200:
                                      gif = Image.open(BytesIO(response.content))
                                      frames = []
                                      try:
                                          while True:
                                              # Resize here once on load
                                              frame = gif.copy().convert("RGBA").resize((disp.width, disp.height), Image.ANTIALIAS)
                                              frames.append(frame)
                                              gif.seek(gif.tell() + 1)
                                      except EOFError:
                                          pass  # End of frames
                          
                                      doppler_cache["image"] = frames
                                      doppler_cache["last_updated"] = now
                                      return frames
                              except Exception:
                                  pass
                              return None
                          
                          # --- Moon phase logic ---
                          moon_image_folder = "/usr/Zero_LCD_HAT_A_Demo/python/example/moon/moon_phases"
                          moon_images_cache = {}
                          
                          def get_moon_image(illumination):
                              # Clamp illumination
                              illumination = max(0, min(illumination, 100))
                              phase_index = int(illumination / 6.25)
                              if phase_index > 15:
                                  phase_index = 15
                              if phase_index in moon_images_cache:
                                  return moon_images_cache[phase_index]
                              filename = f"Fase_{phase_index+1}.jpg"
                              path = os.path.join(moon_image_folder, filename)
                              try:
                                  img = Image.open(path).convert("RGBA")
                              except FileNotFoundError:
                                  print(f"Warning: {filename} not found.")
                                  img = Image.new("RGBA", (100, 100), (0,0,0,0))  # transparent fallback
                              # Cache it
                              moon_images_cache[phase_index] = img
                              # Limit cache size (optional): keep only last 4 images
                              if len(moon_images_cache) > 4:
                                  # remove oldest cache item
                                  oldest_key = next(iter(moon_images_cache))
                                  del moon_images_cache[oldest_key]
                          
                              return img
                          
                          def get_moon_phase():
                              # Calculate moon age and illumination as before
                              known_new_moon = datetime(2000, 1, 6, 18, 14)
                              now = datetime.utcnow()
                              diff = now - known_new_moon
                              days_since_new = diff.total_seconds() / 86400.0
                              lunar_cycle = 29.53058867
                              age = days_since_new % lunar_cycle
                          
                              phase = age / lunar_cycle
                              illumination = int((1 - abs(phase * 2 - 1)) * 100)
                          
                              # Use illumination % to assign phase name to match images better
                              if illumination <= 1:
                                  phase_name = "New Moon"
                              elif illumination < 49:
                                  phase_name = "Waxing Crescent"
                              elif 49 <= illumination <= 51:
                                  phase_name = "First Quarter"
                              elif illumination < 99:
                                  phase_name = "Waxing Gibbous"
                              elif illumination == 100:
                                  phase_name = "Full Moon"
                              elif illumination > 51:
                                  # Waning phases: just invert ranges or you can use age for waning/waxing detection
                                  # For better accuracy, check if moon is waxing or waning by age
                                  if age > lunar_cycle / 2:
                                      # Waning phases
                                      if illumination > 51 and illumination < 99:
                                          phase_name = "Waning Gibbous"
                                      elif 49 <= illumination <= 51:
                                          phase_name = "Last Quarter"
                                      elif illumination < 49:
                                          phase_name = "Waning Crescent"
                                  else:
                                      phase_name = "Waxing Gibbous"  # fallback
                          
                              else:
                                  phase_name = "New Moon"  # fallback
                          
                              return phase_name, illumination
                          
                          def draw_moon_icon(base_image, center_x, center_y, radius, illumination):
                              # Get the moon image based on illumination
                              moon_image = get_moon_image(illumination)
                              # Resize the moon image to match the desired diameter
                              moon_image_resized = moon_image.resize((radius * 2, radius * 2), Image.ANTIALIAS)
                              # Calculate position to paste
                              x_offset = center_x - radius
                              y_offset = center_y - radius
                              # Paste moon image onto base image (with transparency if any)
                              base_image.paste(moon_image_resized, (x_offset, y_offset), moon_image_resized.convert("RGBA"))
                          
                          def get_goes_gif(index):
                              now = time.time()
                              product = GOES_PRODUCTS[index]
                              cache = goes_dynamic_cache[index]
                          
                              if "image" in cache and (now - cache.get("last_updated", 0) < 300):
                                  return cache["image"]
                          
                              try:
                                  response = requests.get(product["url"], timeout=10, proxies=proxies)
                                  if response.status_code == 200:
                                      gif = Image.open(BytesIO(response.content))
                                      frames = []
                                      try:
                                          while True:
                                              # Resize frames once on load here
                                              frame = gif.copy().convert("RGBA").resize((disp.width, disp.height), Image.ANTIALIAS)
                                              frames.append(frame)
                                              gif.seek(gif.tell() + 1)
                                      except EOFError:
                                          pass
                          
                                      cache["image"] = frames
                                      cache["last_updated"] = now
                                      return frames
                              except Exception as e:
                                  print(f"Error fetching GOES [{product['name']}] GIF: {e}")
                              return None
                          
                          # Setup displays
                          gain = Gain_Param.Gain_Param()
                          spi0 = SPI.SpiDev()
                          spi0.open(BUS_0, DEVICE_0)
                          spi0.max_speed_hz = 10000000
                          spi1 = SPI.SpiDev()
                          spi1.open(BUS_1, DEVICE_1)
                          spi1.max_speed_hz = 10000000
                          spi_main = SPI.SpiDev()
                          spi_main.open(BUS, DEVICE)
                          spi_main.max_speed_hz = 10000000
                          disp_0 = LCD_0inch96.LCD_0inch96(spi=spi0, spi_freq=10000000, rst=RST_0, dc=DC_0, bl=BL_0, bl_freq=1000)
                          disp_1 = LCD_0inch96.LCD_0inch96(spi=spi1, spi_freq=10000000, rst=RST_1, dc=DC_1, bl=BL_1, bl_freq=1000)
                          disp = LCD_1inch3.LCD_1inch3(spi=spi_main, spi_freq=10000000, rst=RST, dc=DC, bl=BL)
                          disp.Init()
                          disp_0.Init()
                          disp_1.Init()
                          disp.clear()
                          disp_0.clear()
                          disp_1.clear()
                          disp.bl_DutyCycle(100)
                          disp_0.bl_DutyCycle(100)
                          disp_1.bl_DutyCycle(100)
                          
                          # Fonts
                          FontXL = ImageFont.truetype("../Font/Font00.ttf", 28)
                          FontLarge = ImageFont.truetype("../Font/Font00.ttf", 24)
                          FontMid = ImageFont.truetype("../Font/Font00.ttf", 22)
                          FontSmall = ImageFont.truetype("../Font/Font00.ttf", 16)
                          FontTiny = ImageFont.truetype("../Font/Font00.ttf", 12)
                          
                          # Colors (RGB tuples)
                          COLOR_IP = (0, 204, 204)           
                          COLOR_IPV6 = (102, 153, 204)       
                          COLOR_DATE = (76, 175, 80)         
                          COLOR_TIME = (255, 193, 7)         
                          
                          COLOR_HEADER = (0, 153, 153)       
                          COLOR_WEATHER = (153, 204, 255)    
                          
                          COLOR_CPU = (33, 150, 243)         
                          COLOR_TEMP = (100, 181, 246)       
                          COLOR_RAM = (255, 193, 7)          
                          COLOR_DISK = (156, 39, 176)        
                          COLOR_UP = (0, 188, 212)           
                          COLOR_DN = (77, 182, 172)          
                          
                          COLOR_WPAD_HEADER = (0, 153, 153)  
                          COLOR_WPAD_USERS = (244, 67, 54)   
                          COLOR_WPAD_NONE = (117, 117, 117)  
                          
                          # Button GPIO pins (adjust if needed)
                          KEY1_PIN = 25
                          KEY2_PIN = 26
                          
                          key1 = gpiozero.Button(KEY1_PIN)
                          key2 = gpiozero.Button(KEY2_PIN)
                          
                          # Initial brightness percentage (10 to 100)
                          brightness = 100
                          
                          def update_brightness(new_brightness):
                              global brightness
                              brightness = max(10, min(100, new_brightness))  # clamp between 10 and 100
                              disp.bl_DutyCycle(brightness)
                              disp_0.bl_DutyCycle(brightness)
                              disp_1.bl_DutyCycle(brightness)
                              print(f"Brightness set to {brightness}%")
                          
                          def key2_callback():
                              new_bright = brightness + 10
                              if new_bright > 100:
                                  new_bright = 10
                              update_brightness(new_bright)
                          
                          key2.when_pressed = key2_callback
                          
                          # State tracking for pages: 0=original,1=weather,2=moon,3=doppler
                          current_page = 0
                          NUM_PAGES = 4 + len(GOES_PRODUCTS)  # 4 original + new GOES pages
                          
                          key1_hold_timer = None
                          key1_held = False
                          HOLD_TIME_SECONDS = 3
                          
                          def key1_hold_action():
                              global current_page, key1_held
                              with state_lock:
                                  key1_held = True
                                  current_page = 0
                              print("Button held for 3 seconds: returning to page 0")
                          
                          
                          def key1_pressed():
                              global key1_hold_timer, key1_held
                              with state_lock:
                                  key1_held = False
                                  key1_hold_timer = threading.Timer(HOLD_TIME_SECONDS, key1_hold_action)
                                  key1_hold_timer.start()
                          
                          
                          def update_display_after_debounce():
                              global debounce_timer, current_page
                              with state_lock:
                                  debounce_timer = None
                                  # Usually here you might want to trigger a redraw or notify main loop
                                  # You print info for debugging only
                                  print(f"Debounced update: Showing page {current_page}")
                          
                          
                          def key1_released():
                              global key1_hold_timer, key1_held, current_page, debounce_timer
                              with state_lock:
                                  if key1_hold_timer:
                                      key1_hold_timer.cancel()
                                      key1_hold_timer = None
                          
                                  if not key1_held:
                                      current_page = (current_page + 1) % NUM_PAGES
                                      print(f"Button released: page now {current_page}")
                          
                                      if debounce_timer:
                                          debounce_timer.cancel()
                          
                                      debounce_timer = threading.Timer(DEBOUNCE_DELAY, update_display_after_debounce)
                                      debounce_timer.start()
                          
                          key1.when_pressed = key1_pressed
                          key1.when_released = key1_released
                          
                          # --- Original display functions ---
                          def draw_top_screen(draw):
                              ip = gain.GET_IP()
                              ipv6 = get_ipv6_address()
                              now = time.localtime()
                              time_t = time.strftime("%H:%M:%S", now)
                              time_D = time.strftime("%Y-%m-%d", now)
                          
                              draw.text((2, 0), 'IP: ' + ip, fill=COLOR_IP, font=FontSmall)
                              draw.text((2, 18), ipv6, fill=COLOR_IPV6, font=FontTiny)
                              draw.text((2, 36), 'DATE: ' + time_D, fill=COLOR_DATE, font=FontSmall)
                              draw.text((2, 54), 'TIME: ' + time_t, fill=COLOR_TIME, font=FontSmall)
                          
                          def draw_middle_screen(draw, last_bytes):
                              y = 0
                              spacing = 26
                          
                              header_text = "ROSEVILLE   WEATHER"
                              w, _ = draw.textsize(header_text, font=FontSmall)
                              draw.text(((disp.width - w) // 2, y), header_text, fill=COLOR_HEADER, font=FontSmall)
                              y += spacing
                          
                              weather = get_detailed_weather()
                              if weather:
                                  temp_f = int(round(weather["main"]["temp"]))
                                  humidity = int(weather["main"]["humidity"])
                                  pressure_pa = weather["main"]["pressure"] * 100
                                  pressure_inhg = pressure_pa / 3386.39
                                  summary = f"{temp_f}°{humidity}%  {pressure_inhg:.2f} inHg"
                              else:
                                  summary = "Weather: Unavailable"
                              w, _ = draw.textsize(summary, font=FontMid)
                              draw.text(((disp.width - w) // 2, y), summary, fill=COLOR_WEATHER, font=FontMid)
                              y += spacing
                          
                              wpad_header = "WPAD   HEALTH"
                              w, _ = draw.textsize(wpad_header, font=FontSmall)
                              draw.text(((disp.width - w) // 2, y), wpad_header, fill=COLOR_HEADER, font=FontSmall)
                              y += spacing
                          
                              cpu = int(psutil.cpu_percent(interval=None))
                              draw.text((2, y), f"CPU: {cpu}%", fill=COLOR_CPU, font=FontLarge)
                              y += spacing
                          
                              temp = int(gain.GET_Temp())
                              draw.text((2, y), f"Temp: {temp}℃", fill=COLOR_TEMP, font=FontLarge)
                              y += spacing
                          
                              ram = int(psutil.virtual_memory().percent)
                              draw.text((2, y), f"RAM: {ram}%", fill=COLOR_RAM, font=FontLarge)
                              y += spacing
                          
                              disk = int(psutil.disk_usage('/').percent)
                              draw.text((2, y), f"Disk: {disk}%", fill=COLOR_DISK, font=FontLarge)
                              y += spacing
                          
                              # Network speed calculations
                              counters = psutil.net_io_counters()
                              new_sent = counters.bytes_sent
                              new_recv = counters.bytes_recv
                          
                              sent_diff = new_sent - last_bytes[0]
                              recv_diff = new_recv - last_bytes[1]
                          
                              # Time passed between measurements (assumed 1 sec loop; adjust if different)
                              interval = 1.0  # seconds
                          
                              # Avoid negative values and division by zero
                              if sent_diff < 0:
                                  sent_diff = 0
                              if recv_diff < 0:
                                  recv_diff = 0
                          
                              up = (sent_diff * 8) / (1_000_000 * interval)  # Mbps
                              dn = (recv_diff * 8) / (1_000_000 * interval)  # Mbps
                          
                              draw.text((2, y), f"UP: {up:.2f} Mbps", fill=COLOR_UP, font=FontLarge)
                              y += spacing
                              draw.text((2, y), f"DN: {dn:.2f} Mbps", fill=COLOR_DN, font=FontLarge)
                          
                              return new_sent, new_recv
                          
                          def draw_wpad_status(draw):
                              draw.text((2, 0), "WPAD Users:", fill=COLOR_WPAD_HEADER, font=FontMid)
                              try:
                                  users = subprocess.check_output(['who']).decode().splitlines()
                                  usernames = sorted(set(line.split()[0] for line in users if line.strip()))
                                  display = ', '.join(usernames) if usernames else "None"
                              except Exception as e:
                                  display = f"Error: {e}"
                          
                              color = COLOR_WPAD_USERS if display != "None" else COLOR_WPAD_NONE
                              draw.text((2, 20), display, fill=color, font=FontMid)
                          
                          # --- Weather page drawing functions ---
                          
                          def draw_weather_top(draw):
                              now = time.localtime()
                              time_t = time.strftime("%H:%M:%S", now)
                              date_t = time.strftime("%A, %b %d %Y", now)
                              ip = gain.GET_IP()
                              ipv6 = get_ipv6_address()
                          
                              draw.text((2, 0), "Detailed Weather", fill=COLOR_HEADER, font=FontSmall)
                              draw.text((2, 18), f"IP: {ip}", fill=COLOR_IP, font=FontTiny)
                              draw.text((2, 30), f"IPv6: {ipv6}", fill=COLOR_IPV6, font=FontTiny)
                              draw.text((2, 44), date_t, fill=COLOR_DATE, font=FontTiny)
                              draw.text((2, 58), time_t, fill=COLOR_TIME, font=FontTiny)
                          
                          def draw_weather_middle(draw):
                              weather = get_detailed_weather()
                              y = 0
                              spacing = 20
                          
                              if weather:
                                  main = weather["main"]
                                  wind = weather.get("wind", {})
                                  weather_desc = weather["weather"][0]["description"].capitalize()
                          
                                  temp = main["temp"]
                                  feels_like = main.get("feels_like", temp)
                                  humidity = main["humidity"]
                                  pressure_pa = main["pressure"] * 100
                                  pressure_inhg = pressure_pa / 3386.39
                                  wind_speed = wind.get("speed", 0)
                                  wind_deg = wind.get("deg", 0)
                          
                                  draw.text((2, y), f"Temp: {temp:.1f}°F", fill=COLOR_WEATHER, font=FontMid)
                                  y += spacing
                                  draw.text((2, y), f"Feels Like: {feels_like:.1f}°F", fill=COLOR_WEATHER, font=FontMid)
                                  y += spacing
                                  draw.text((2, y), f"Humidity: {humidity}%", fill=COLOR_WEATHER, font=FontMid)
                                  y += spacing
                                  draw.text((2, y), f"Pressure: {pressure_inhg:.2f} inHg", fill=COLOR_WEATHER, font=FontMid)
                                  y += spacing
                                  draw.text((2, y), f"Wind: {wind_speed} mph @ {wind_deg}°", fill=COLOR_WEATHER, font=FontMid)
                                  y += spacing
                                  draw.text((2, y), f"Condition: {weather_desc}", fill=COLOR_WEATHER, font=FontMid)
                              else:
                                  draw.text((2, y), "Weather data unavailable", fill=COLOR_WPAD_NONE, font=FontMid)
                          
                          def draw_weather_bottom(image):
                              draw = ImageDraw.Draw(image)
                              weather = get_detailed_weather()
                              if weather and "weather" in weather and len(weather["weather"]) > 0:
                                  condition = weather["weather"][0]["description"].capitalize()
                                  if weather_cache["icon_image"]:
                                      try:
                                          icon_img = weather_cache["icon_image"].resize((64, 64), Image.ANTIALIAS)
                                          x = (image.width - icon_img.width) // 2
                                          y = (image.height - icon_img.height) // 2 - 10
                                          image.paste(icon_img, (x, y), icon_img)
                          
                                          font = FontTiny
                                          w, h = draw.textsize(condition, font=font)
                                          text_x = (image.width - w) // 2
                                          text_y = y + icon_img.height + 2
                                          draw.text((text_x, text_y), condition, fill=COLOR_WEATHER, font=font)
                                      except Exception:
                                          draw.text((10, 10), condition, fill=COLOR_WPAD_NONE, font=FontMid)
                                  else:
                                      draw.text((10, 10), condition, fill=COLOR_WPAD_NONE, font=FontMid)
                              else:
                                  draw.text((10, 10), "No icon data", fill=COLOR_WPAD_NONE, font=FontMid)
                          
                          # --- Moon phase page ---
                          
                          def draw_moon_top(draw):
                              now = time.localtime()
                              time_t = time.strftime("%H:%M:%S", now)
                              date_t = time.strftime("%A, %b %d %Y", now)
                              ip = gain.GET_IP()
                              ipv6 = get_ipv6_address()
                          
                              draw.text((2, 0), "Moon Phase", fill=COLOR_HEADER, font=FontSmall)
                              draw.text((2, 18), f"IP: {ip}", fill=COLOR_IP, font=FontTiny)
                              draw.text((2, 30), f"IPv6: {ipv6}", fill=COLOR_IPV6, font=FontTiny)
                              draw.text((2, 44), date_t, fill=COLOR_DATE, font=FontTiny)
                              draw.text((2, 58), time_t, fill=COLOR_TIME, font=FontTiny)
                          
                          def draw_moon_middle(image):
                              draw = ImageDraw.Draw(image)
                              phase_name, illumination = get_moon_phase()
                              # Draw phase name at the top with spacing
                              text_y = 10
                              w, h = draw.textsize(phase_name, font=FontLarge)
                              draw.text(((disp.width - w) // 2, text_y), phase_name, fill=COLOR_WEATHER, font=FontLarge)
                              # Calculate available space for moon below the text
                              top_of_moon_area = text_y + h + 10  # 10px padding below text
                              bottom_of_moon_area = disp.height - 10  # 10px padding from bottom
                              available_height = bottom_of_moon_area - top_of_moon_area
                              radius = min(available_height, disp.width) // 2  # keep it a circle
                              # Calculate center Y within the moon area
                              center_y = top_of_moon_area + available_height // 2
                              # Center X remains same
                              center_x = disp.width // 2
                              # Draw the moon
                              draw_moon_icon(image, center_x, center_y, radius, illumination)
                          
                          def draw_moon_bottom(draw):
                              draw.text((2, 0), "Lunar illumination %", fill=COLOR_WPAD_HEADER, font=FontSmall)
                              phase_name, illumination = get_moon_phase()
                              draw.text((2, 18), f"{illumination}%", fill=COLOR_WEATHER, font=FontLarge)
                          
                          # --- Doppler radar page ---
                          def draw_doppler_top(draw):
                              now = time.localtime()
                              date_str = time.strftime("%A, %b %d %Y", now)
                              time_str = time.strftime("%H:%M:%S", now)
                              ip = gain.GET_IP()
                              ipv6 = get_ipv6_address()
                          
                              draw.text((2, 0), "Doppler Radar", fill=COLOR_HEADER, font=FontSmall)
                              draw.text((2, 18), f"IP: {ip}", fill=COLOR_IP, font=FontTiny)
                              draw.text((2, 30), f"IPv6: {ipv6}", fill=COLOR_IPV6, font=FontTiny)
                              draw.text((2, 44), date_str, fill=COLOR_DATE, font=FontTiny)
                              draw.text((2, 58), time_str, fill=COLOR_TIME, font=FontTiny)
                          
                          #---GOES page-------
                          def draw_doppler_middle(image):
                              global doppler_frame_index
                              frames = get_doppler_image()
                              if frames:
                                  frame = frames[doppler_frame_index % len(frames)]
                                  resized = frame.resize((image.width, image.height), Image.ANTIALIAS)
                                  image.paste(resized, (0, 0), resized)
                                  doppler_frame_index += 1
                              else:
                                  draw = ImageDraw.Draw(image)
                                  msg = "Radar Image\nUnavailable"
                                  w, h = draw.textsize(msg, font=FontLarge)
                                  draw.text(((image.width - w) // 2, (image.height - h) // 2), msg, fill=COLOR_WPAD_NONE, font=FontLarge)
                          
                          def draw_doppler_bottom(draw):
                              draw.text((2, 2), "Radar updated every 5 min", fill=COLOR_WPAD_NONE, font=FontTiny)
                          
                          def draw_goes_top(draw, label="GOES-18 Product"):
                              now = time.localtime()
                              date_str = time.strftime("%A, %b %d %Y", now)
                              time_str = time.strftime("%H:%M:%S", now)
                              ip = gain.GET_IP()
                              ipv6 = get_ipv6_address()
                          
                              draw.text((2, 0), label, fill=COLOR_HEADER, font=FontSmall)
                              draw.text((2, 18), f"IP: {ip}", fill=COLOR_IP, font=FontTiny)
                              draw.text((2, 30), f"IPv6: {ipv6}", fill=COLOR_IPV6, font=FontTiny)
                              draw.text((2, 44), date_str, fill=COLOR_DATE, font=FontTiny)
                              draw.text((2, 58), time_str, fill=COLOR_TIME, font=FontTiny)
                          
                          def draw_goes_middle(image, index):
                              global goes_dynamic_frame_index
                              frames = get_goes_gif(index)
                              if frames:
                                  frame = frames[goes_dynamic_frame_index[index] % len(frames)]
                                  resized = frame.resize((image.width, image.height), Image.ANTIALIAS)
                                  image.paste(resized, (0, 0), resized)
                                  goes_dynamic_frame_index[index] += 1
                              else:
                                  draw = ImageDraw.Draw(image)
                                  msg = "GOES GIF\nUnavailable"
                                  w, h = draw.textsize(msg, font=FontLarge)
                                  draw.text(((image.width - w) // 2, (image.height - h) // 2), msg, fill=COLOR_WPAD_NONE, font=FontLarge)
                          
                          def draw_goes_bottom(draw, product_name=None):
                              # Compose the full text
                              base_text = "Updated every 5 min"
                              if product_name:
                                  base_text = f"{product_name} - {base_text}"
                          
                              # Max width available for text on screen
                              max_width = disp_1.width - 4  # some margin (2px left and right)
                          
                              # Font to use
                              font = FontTiny
                          
                              # Simple word wrap helper
                              def wrap_text(text, font, max_width):
                                  words = text.split()
                                  lines = []
                                  current_line = ""
                                  for word in words:
                                      test_line = f"{current_line} {word}".strip()
                                      w, h = draw.textsize(test_line, font=font)
                                      if w <= max_width:
                                          current_line = test_line
                                      else:
                                          if current_line:
                                              lines.append(current_line)
                                          current_line = word
                                  if current_line:
                                      lines.append(current_line)
                                  return lines
                          
                              lines = wrap_text(base_text, font, max_width)
                              # Draw each line with vertical spacing
                              y = 2  # Start a bit down from top
                              line_spacing = 14  # pixels between lines
                          
                              for line in lines:
                                  draw.text((2, y), line, fill=COLOR_WPAD_NONE, font=font)
                                  y += line_spacing
                          
                          # --- Main loop ---
                          last_bytes_sent = 0
                          last_bytes_recv = 0
                          last_page = None  # Track the previous page to detect transitions
                          
                          try:
                              while True:
                                  with state_lock:
                                      page = current_page  # safely snapshot current page under lock
                          
                                  # Reset GOES animation frame index if just switched from Doppler (page 3)
                                  if (4 <= page < 4 + len(GOES_PRODUCTS)) and (last_page == 3):
                                      goes_index = page - 4
                                      goes_dynamic_frame_index[goes_index] = 0
                          
                                  if page == 0:
                                      image0 = Image.new("RGB", (disp_0.width, disp_0.height), (0, 0, 0))
                                      draw0 = ImageDraw.Draw(image0)
                                      draw_top_screen(draw0)
                                      disp_0.ShowImage(image0)
                          
                                      image = Image.new("RGB", (disp.width, disp.height), (0, 0, 0))
                                      draw = ImageDraw.Draw(image)
                                      last_bytes_sent, last_bytes_recv = draw_middle_screen(draw, (last_bytes_sent, last_bytes_recv))
                                      disp.ShowImage(image)
                          
                                      image1 = Image.new("RGB", (disp_1.width, disp_1.height), (0, 0, 0))
                                      draw1 = ImageDraw.Draw(image1)
                                      draw_wpad_status(draw1)
                                      disp_1.ShowImage(image1)
                          
                                  elif page == 1:
                                      image0 = Image.new("RGB", (disp_0.width, disp_0.height), (0, 0, 0))
                                      draw0 = ImageDraw.Draw(image0)
                                      draw_weather_top(draw0)
                                      disp_0.ShowImage(image0)
                          
                                      image = Image.new("RGB", (disp.width, disp.height), (0, 0, 0))
                                      draw = ImageDraw.Draw(image)
                                      draw_weather_middle(draw)
                                      disp.ShowImage(image)
                          
                                      image1 = Image.new("RGB", (disp_1.width, disp_1.height), (0, 0, 0))
                                      # Pass image1 (not draw) to draw_weather_bottom if it requires the image object
                                      draw_weather_bottom(image1)
                                      disp_1.ShowImage(image1)
                          
                                  elif page == 2:
                                      phase_name, illumination = get_moon_phase()
                                      # Create the display image for page 2
                                      image0 = Image.new("RGB", (disp_0.width, disp_0.height), (0, 0, 0))
                                      draw0 = ImageDraw.Draw(image0)
                                      draw_moon_top(draw0)  # top part uses draw
                                      disp_0.ShowImage(image0)
                          
                                      # Main display
                                      image = Image.new("RGB", (disp.width, disp.height), (0, 0, 0))
                                      # Pass the image (not draw) because your fixed function expects the image
                                      draw_moon_middle(image)
                                      disp.ShowImage(image)
                          
                                      image1 = Image.new("RGB", (disp_1.width, disp_1.height), (0, 0, 0))
                                      draw1 = ImageDraw.Draw(image1)
                                      draw_moon_bottom(draw1)  # bottom part uses draw
                                      disp_1.ShowImage(image1)
                          
                                  elif page == 3:
                                      if time.time() - last_doppler_fetch > 300 or not doppler_frames:
                                          doppler_frames = get_doppler_image()
                                          last_doppler_fetch = time.time()
                                          doppler_frame_index = 0
                          
                                      image0 = Image.new("RGB", (disp_0.width, disp_0.height), (0, 0, 0))
                                      draw0 = ImageDraw.Draw(image0)
                                      draw_doppler_top(draw0)
                                      disp_0.ShowImage(image0)
                          
                                      image = Image.new("RGBA", (disp.width, disp.height), (0, 0, 0, 0))
                                      if doppler_frames:
                                          frame = doppler_frames[doppler_frame_index % len(doppler_frames)]
                                          image.paste(frame, (0, 0), frame)
                                          doppler_frame_index += 1
                                      disp.ShowImage(image.convert("RGB"))
                          
                                      image1 = Image.new("RGB", (disp_1.width, disp_1.height), (0, 0, 0))
                                      draw1 = ImageDraw.Draw(image1)
                                      draw_doppler_bottom(draw1)
                                      disp_1.ShowImage(image1)
                          
                                  elif 4 <= page < 4 + len(GOES_PRODUCTS):
                                      goes_index = page - 4
                                      now = time.time()
                                      cache = goes_dynamic_cache[goes_index]
                          
                                      if "last_updated" not in cache or (now - cache["last_updated"]) > 300:
                                          frames = get_goes_gif(goes_index)
                                          if frames:
                                              cache["image"] = frames
                                              cache["last_updated"] = now
                                              # We do NOT reset goes_dynamic_frame_index here; it's reset on page transition
                          
                                      image0 = Image.new("RGB", (disp_0.width, disp_0.height), (0, 0, 0))
                                      draw0 = ImageDraw.Draw(image0)
                                      draw_goes_top(draw0, GOES_PRODUCTS[goes_index]["name"])
                                      disp_0.ShowImage(image0)
                          
                                      image = Image.new("RGBA", (disp.width, disp.height), (0, 0, 0, 0))
                                      frames = cache.get("image")
                                      if frames:
                                          frame = frames[goes_dynamic_frame_index[goes_index] % len(frames)]
                                          image.paste(frame, (0, 0), frame)
                                          goes_dynamic_frame_index[goes_index] += 1
                                      else:
                                          draw = ImageDraw.Draw(image)
                                          msg = "GOES GIF\nUnavailable"
                                          w, h = draw.textsize(msg, font=FontLarge)
                                          draw.text(((image.width - w) // 2, (image.height - h) // 2), msg, fill=COLOR_WPAD_NONE, font=FontLarge)
                          
                                      disp.ShowImage(image.convert("RGB"))
                          
                                      image1 = Image.new("RGB", (disp_1.width, disp_1.height), (0, 0, 0))
                                      draw1 = ImageDraw.Draw(image1)
                                      draw_goes_bottom(draw1, GOES_PRODUCTS[goes_index]["name"])
                                      disp_1.ShowImage(image1)
                          
                                  # Sleep delays based on page type
                                  if page in [3] + list(range(4, 4 + len(GOES_PRODUCTS))):
                                      time.sleep(FRAME_DELAYS["animation"])
                                  else:
                                      time.sleep(FRAME_DELAYS["default"])
                          
                                  last_page = page  # Update last_page after processing
                          
                          except KeyboardInterrupt:
                              print("Exiting...")
                              disp_0.clear()
                              disp_1.clear()
                              disp.clear()
                              sys.exit()
                          

                          use these images
                          https://commons.wikimedia.org/wiki/Category:Lunar_phases

                          Fase 1-16 need to be used

                          1 Reply Last reply Reply Quote 0
                          • JonathanLeeJ
                            JonathanLee
                            last edited by JonathanLee

                            This is a better WPAD file

                            server.modules = (
                                "mod_access",
                                "mod_staticfile",
                                "mod_expire",
                                "mod_setenv"
                            )
                            
                            server.document-root        = "/var/www/html"
                            server.errorlog             = "/var/log/lighttpd/error.log"
                            server.pid-file             = "/run/lighttpd.pid"
                            server.username             = "www-data"
                            server.groupname            = "www-data"
                            server.port                 = 80
                            server.bind                 = "192.168.1.6"
                            server.tag                  = ""
                            server.range-requests       = "disable"
                            server.max-connections      = 10
                            connect-timeout             = 2
                            server.max-keep-alive-idle  = 2
                            server.max-keep-alive-requests = 1
                            server.max-read-idle        = 2
                            server.max-write-idle       = 2
                            dir-listing = "disable"
                            
                            $HTTP["request-method"] =~ "^(TRACE|TRACK)$" {
                              url.access-deny = ( "" )
                            }
                            
                            # Cache WPAD and proxy PAC files for 1 day (good practice)
                            expire.url = (
                              "/wpad.dat" => "access plus 1 day",
                              "/proxy.pac" => "access plus 1 day"
                            )
                            
                            # Disable access logs to reduce SD card wear (optional)
                            accesslog = ""
                            
                            $HTTP["url"] =~ "^/(wpad\.dat|proxy\.pac)$" {
                              setenv.add-response-header = (
                                "X-Content-Type-Options"        => "nosniff",
                                "X-Frame-Options"               => "DENY",
                                "Content-Security-Policy"       => "default-src 'none';",
                                "Cache-Control"                 => "public, max-age=86400",
                                "Referrer-Policy"               => "no-referrer",
                                "X-Download-Options"            => "noopen",
                                "X-Permitted-Cross-Domain-Policies" => "none"
                              )
                            
                              # Allow only GET and HEAD methods
                              $HTTP["request-method"] !~ "^(GET|HEAD)$" {
                                url.access-deny = ( "" )
                              }
                            
                              # Restrict access by IP subnets
                              $HTTP["remoteip"] == "192.168.1.0/27" { }
                              else $HTTP["remoteip"] == "2001:470:8052:a::/64" { }
                              else {
                                url.access-deny = ( "" )
                              }
                            }
                            
                            # Deny all other URL requests
                            $HTTP["url"] !~ "^/(wpad\.dat|proxy\.pac)$" {
                              url.access-deny = ( "" )
                            }
                            
                            # Strict URL parsing for security and consistency
                            server.http-parseopts = (
                              "header-strict"           => "enable",
                              "host-strict"             => "enable",
                              "host-normalize"          => "enable",
                              "url-normalize-unreserved"=> "enable",
                              "url-normalize-required"  => "enable",
                              "url-ctrls-reject"        => "enable",
                              "url-path-2f-decode"      => "disable",
                              "url-path-2f-reject"      => "enable",
                              "url-path-dotseg-remove"  => "disable",
                              "url-path-dotseg-reject"  => "enable",
                            )
                            
                            url.access-deny             = ( "~", ".inc" )
                            static-file.exclude-extensions = ( ".php", ".pl", ".fcgi" )
                            
                            # Add WPAD MIME type for correct browser handling
                            mimetype.assign = (
                              ".dat" => "application/x-ns-proxy-autoconfig",
                              ".pac" => "application/x-ns-proxy-autoconfig"
                            )
                            
                            1 Reply Last reply Reply Quote 0
                            • First post
                              Last post
                            Copyright 2025 Rubicon Communications LLC (Netgate). All rights reserved.