Captures everything needed to redeploy the two-display clock (hour on I2C 0x61, minute on I2C 0x63) on a fresh Pi: - Both systemd units (matrix0x61.service, matrix0x63.service) - Deployed Pimoroni script tree, including the local %I (12-hour) clock customization - Vendored upstream sources (ltp305-python, breakout-garden) so restore is fully offline-capable - Boot config snippet enabling I2C - install.sh that wires it all back up idempotently - Inventory doc cross-referencing every live-system path Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
219 lines
6.6 KiB
Python
Executable File
219 lines
6.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
import os
|
|
import time
|
|
import datetime
|
|
import glob
|
|
import logging
|
|
import sys
|
|
|
|
try:
|
|
import requests
|
|
import geocoder
|
|
import lxml
|
|
from bs4 import BeautifulSoup
|
|
from PIL import Image
|
|
from PIL import ImageFont
|
|
from PIL import ImageDraw
|
|
except ImportError:
|
|
print("""
|
|
This script requires several modules to run correctly.
|
|
Install with:
|
|
sudo pip install requests geocoder beautifulsoup4
|
|
sudo apt install python{v}-lxml python{v}-pil
|
|
""".format(v="" if sys.version_info.major == 2 else sys.version_info.major))
|
|
sys.exit(1)
|
|
|
|
import bme680
|
|
from luma.core.interface.serial import spi
|
|
from luma.core.error import DeviceNotFoundError
|
|
from luma.oled.device import sh1106
|
|
|
|
|
|
|
|
|
|
TEMPERATURE_UPDATE_INTERVAL = 0.1 # in seconds
|
|
|
|
# Default to Sheffield-on-Sea for location
|
|
CITY = "Sheffield"
|
|
COUNTRYCODE = "GB"
|
|
|
|
# Used to calibrate the sensor
|
|
TEMP_OFFSET = 0.0
|
|
|
|
|
|
logging.basicConfig(level=os.environ.get("LOGLEVEL", "WARNING"))
|
|
|
|
|
|
print("""This Pimoroni Breakout Garden example requires a
|
|
BME680 Environmental Sensor Breakout and a 1.12" OLED Breakout.
|
|
|
|
This example turns your Breakout Garden into a mini weather display
|
|
combining indoor temperature and pressure data with a weather icon
|
|
indicating the current local weather conditions.
|
|
|
|
Press Ctrl+C a couple times to exit.
|
|
""")
|
|
|
|
|
|
# Convert a city name and country code to latitude and longitude
|
|
def get_coords(address):
|
|
g = geocoder.arcgis(address)
|
|
coords = g.latlng
|
|
logging.info("Location coordinates: %s", coords)
|
|
return coords
|
|
|
|
|
|
# Query Dark Sky (https://darksky.net/) to scrape current weather data
|
|
def get_weather(coords):
|
|
weather = {}
|
|
try:
|
|
res = requests.get("https://darksky.net/forecast/{}/uk212/en".format(","
|
|
.join([str(c) for c in coords])))
|
|
if res.status_code == 200:
|
|
soup = BeautifulSoup(res.content, "lxml")
|
|
curr = soup.find("span", "currently")
|
|
if curr:
|
|
img_name = curr.img["alt"].split()[0]
|
|
logging.info("Weather summary: %s", img_name)
|
|
weather["summary"] = img_name
|
|
except requests.exceptions.RequestException as e:
|
|
logging.error("Could not get weather data from DarkSky: {}".format(e))
|
|
pass
|
|
|
|
return weather
|
|
|
|
|
|
# This maps the weather summary from Dark Sky
|
|
# to the appropriate weather icons
|
|
icon_map = {
|
|
"snow": ["snow", "sleet"],
|
|
"rain": ["rain"],
|
|
"cloud": ["fog", "cloudy", "partly-cloudy-day", "partly-cloudy-night"],
|
|
"sun": ["clear-day", "clear-night"],
|
|
"storm": [],
|
|
"wind": ["wind"]
|
|
}
|
|
|
|
# Pre-load icons into a dictionary with PIL
|
|
icons = {}
|
|
|
|
for icon in glob.glob("icons/*.png"):
|
|
icon_name = icon.split("/")[1].replace(".png", "")
|
|
icon_image = Image.open(icon)
|
|
icons[icon_name] = icon_image
|
|
|
|
|
|
location_string = "{city}, {countrycode}".format(city=CITY,
|
|
countrycode=COUNTRYCODE)
|
|
coords = get_coords(location_string)
|
|
|
|
|
|
def get_weather_icon(weather):
|
|
if weather:
|
|
summary = weather["summary"]
|
|
|
|
for icon in icon_map:
|
|
if summary in icon_map[icon]:
|
|
logging.info("Weather icon: %s", icon)
|
|
return icons[icon]
|
|
logging.error("Could not determine icon for weather")
|
|
return None
|
|
else:
|
|
logging.error("No weather information provided to get icon")
|
|
return None
|
|
|
|
|
|
# Get initial weather data for the given location
|
|
weather_icon = get_weather_icon(get_weather(coords))
|
|
|
|
# Set up OLED
|
|
oled = sh1106(spi(port=0, device=1, gpio_DC=9), rotate=2, height=128, width=128)
|
|
|
|
# Set up BME680 sensor
|
|
sensor = bme680.BME680()
|
|
|
|
sensor.set_humidity_oversample(bme680.OS_2X)
|
|
sensor.set_pressure_oversample(bme680.OS_4X)
|
|
sensor.set_temperature_oversample(bme680.OS_8X)
|
|
sensor.set_filter(bme680.FILTER_SIZE_3)
|
|
sensor.set_temp_offset(TEMP_OFFSET)
|
|
|
|
# Load fonts
|
|
rr_path = os.path.abspath(os.path.join(os.path.dirname(__file__), 'fonts',
|
|
'Roboto-Regular.ttf'))
|
|
rb_path = os.path.abspath(os.path.join(os.path.dirname(__file__), 'fonts',
|
|
'Roboto-Black.ttf'))
|
|
rr_24 = ImageFont.truetype(rr_path, 24)
|
|
rb_20 = ImageFont.truetype(rb_path, 20)
|
|
rr_12 = ImageFont.truetype(rr_path, 12)
|
|
|
|
# Fetch sensor dating first so that device settings take effect
|
|
sensor.get_sensor_data()
|
|
# Initial values
|
|
low_temp = sensor.data.temperature
|
|
high_temp = sensor.data.temperature
|
|
curr_date = datetime.date.today().day
|
|
|
|
last_checked = time.time()
|
|
|
|
# Main loop
|
|
while True:
|
|
# Limit calls to Dark Sky to 1 per minute
|
|
if time.time() - last_checked > 60:
|
|
weather_icon = get_weather_icon(get_weather(coords))
|
|
last_checked = time.time()
|
|
|
|
# Load in the background image
|
|
background = Image.open("images/weather.png").convert(oled.mode)
|
|
|
|
# Place the weather icon and draw the background
|
|
if weather_icon:
|
|
background.paste(weather_icon, (10, 46))
|
|
draw = ImageDraw.ImageDraw(background)
|
|
|
|
# Gets temp. and press. and keeps track of daily min and max temp
|
|
if sensor.get_sensor_data():
|
|
temp = sensor.data.temperature
|
|
press = sensor.data.pressure
|
|
if datetime.datetime.today().day == curr_date:
|
|
if temp < low_temp:
|
|
low_temp = temp
|
|
elif temp > high_temp:
|
|
high_temp = temp
|
|
else:
|
|
curr_date = datetime.datetime.today().day
|
|
low_temp = temp
|
|
high_temp = temp
|
|
|
|
# Write temp. and press. to image
|
|
draw.text((8, 22), "{0:4.0f}".format(press),
|
|
fill="white", font=rb_20)
|
|
draw.text((86, 12), u"{0:2.0f}°".format(temp),
|
|
fill="white", font=rb_20)
|
|
|
|
# Write min and max temp. to image
|
|
draw.text((80, 0), u"max: {0:2.0f}°".format(high_temp),
|
|
fill="white", font=rr_12)
|
|
draw.text((80, 110), u"min: {0:2.0f}°".format(low_temp),
|
|
fill="white", font=rr_12)
|
|
|
|
# Write the 24h time and blink the separator every second
|
|
if int(time.time()) % 2 == 0:
|
|
draw.text((4, 98), datetime.datetime.now().strftime("%H:%M"),
|
|
fill="white", font=rr_24)
|
|
else:
|
|
draw.text((4, 98), datetime.datetime.now().strftime("%H %M"),
|
|
fill="white", font=rr_24)
|
|
|
|
# These lines display the temp. on the thermometer image
|
|
draw.rectangle([(97, 43), (100, 86)], fill="black")
|
|
temp_offset = 86 - ((86 - 43) * ((temp - 20) / (32 - 20)))
|
|
draw.rectangle([(97, temp_offset), (100, 86)], fill="white")
|
|
|
|
# Display the completed image on the OLED
|
|
oled.display(background)
|
|
|
|
time.sleep(TEMPERATURE_UPDATE_INTERVAL)
|