commit 28d9dea3c4276329d54c742c0b7845f4dbdbceaa Author: Wiktor Przybylski Date: Wed Nov 20 23:09:14 2024 +0100 initial commit diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..bc3c6c7 --- /dev/null +++ b/.env.dist @@ -0,0 +1,5 @@ +OPENWEATHER_API_KEY=CHANGEME +LOCATION_ID=CHANGEME +FLIPDOT_API=http://127.0.0.1/ +FLIPDOT_SLOTS=15 +LUFTDATEN_CLOSEST_SENSOR=CHANGEME \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94136f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +trash/ +venv/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c26921f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.9-slim + +WORKDIR /app +COPY requirements.txt /app +RUN pip install --no-cache-dir -r requirements.txt + + +RUN apt-get update && apt-get install --assume-yes \ + cron \ + && rm -rf /var/lib/apt/lists/* + + COPY . /app + +RUN (echo "SHELL=/bin/bash" > /etc/cron.d/fetch_crl_cron) +RUN (echo "BASH_ENV=/app/.env" >> /etc/cron.d/fetch_crl_cron) +RUN (echo "* * * * * root /usr/local/bin/python3 /app/app.py >> /var/log/cron.log 2>&1" >> /etc/cron.d/fetch_crl_cron) + +RUN touch /var/log/cron.log + +CMD printenv > /etc/environment && cron && tail -f /var/log/cron.log \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c862205 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ + +![Logo](docs/flipdot.jpg) + + +# HSWroFlipdotWeather + +Proof of concept of Hackerspace Wrocław shitposting infotainment platform service, that is currently knocking straight to [PixelRaspi](https://github.com/domints/PixelRaspi) running on Flipdot Raspi Zero. + + + + + +## Environment Variables + +To run this project, you will need to add the following environment variables to your .env file + +`OPENWEATHER_API_KEY` +`LOCATION_ID` +`LUFTDATEN_CLOSEST_SENSOR` + +for communicating with openweather and luftdaten + +- `FLIPDOT_API` +- `FLIPDOT_SLOTS` - default for HSWro instance is 15 + +for your [PixelRaspi](https://github.com/domints/PixelRaspi) + + +there is also `.env.dist` file that you'll find useful that would have all of the above data. +## Deployment + +To deploy this project after tweaking `Dockerfile` to fine-tune crontab inside, run + +```bash + docker build -t flipdot-weather . +``` + +and then + +```bash + docker run -d --env-file .env flipdot-weather . +``` + + +## Roadmap + +- Now it shoots straight to REST api on the flipdot raspi, but plans are to throw the rendered boards into the MQTT stream, and write a interface subscribing on the MQTT stream and shooting requests to the flipdot, and also orchestrator, that would decide what is to be shown. Now it's ready to do a harsh cron driven orchestration diff --git a/app.py b/app.py new file mode 100644 index 0000000..8ca0be9 --- /dev/null +++ b/app.py @@ -0,0 +1,187 @@ +import requests +from datetime import datetime +import os +import logging + +logging.basicConfig(format='%(asctime)s %(levelname)s:%(message)s',level=logging.DEBUG) +logger = logging.getLogger(__name__) + +# OpenWeather API settings +OPENWEATHER_API_KEY = os.getenv('OPENWEATHER_API_KEY') +LOCATION_ID = os.getenv('LOCATION_ID', '3081368') +BASE_URL = "https://api.openweathermap.org/data/2.5/" +FLIPDOT_API = os.getenv('FLIPDOT_API', 'http://127.0.0.1/') +FLIPDOT_SLOTS = int(os.getenv('FLIPDOT_SLOTS', 15)) +LUFTDATEN_CLOSEST_SENSOR = os.getenv('LUFTDATEN_CLOSEST_SENSOR', '84121') +DAYS = os.getenv('DAYS', 3) + +# Icon mapping from API's "icon" field +ICON_MAPPING = { + "01d": "sun", + "01n": "moon", + "02d": "cloud_sun", + "02n": "cloud_moon", + "03d": "cloud", + "03n": "cloud", + "04d": "clouds", + "04n": "clouds", + "09d": "rain1", + "09n": "rain1", + "10d": "rain2", + "10n": "rain2", + "11d": "lightning", + "11n": "lightning", + "13d": "snow", + "13n": "snow", + "50d": "mist", + "50n": "mist", + "unknown": "wtf" +} + +def evenly_distribute(data, target_slots): ## TODO - move to utils + num_elements = len(data) + + if num_elements == 0: + raise ValueError("Data array cannot be empty.") + + base_repeats = target_slots // num_elements + remainder = target_slots % num_elements + + if remainder > 0: + max_repeats = base_repeats + else: + max_repeats = base_repeats + + result = [] + for item in data: + result.extend([item] * max_repeats) + + return result + + +def clean(): + send_post('actions/clear_pages') + +def send_post(endpoint,payload = {}, data = {}): + headers = { + 'accept': 'application/json', + } + uri = (FLIPDOT_API+endpoint).replace('\r', '').replace('\n', '') + print(uri) + requests.post( uri, headers=headers, json = payload, params=data ) +# Helper function to map API's icon to your icon set +def get_icon(api_icon): + return ICON_MAPPING.get(api_icon, ICON_MAPPING["unknown"]) + +def fetch_luftdaten_data(): + url = "https://data.sensor.community/airrohr/v1/sensor/{}/".format(LUFTDATEN_CLOSEST_SENSOR).replace('\r', '').replace('\n', '') + response = requests.get(url) + return response.json() + +# Fetch current weather +def fetch_current_weather(): + url = "{}weather?id={}&appid={}&units=metric".format(BASE_URL, LOCATION_ID, OPENWEATHER_API_KEY).replace('\r', '').replace('\n', '') + response = requests.get(url) + return response.json() + +# Fetch hourly forecast +def fetch_hourly_forecast(): + url = "{}forecast?id={}&appid={}&units=metric".format(BASE_URL, LOCATION_ID, OPENWEATHER_API_KEY).replace('\r', '').replace('\n', '') + response = requests.get(url) + return response.json() + + +def create_payload(icon, text1="", text2=None, font="(5) LITERY 112X17", invert=False, auto_break=False, align="center"): + result = { + "addition": { + "addition_type": "icon", + "invert": invert, + "icon": icon + }, + "lines": [] + } + if text1: + result["lines"].append({ + "text": text1, + "font": font, + "invert": invert, + "auto_break": auto_break, + "align": "center" + }) + if text2: + result["lines"].append({ + "text": text2, + "font": font, + "invert": invert, + "auto_break": auto_break, + "align": "center" + }) + return result + +# Generate payload for the flipdot display +def generate_payload(current_weather, forecast): + payloads = [] + + # Current weather payload + temp = round(current_weather["main"]["temp"]) + weather_icon = current_weather["weather"][0]["icon"] + weather_main = current_weather["weather"][0]["main"] + wind_speed = round(current_weather["wind"]["speed"] * 3.6) # Convert m/s to km/h + wind_dir = current_weather["wind"]["deg"] + pm25 = fetch_luftdaten_data()[0]['sensordatavalues'][0]['value'] + pm10 = fetch_luftdaten_data()[0]['sensordatavalues'][1]['value'] + + if "rain" in current_weather.keys(): + rain = current_weather["rain"]["1h"] + else: + rain = 0 + + if "snow" in current_weather.keys(): + snow = current_weather["snow"]["1h"] + else: + snow = 0 + + payloads.append(create_payload(get_icon(weather_icon), f"NOW: {temp} st.C - {weather_main}", f"WIND: {wind_speed}KM/H {wind_dir}°")) + payloads.append(create_payload("mist", f"PM2.5: {pm25} ug/m3", f"PM10: {pm10} ug/m3")) + + if rain > 0: + payloads.append(create_payload("rain2", f"NOW: RAIN: {rain} mm", "IN 3H")) + if snow > 0: + payloads.append(create_payload("snow", f"NOW: SNOW: {snow} mm", "IN 3H")) + + payloads.append(create_payload('mist', f"PM2.5: {pm25} ug/m3", f"PM10: {pm10} ug/m3")) + + # Hourly forecast payloads + for entry in forecast["list"][:DAYS]: + dt = datetime.fromtimestamp(entry["dt"]).strftime("%H:%M") + temp = round(entry["main"]["temp"]) + weather_icon = entry["weather"][0]["icon"] + weather_main = entry["weather"][0]["main"] + wind_speed = round(entry["wind"]["speed"] * 3.6) + wind_dir = entry["wind"]["deg"] + rain = entry['rain']['3h'] if 'rain' in entry.keys() else 0 + snow = entry['snow']['3h'] if 'snow' in entry.keys() else 0 + + payloads.append(create_payload(get_icon(weather_icon), f"{dt}: {temp} st.C - {weather_main}", f"WIND: {wind_speed}KM/H {wind_dir}°")) + + if rain > 0: + payloads.append(create_payload("rain2", f"{dt}: RAIN: {rain} mm", "IN 3H")) + + if snow > 0: + payloads.append(create_payload("snow", f"{dt}: SNOW: {snow} mm", "IN 3H")) + + return evenly_distribute(payloads, FLIPDOT_SLOTS) + +# Main function +def main(): + current_weather = fetch_current_weather() + forecast = fetch_hourly_forecast() + payloads = generate_payload(current_weather, forecast) + clean() + for i, payload in enumerate(payloads): + logger.debug(payload) + logger.info(f"Sending frame {i+1} of {len(payloads)} - text: {payload['lines'][0]['text']}") + send_post('display/complex', payload=payload,data={'page':i}) + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e4954fc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +requests +urllib3 +python-dotenv \ No newline at end of file