Hello everyone,
Since the original script by @pmpkk hasn’t been updated in a long time and I personaly used it for years and did some modifications to it… now I decided to “fork it”,
I’ve made several tweaks over the years to suit my needs better, so let’s share. Most recently, I’ve updated it to work with the latest version of Astral without requiring any specific old version.
To improve visibility and maintainability, I decided to create a separate topic for this version. Feel free to modify, enhance, or correct my code as needed! Feedback is always appreciated.
What Does It Do?
The script generates an SVG file in the openhab/html
directory by default, making it easy to integrate into your OpenHAB UI. Every time the sun’s azimuth changes, the script regenerates the SVG, ensuring your panel always displays the correct sun position, shadow, moon, and wind direction.
Example (Dark Basic UI):
blue line → wind direction
yellow dot → sun

gray half dot → moon phase & position
green half circle → daytime (half is noon) half on the other end is midnight
everything is rotating and changing in relation to your house.
The raw SVG is transparent
You can easily customize the colors in the file to match your preferred style.
How to Use It?
Simply adjust your location details, define your house shape, and tweak colors if needed. Other than that, the script runs independently and doesn’t require anything beyond a wind angle.
If your home setup doesn’t provide a wind direction variable, you can either remove that part of the script or call it with 0
, as shown in the example below.
Prerequisities
apt update
apt dist-upgrade
apt install python3-pip -y
pip install astral pytz pylunar
The script: shadow.py
note: adjust Your Location Info to yours values!
ver: 02/2025
from datetime import datetime, timedelta, date, time
from astral import LocationInfo, sun
import math, sys, pytz, pylunar
####
#$ CONFIGURE YOUR LOCATION
####
LATITUDE = 50.08239089640888
LONGITUDE = 14.426095753880265
ALTITUDE = 474.0
TIMEZONE = 'Europe/Prague'
TOWN = 'Prague'
COUNTRY = 'Czech Republic'
####
# CONFIGURE YOUR HOUSE SHAPE (100 by 100 units square)
####
SHAPE = [
{'x': 16.37, 'y': 62.07}, {'x': 28.32, 'y': 40.16}, {'x': 29.57, 'y': 39.87},
{'x': 36.16, 'y': 28.41}, {'x': 35.51, 'y': 27.74}, {'x': 35.63, 'y': 15.48},
{'x': 46.14, 'y': 9.63}, {'x': 56.50, 'y': 16.13}, {'x': 56.63, 'y': 22.21},
{'x': 69.77, 'y': 28.99}, {'x': 69.99, 'y': 28.78}, {'x': 85.67, 'y': 37.63},
{'x': 67.10, 'y': 70.33}, {'x': 66.09, 'y': 70.21}, {'x': 58.27, 'y': 83.97},
{'x': 39.55, 'y': 73.58}, {'x': 38.68, 'y': 74.68}
]
####
# General setting, colors and fileoutput path
####
HOURS = 1
WIDTH = 100
HEIGHT = 100
PRIMARY_COLOR = '#1b3024'
LIGHT_COLOR = '#26bf75'
BG_COLOR = '#1a1919'
SUN_COLOR = '#ffff66'
WIND_COLOR = '#52b4bf'
SUN_RADIUS = 5
MOON_COLOR = '#999999'
MOON_RADIUS = 3
STROKE_WIDTH = '1'
FILENAME = '/etc/openhab/html/shadow.svg'
DEGS = []
#######################################################################
#######################################################################
##### not really needed to edit anything below
#######################################################################
#######################################################################
class Shadow:
def __init__(self):
self.debug = False
self.location = LocationInfo(TOWN, COUNTRY, TIMEZONE, LATITUDE, LONGITUDE)
# Get the current time in the specified timezone
timezone = pytz.timezone(TIMEZONE)
self.now = timezone.localize(datetime.now())
self.nowUTC = datetime.utcnow()
# Get the sun data using the observer and current date
self.sun_azimuth = self.calculate_sun_azimuth()
self.sun_elevation = self.calculate_sun_elevation()
if self.debug:
print(f'Sun azimuth: {self.sun_azimuth}')
print(f'Sun elevation: {self.sun_elevation}')
# Loop through the hours and calculate azimuth at each hour
for i in range(0, 24, HOURS):
hour_time = timezone.localize(datetime.combine(date.today(), time(i)))
azimuth = self.calculate_sun_azimuth(hour_time)
DEGS.append(azimuth if azimuth is not None else 0)
# Get the moon info
self.moon_info = pylunar.MoonInfo(self.decdeg2dms(LATITUDE), self.decdeg2dms(LONGITUDE))
self.moon_info.update(self.nowUTC)
self.moon_azimuth = self.moon_info.azimuth()
self.moon_elevation = self.moon_info.altitude()
# Calculate the moon phase using pylunar
self.phase = self.calculate_moon_phase()
if self.debug:
print(f'Moon Age: {self.phase}')
print(f'Moon azimuth: {self.moon_azimuth}')
print(f'Moon elevation: {self.moon_elevation}')
# Use the sun elevation or moon elevation as the final elevation
self.elevation = self.sun_elevation if self.sun_elevation > 0 else self.moon_elevation
# Calculate the sun azimuth at sunrise and sunset
self.sunrise_azimuth = self.calculate_sunrise_sunset('sunrise')
self.sunset_azimuth = self.calculate_sunrise_sunset('sunset')
def calculate_moon_phase(self):
# Calculate the moon phase using pylunar
phase = self.moon_info.age()
return phase
def calculate_sun_azimuth(self, moment=None):
if moment is None:
moment = self.now
return sun.azimuth(self.location.observer, moment)
def calculate_sun_elevation(self, moment=None):
if moment is None:
moment = self.now
return sun.elevation(self.location.observer, moment)
def calculate_sunrise_sunset(self, event_type):
# Get sunrise or sunset time
if event_type == 'sunrise':
event_time = sun.sunrise(self.location.observer, self.now.date())
elif event_type == 'sunset':
event_time = sun.sunset(self.location.observer, self.now.date())
# Convert event time to correct timezone (if necessary)
timezone = pytz.timezone(TIMEZONE)
event_time = event_time.astimezone(timezone)
# Calculate the azimuth for sunrise or sunset time
return self.calculate_sun_azimuth(event_time)
def decdeg2dms(self, deg):
d = int(deg)
m = int((deg - d) * 60)
s = (deg - d - m / 60) * 3600.0
return (d, m, s)
def generatePath(self,stroke,fill,points,attrs=None,width=None):
swith = STROKE_WIDTH
if (width != None): swith = width
p = ''
p = p + '<path stroke="' + stroke + '" stroke-width="' + swith + '" fill="' + fill + '" '
if (attrs != None): p = p + ' ' + attrs + ' '
p = p + ' d="'
for point in points:
if (points.index(point) == 0):
p = p + 'M' + str(point['x']) + ' ' + str(point['y'])
else:
p = p + ' L' + str(point['x']) + ' ' + str(point['y'])
p = p + '" />'
return p
def generateArc(self,dist,stroke,fill,orig_start,orig_end,attrs=None):
if(LATITUDE<0):
start = orig_end
end = orig_start
else:
start = orig_start
end = orig_end
p = ''
try:
angle = end-start
if (angle<0):
angle = 360 + angle
p = p + '<path d="M' + str(self.degreesToPoint(start,dist)['x']) + ' ' + str(self.degreesToPoint(start,dist)['y']) + ' '
p = p + 'A' + str(dist) + ' ' + str(dist) + ' 0 '
if (angle<180):
p = p + '0 1 '
else:
p = p + '1 1 '
p = p + str(self.degreesToPoint(end,dist)['x']) + ' ' + str(self.degreesToPoint(end,dist)['y']) + '"'
p = p + ' stroke="' + stroke + '"'
if (fill != None):
p = p + ' fill="' + fill + '" '
else:
p = p + ' fill="none" '
if (attrs != None):
p = p + ' ' + attrs + ' '
else:
p = p + ' stroke-width="' + STROKE_WIDTH + '"'
p = p + ' />'
except:
p = ''
return p
def degreesToPoint(self,d,r):
coordinates = {'x': 0, 'y': 0}
cx = WIDTH / 2
cy = HEIGHT / 2
d2 = 180 - d
coordinates['x'] = cx + math.sin(math.radians(d2))*r
coordinates['y'] = cy + math.cos(math.radians(d2))*r
return coordinates
def generateSVG(self, wind_azimuth=0):
wind_azimuth = float(wind_azimuth)
realSun_pos = self.degreesToPoint(self.sun_azimuth, 10000)
realMoon_pos = self.degreesToPoint(self.moon_azimuth, 10000)
if self.debug:
print("")
print("real sun position: " + str(realSun_pos))
sun_pos = self.degreesToPoint(self.sun_azimuth, WIDTH / 2)
moon_pos = self.degreesToPoint(self.moon_azimuth, WIDTH / 2)
wind_pos = self.degreesToPoint(wind_azimuth, WIDTH / 2)
minPoint = -1
maxPoint = -1
i = 0
minAngle = 999
maxAngle = -999
if(self.sun_elevation>0):
angle_pos = sun_pos
real_pos = realSun_pos
else:
angle_pos = moon_pos
real_pos = realMoon_pos
if self.debug:
print("")
print("House:")
for point in SHAPE:
#Angle of close light source
angle = -math.degrees(math.atan2(point['y']-angle_pos['y'],point['x']-angle_pos['x']))
#Angle of distant light source (e.g. sun_pos)
angle = -math.degrees(math.atan2(point['y']-real_pos['y'],point['x']-real_pos['x']))
distance = math.sqrt(math.pow(angle_pos['y']-point['y'],2) + math.pow(angle_pos['x']-point['x'],2))
if (angle<minAngle):
minAngle = angle
minPoint = i
if (angle>maxAngle):
maxAngle = angle
maxPoint = i
point['angle'] = angle
point['distance'] = distance
if self.debug:
print(str(i).ljust(10),":", str(point['x']).ljust(10), str(point['y']).ljust(10), "angle: ", str(round(angle,7)).ljust(10), "dist: ", str(round(distance)).ljust(10))
i = i + 1
if self.debug:
print("")
print("Min Point = ",minPoint)
print("Max Point = ",maxPoint)
print("")
i = minPoint
k = 0
side1Distance = 0
side2Distance = 0
side1Done = False
side2Done = False
side1 = []
side2 = []
while True:
if (side1Done == False):
side1Distance = side1Distance + SHAPE[i]['distance']
if(i != minPoint and i != maxPoint): SHAPE[i]['side'] = 1
if (i == maxPoint): side1Done = True
side1.append( { 'x': SHAPE[i]['x'], 'y': SHAPE[i]['y'] } )
if (side1Done == True):
side2Distance = side2Distance + SHAPE[i]['distance']
if(i != minPoint and i != maxPoint): SHAPE[i]['side'] = 2
if (i == minPoint): side2Done = True
side2.append( { 'x': SHAPE[i]['x'], 'y': SHAPE[i]['y'] } )
i = i + 1
if( i > len(SHAPE)-1): i = 0
if (side1Done and side2Done): break
k = k + 1
if (k == 20): break
svg = '<?xml version="1.0" encoding="utf-8"?>'
svg = svg + '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">'
svg = svg + '<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="-10 -10 120 120" xml:space="preserve" width="150px" height="150px">'
# background
svg = svg + '<circle cx="' + str(WIDTH/2) + '" cy="' + str(HEIGHT/2) + '" r="' + str(WIDTH/2-1) + '" fill="' + BG_COLOR + '"/>'
minPointShadowX = SHAPE[minPoint]['x'] + WIDTH * math.cos(math.radians(minAngle))
minPointShadowY = SHAPE[minPoint]['y'] - HEIGHT * math.sin(math.radians(minAngle))
maxPointShadowX = SHAPE[maxPoint]['x'] + WIDTH * math.cos(math.radians(maxAngle))
maxPointShadowY = SHAPE[maxPoint]['y'] - HEIGHT * math.sin(math.radians(maxAngle))
shadow = [ {'x': maxPointShadowX, 'y': maxPointShadowY } ] + \
side2 + \
[ {'x': minPointShadowX, 'y': minPointShadowY } ]
svg = svg + '<defs><mask id="shadowMask">'
svg = svg + ' <rect width="100%" height="100%" fill="black"/>'
svg = svg + ' <circle cx="' + str(WIDTH/2) + '" cy="' + str(HEIGHT/2) + '" r="' + str(WIDTH/2-1) + '" fill="white"/>'
svg = svg + '</mask></defs>'
svg = svg + self.generatePath('none',PRIMARY_COLOR,SHAPE)
shadow_svg = self.generatePath('none','black',shadow,'mask="url(#shadowMask)" fill-opacity="0.5"')
if (self.elevation>0):
svg = svg + self.generatePath(LIGHT_COLOR,'none',side1)
else:
svg = svg + self.generatePath(PRIMARY_COLOR,'none',side2)
if (self.elevation>0): svg = svg + shadow_svg
svg = svg + self.generateArc(WIDTH/2,PRIMARY_COLOR,'none',self.sunset_azimuth,self.sunrise_azimuth)
svg = svg + self.generateArc(WIDTH/2,LIGHT_COLOR,'none',self.sunrise_azimuth,self.sunset_azimuth)
svg = svg + self.generatePath(LIGHT_COLOR,'none',[self.degreesToPoint(self.sunrise_azimuth,WIDTH//2-2), self.degreesToPoint(self.sunrise_azimuth,WIDTH//2+2)])
svg = svg + self.generatePath(LIGHT_COLOR,'none',[self.degreesToPoint(self.sunset_azimuth,WIDTH//2-2), self.degreesToPoint(self.sunset_azimuth,WIDTH//2+2)])
for i in range(0,len(DEGS)):
if (i == len(DEGS)-1):
j = 0
else:
j = i + 1
if (i % 2 == 0):
svg = svg + self.generateArc(WIDTH/2+8,PRIMARY_COLOR,'none',DEGS[i],DEGS[j],'stroke-width="3" stroke-opacity="0.2"')
else:
svg = svg + self.generateArc(WIDTH/2+8,PRIMARY_COLOR,'none',DEGS[i],DEGS[j],'stroke-width="3"')
if self.debug:
print(DEGS[i])
svg = svg + self.generatePath(LIGHT_COLOR,'none',[self.degreesToPoint(DEGS[0],WIDTH//2+5), self.degreesToPoint(DEGS[0],WIDTH//2+11)])
svg = svg + self.generatePath(LIGHT_COLOR,'none',[self.degreesToPoint(DEGS[(len(DEGS))//2],WIDTH//2+5), self.degreesToPoint(DEGS[(len(DEGS))//2],WIDTH//2+11)])
# moon drawing: compute left and right arcs
if self.debug:
print('phase: ' + str(phase))
left_radius=MOON_RADIUS
left_sweep=0
right_radius=MOON_RADIUS
right_sweep=0
if (self.phase > 14):
right_radius = MOON_RADIUS - (2.0*MOON_RADIUS* (1.0 - ((self.phase%14)*0.99 / 14.0)))
if (right_radius < 0):
right_radius = right_radius * -1.0
right_sweep = 0
else:
right_sweep = 1
if (self.phase < 14):
left_radius = MOON_RADIUS - (2.0*MOON_RADIUS* (1.0 - ((self.phase%14)*0.99 / 14.0)))
if (left_radius < 0):
left_radius = left_radius * -1.0
left_sweep = 1
if (self.moon_elevation>0):
svg = svg + '<path stroke="none" stroke-width="0" fill="' + MOON_COLOR \
+ '" d="M ' + str(moon_pos['x']) + ' ' + str(moon_pos['y']-MOON_RADIUS) \
+ ' A ' + str(left_radius) + ' ' + str(MOON_RADIUS) + ' 0 0 ' + str(left_sweep) + ' ' + str(moon_pos['x']) + ' ' + str(moon_pos['y']+MOON_RADIUS) \
+ ' ' + str(right_radius) + ' ' + str(MOON_RADIUS) + ' 0 0 ' + str(right_sweep) + ' ' + str(moon_pos['x']) + ' ' + str(moon_pos['y']-MOON_RADIUS) + ' z" />'
# sun drawing
if (self.sun_elevation>0):
svg = svg + '<circle cx="' + str(sun_pos['x']) + '" cy="' + str(sun_pos['y']) + '" r="' + str(SUN_RADIUS) + '" stroke="none" stroke-width="0" fill="' + SUN_COLOR + '55" />'
svg = svg + '<circle cx="' + str(sun_pos['x']) + '" cy="' + str(sun_pos['y']) + '" r="' + str(SUN_RADIUS -1) + '" stroke="none" stroke-width="0" fill="' + SUN_COLOR + '99" />'
svg = svg + '<circle cx="' + str(sun_pos['x']) + '" cy="' + str(sun_pos['y']) + '" r="' + str(SUN_RADIUS -2) + '" stroke="' + SUN_COLOR + '" stroke-width="0" fill="' + SUN_COLOR + '" />'
#wind
svg = svg + self.generatePath(WIND_COLOR,'none',[self.degreesToPoint(wind_azimuth,WIDTH//2+5), self.degreesToPoint(wind_azimuth,WIDTH//2+20)],"","2")
svg = svg + '</svg>'
#if self.debug:
# print(svg)
f = open(FILENAME, 'w')
f.write(svg)
f.close()
def main():
t1 = datetime.now()
s = Shadow()
args = sys.argv
if(len(args) == 1):
dummy = 0
else:
if(args[1] == "update"):
s.generateSVG(args[2])
t2 = datetime.now()
print("Done in " + str(t2-t1) + " seconds")
if __name__ == '__main__':
main()
to call the script from OH on SunAziumuth and WindAngle change:
rule "Updater: python: Shadow SVG"
when
Item Sun_Azimuth received update or
Item WS_WindAngle received update or
System started
then
executeCommandLine("python3", "/etc/openhab/scripts/shadow.py", "update", (WS_WindAngle.state as Number).floatValue.toString)
end
if you don’t have WindAngle in your home setup, either remove respective parts from the python script direcly or call the script with 0 (which will draw line, but it is not tha much visible )
executeCommandLine("python3", "/etc/openhab/scripts/shadow.py", "update", 0)
to display it on BasicUI
Image url="https://<OHIP>/static/shadow.svg" refresh=30000