Inital Commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
images/*
|
||||||
|
!images/.dontDelete
|
||||||
|
ref
|
||||||
|
ref/*
|
||||||
|
config.json
|
||||||
2
README.md
Normal file
2
README.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# DiaShower
|
||||||
|
A chromecast ambient mode like Diashow
|
||||||
12
config.json.example
Normal file
12
config.json.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"PORT":8000,
|
||||||
|
"IMAGE_DIR":"images",
|
||||||
|
"WeatherProvider":"HomeAssistant or MetNO",
|
||||||
|
"METNO_LAT":"MetNO Weather GPS Latitude",
|
||||||
|
"METNO_LON":"MetNO Weather GPS longitude",
|
||||||
|
"HA_URL": "HomeAssistant URL",
|
||||||
|
"HA_TOKEN": "HomeAssistant Long-lived acces token",
|
||||||
|
"HA_WEATHER_ENTITY": "HomeAssistant Weather entity",
|
||||||
|
"PhotonAPI":"URL of komoot photon api",
|
||||||
|
"PhotonAPI_TOKEN":"X-Api-Key for api"
|
||||||
|
}
|
||||||
0
images/.dontDelete
Normal file
0
images/.dontDelete
Normal file
268
server.py
Normal file
268
server.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import http.server
|
||||||
|
import socketserver
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
from PIL import Image
|
||||||
|
from PIL.ExifTags import TAGS
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# --
|
||||||
|
# Configuration
|
||||||
|
# --
|
||||||
|
with open("config.json") as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
|
||||||
|
#--
|
||||||
|
# Web Router
|
||||||
|
#--
|
||||||
|
class SlideshowHandler(http.server.SimpleHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/":
|
||||||
|
self.send_slideshow_page()
|
||||||
|
elif self.path == "/images.json":
|
||||||
|
self.send_image_list()
|
||||||
|
elif self.path == "/weather":
|
||||||
|
if cfg['WeatherProvider'] == "HomeAssistant":
|
||||||
|
self.get_HASSweather()
|
||||||
|
else:
|
||||||
|
self.get_METNOweather()
|
||||||
|
elif self.path.startswith("/exif"):
|
||||||
|
self.send_exif_data()
|
||||||
|
else:
|
||||||
|
super().do_GET()
|
||||||
|
|
||||||
|
#--
|
||||||
|
# Main Page
|
||||||
|
#--
|
||||||
|
def send_slideshow_page(self):
|
||||||
|
html_path = "show.html"
|
||||||
|
|
||||||
|
if os.path.exists(html_path):
|
||||||
|
with open(html_path, "rb") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-type", "text/html")
|
||||||
|
self.send_header("Content-length", len(content))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(content)
|
||||||
|
|
||||||
|
#--
|
||||||
|
# Image list
|
||||||
|
#--
|
||||||
|
def send_image_list(self):
|
||||||
|
files = os.listdir(cfg['IMAGE_DIR'])
|
||||||
|
images = [
|
||||||
|
f"/{cfg['IMAGE_DIR']}/{f}"
|
||||||
|
for f in files
|
||||||
|
if f.lower().endswith((".jpg", ".jpeg", ".png", ".gif", ".webp"))
|
||||||
|
]
|
||||||
|
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(images).encode("utf-8"))
|
||||||
|
print(self)
|
||||||
|
#--
|
||||||
|
# Weather - From HASS
|
||||||
|
#--
|
||||||
|
def get_HASSweather(self):
|
||||||
|
try:
|
||||||
|
url = f"{cfg['HA_URL']}/api/states/{cfg['HA_WEATHER_ENTITY']}"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {cfg['HA_TOKEN']}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
ha_response = requests.get(url, headers=headers, timeout=5)
|
||||||
|
ha_response.raise_for_status()
|
||||||
|
data = ha_response.json()
|
||||||
|
|
||||||
|
icon_map = {
|
||||||
|
"sunny": "clearsky_day",
|
||||||
|
"clear-night": "clearsky_night",
|
||||||
|
"cloudy": "cloudy",
|
||||||
|
"partlycloudy": "fair_day",
|
||||||
|
"rainy": "rain",
|
||||||
|
"pouring": "heavyrain",
|
||||||
|
"lightning": "lightrainandthunder",
|
||||||
|
"lightning-rainy": "rainandthunder",
|
||||||
|
"snowy": "snow",
|
||||||
|
"snowy-rainy": "sleet",
|
||||||
|
"fog": "fog"
|
||||||
|
}
|
||||||
|
|
||||||
|
symbol = icon_map.get(data.get("state"), "cloudy")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
#"state": data.get("state"),
|
||||||
|
"temperature": data["attributes"].get("temperature"),
|
||||||
|
"symbol": 'https://raw.githubusercontent.com/metno/weathericons/refs/heads/main/weather/svg/'+ symbol + '.svg'
|
||||||
|
#"humidity": data["attributes"].get("humidity"),
|
||||||
|
#"pressure": data["attributes"].get("pressure"),
|
||||||
|
#"wind_speed": data["attributes"].get("wind_speed"),
|
||||||
|
#"forecast": data["attributes"].get("forecast"),
|
||||||
|
}
|
||||||
|
|
||||||
|
self._send_json(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._send_json({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
#--
|
||||||
|
# Weather - From Net.no
|
||||||
|
#--
|
||||||
|
def get_METNOweather(self):
|
||||||
|
try:
|
||||||
|
url = f"https://api.met.no/weatherapi/locationforecast/2.0/compact?lat={cfg['METNO_LAT']}&lon={cfg['METNO_LON']}"
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "AmbientDisplay/1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
metno_response = requests.get(url, headers=headers, timeout=5)
|
||||||
|
metno_response.raise_for_status()
|
||||||
|
data = metno_response.json()
|
||||||
|
result = {
|
||||||
|
"temperature": data.get("properties", {}).get("timeseries", [{}])[0].get("data", {}).get("instant", {}).get("details", {}).get("air_temperature"),
|
||||||
|
"symbol": 'https://raw.githubusercontent.com/metno/weathericons/refs/heads/main/weather/svg/'+ data.get("properties", {}).get("timeseries", [{}])[0].get("data", {}).get("next_1_hours", {}).get("summary", {}).get("symbol_code") + '.svg'
|
||||||
|
}
|
||||||
|
|
||||||
|
self._send_json(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._send_json({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
#--
|
||||||
|
# Weather - Json answer
|
||||||
|
#--
|
||||||
|
def _send_json(self, obj, status=200):
|
||||||
|
payload = json.dumps(obj).encode("utf-8")
|
||||||
|
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(payload)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(payload)
|
||||||
|
#--
|
||||||
|
# Image info
|
||||||
|
#--
|
||||||
|
def send_exif_data(self):
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
params = urllib.parse.parse_qs(parsed.query)
|
||||||
|
filename = params.get("file", [None])[0]
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
self.send_error(400, "Missing ?file= parameter")
|
||||||
|
return
|
||||||
|
|
||||||
|
filepath = filename.lstrip("/")
|
||||||
|
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
self.send_error(404, "File not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
capture_date = self.get_capture_date(filepath)
|
||||||
|
GeoData = self.get_GeoData(filepath)
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"capture_date": capture_date,
|
||||||
|
"geo_data": GeoData
|
||||||
|
}
|
||||||
|
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(response).encode("utf-8"))
|
||||||
|
|
||||||
|
#--
|
||||||
|
# Image info -- Date
|
||||||
|
#--
|
||||||
|
def get_capture_date(self, path):
|
||||||
|
try:
|
||||||
|
img = Image.open(path)
|
||||||
|
exif = img._getexif()
|
||||||
|
if not exif:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for tag_id, value in exif.items():
|
||||||
|
tag = TAGS.get(tag_id, tag_id)
|
||||||
|
if tag in ("DateTimeOriginal", "DateTime"):
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(value, "%Y:%m:%d %H:%M:%S")
|
||||||
|
return dt.isoformat()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
#--
|
||||||
|
# Image info -- GeoData/GPS
|
||||||
|
#--
|
||||||
|
def get_GeoData(self, path):
|
||||||
|
|
||||||
|
def convert_to_degrees(value):
|
||||||
|
def to_float(x):
|
||||||
|
return x[0] / x[1] if isinstance(x, tuple) else float(x)
|
||||||
|
|
||||||
|
d = to_float(value[0])
|
||||||
|
m = to_float(value[1])
|
||||||
|
s = to_float(value[2])
|
||||||
|
return d + (m / 60.0) + (s / 3600.0)
|
||||||
|
|
||||||
|
def get_gecoded(lon, lat):
|
||||||
|
url = f"{cfg['PhotonAPI']}?lang=en&lat={lat}&lon={lon}"
|
||||||
|
headers = {
|
||||||
|
"X-Api-Key": cfg['PhotonAPI_TOKEN']
|
||||||
|
}
|
||||||
|
|
||||||
|
geoapi_response = requests.get(url, headers=headers, timeout=30)
|
||||||
|
geoapi_response.raise_for_status()
|
||||||
|
return geoapi_response.json()
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = Image.open(path)
|
||||||
|
exif = img._getexif()
|
||||||
|
if not exif:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for tag_id, value in exif.items():
|
||||||
|
tag = TAGS.get(tag_id, tag_id)
|
||||||
|
if tag in ("GPSInfo"):
|
||||||
|
try:
|
||||||
|
lat = convert_to_degrees(value[2])
|
||||||
|
lat_ref = value[1]
|
||||||
|
if lat_ref == "S":
|
||||||
|
lat = -lat
|
||||||
|
|
||||||
|
lon = convert_to_degrees(value[4])
|
||||||
|
lon_ref = value[3]
|
||||||
|
if lon_ref == "W":
|
||||||
|
lon = -lon
|
||||||
|
|
||||||
|
GeoDATA = get_gecoded(lon, lat)
|
||||||
|
return {
|
||||||
|
"GeoData": GeoDATA,
|
||||||
|
"latitude": lat,
|
||||||
|
"longitude": lon
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
#--
|
||||||
|
# WebServer
|
||||||
|
#--
|
||||||
|
if __name__ == "__main__":
|
||||||
|
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
print(f"Serving slideshow on http://0.0.0.0:{cfg['PORT']}")
|
||||||
|
with socketserver.TCPServer(("", cfg['PORT']), SlideshowHandler) as httpd:
|
||||||
|
httpd.serve_forever()
|
||||||
218
show.html
Normal file
218
show.html
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Ambient SlideShow</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: "Roboto", sans-serif;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.slideshow {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
.slide {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-position: center center;
|
||||||
|
background-size: cover;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 1.5s ease-in-out;
|
||||||
|
}
|
||||||
|
.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to top,
|
||||||
|
rgba(0, 0, 0, 0.65) 0%,
|
||||||
|
rgba(0, 0, 0, 0.0) 40%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.info-bar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 40px;
|
||||||
|
left: 40px;
|
||||||
|
right: 40px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 25px;
|
||||||
|
}
|
||||||
|
.right-info {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.weather {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size:2rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.weather-icon {
|
||||||
|
margin-top: 44px;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.weather-text {
|
||||||
|
padding-left: 5px;
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 44px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.info-bar-header{
|
||||||
|
text-align: right;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.photo-GeoData {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
.photo-Date {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.clock-time {
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.clock-date {
|
||||||
|
font-size: 2rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="slideshow">
|
||||||
|
<div id="photoA" class="slide visible" style="background-image:url('');"></div>
|
||||||
|
<div id="photoB" class="slide" style="background-image:url('');"></div>
|
||||||
|
<div class="overlay"></div>
|
||||||
|
<div class="info-bar">
|
||||||
|
<div class="left-info">
|
||||||
|
<div class="clock">
|
||||||
|
<div class="weather">
|
||||||
|
<div></div>
|
||||||
|
<img id="weather-icon" class="weather-icon" src="" alt="">
|
||||||
|
<div id="weather-text" class="weather-text"></div>
|
||||||
|
</div>
|
||||||
|
<div class="clock-time" id="clock-time"></div>
|
||||||
|
<div class="clock-date" id="clock-date"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="right-info">
|
||||||
|
<div class="info-bar-header">Photo info:</div>
|
||||||
|
<div class="photo-GeoData" id="photo-GeoData"></div>
|
||||||
|
<div class="photo-Date" id="photo-Date"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
// --- Slideshow ---
|
||||||
|
async function start() {
|
||||||
|
const res = await fetch('/images.json');
|
||||||
|
const images = await res.json();
|
||||||
|
if (!images.length) return;
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
let showingA = true;
|
||||||
|
|
||||||
|
const photoA = document.getElementById('photoA');
|
||||||
|
const photoB = document.getElementById('photoB');
|
||||||
|
|
||||||
|
// Start with A visible
|
||||||
|
photoA.style.backgroundImage = 'url(' + images[index] + ')';
|
||||||
|
photoA.classList.add('visible');
|
||||||
|
photoInfo(images[index])
|
||||||
|
document.getElementById('photo-Date').textContent = ""
|
||||||
|
document.getElementById('photo-GeoData').textContent = ""
|
||||||
|
setInterval(() => {
|
||||||
|
index = (index + 1) % images.length;
|
||||||
|
if (showingA) {
|
||||||
|
photoB.style.backgroundImage = 'url(' + images[index] + ')';
|
||||||
|
photoB.classList.add('visible');
|
||||||
|
photoA.classList.remove('visible');
|
||||||
|
} else {
|
||||||
|
photoA.style.backgroundImage = 'url(' + images[index] + ')';
|
||||||
|
photoA.classList.add('visible');
|
||||||
|
photoB.classList.remove('visible');
|
||||||
|
}
|
||||||
|
photoInfo(images[index])
|
||||||
|
showingA = !showingA;
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
}
|
||||||
|
start();
|
||||||
|
|
||||||
|
// --- Photo Info ---
|
||||||
|
async function photoInfo(photo) {
|
||||||
|
const response = await fetch('exif?file='+photo);
|
||||||
|
const data = await response.json();
|
||||||
|
let formatted = new Date(data.capture_date).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
if (typeof data.geo_data == 'undefined' || data.geo_data == null) {
|
||||||
|
document.getElementById('photo-GeoData').textContent = ""
|
||||||
|
}else if(typeof data.geo_data.GeoData.features[0].properties.name !== 'undefined' & data.geo_data.GeoData.features[0].properties.name !== null & data.geo_data.GeoData.features[0].properties.name !== 'undefined' ){
|
||||||
|
document.getElementById('photo-GeoData').textContent = data.geo_data.GeoData.features[0].properties.name + " - " + data.geo_data.GeoData.features[0].properties.city+ ", " + data.geo_data.GeoData.features[0].properties.country;
|
||||||
|
}else{
|
||||||
|
document.getElementById('photo-GeoData').textContent = data.geo_data.GeoData.features[0].properties.city+ ", " + data.geo_data.GeoData.features[0].properties.country;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('photo-Date').textContent = formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Clock ---
|
||||||
|
function updateClock() {
|
||||||
|
const now = new Date();
|
||||||
|
document.getElementById('clock-time').textContent =
|
||||||
|
now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
document.getElementById('clock-date').textContent =
|
||||||
|
now.toLocaleDateString([], { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
}
|
||||||
|
updateClock();
|
||||||
|
setInterval(updateClock, 1000);
|
||||||
|
|
||||||
|
// --- Weather ---
|
||||||
|
async function loadWeather() {
|
||||||
|
const response = await fetch('/weather');
|
||||||
|
const data = await response.json();
|
||||||
|
const temp = data.temperature;
|
||||||
|
const icon = data.symbol;
|
||||||
|
document.getElementById("weather-text").textContent = `${temp}°C`;
|
||||||
|
document.getElementById("weather-icon").src =`${icon}`;
|
||||||
|
}
|
||||||
|
loadWeather();
|
||||||
|
setInterval(loadWeather, 10 * 60 * 1000); // refresh every 10 min
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user