├── .idea
├── .name
├── .gitignore
├── vcs.xml
├── inspectionProfiles
│ ├── profiles_settings.xml
│ └── Project_Default.xml
├── copilot.data.migration.agent.xml
├── copilot.data.migration.ask.xml
├── copilot.data.migration.edit.xml
├── copilot.data.migration.ask2agent.xml
├── modules.xml
└── Shadow.iml
├── hacs.json
├── custom_components
└── shadow
│ ├── images
│ ├── Legend.png
│ ├── Example_day.png
│ ├── Shadow_logo_256x256.png
│ ├── Shadow_logo_512x512.png
│ └── Example_night_w_moon.png
│ ├── translations
│ └── en.json
│ ├── services.yaml
│ ├── const.py
│ ├── manifest.json
│ ├── shadow_config.py
│ ├── test_shadow.py
│ ├── test_svg.py
│ ├── __init__.py
│ ├── sensor.py
│ ├── tools
│ └── coords_to_shape.py
│ ├── test_shadow.svg
│ └── shadow_core.py
├── .github
└── FUNDING.yml
└── README.md
/.idea/.name:
--------------------------------------------------------------------------------
1 | Shadow
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/hacs.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Shadow",
3 | "content_in_root": false,
4 | "render_readme": true
5 | }
6 |
--------------------------------------------------------------------------------
/custom_components/shadow/images/Legend.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/clmun/Shadow/HEAD/custom_components/shadow/images/Legend.png
--------------------------------------------------------------------------------
/custom_components/shadow/images/Example_day.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/clmun/Shadow/HEAD/custom_components/shadow/images/Example_day.png
--------------------------------------------------------------------------------
/custom_components/shadow/images/Shadow_logo_256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/clmun/Shadow/HEAD/custom_components/shadow/images/Shadow_logo_256x256.png
--------------------------------------------------------------------------------
/custom_components/shadow/images/Shadow_logo_512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/clmun/Shadow/HEAD/custom_components/shadow/images/Shadow_logo_512x512.png
--------------------------------------------------------------------------------
/custom_components/shadow/images/Example_night_w_moon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/clmun/Shadow/HEAD/custom_components/shadow/images/Example_night_w_moon.png
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/custom_components/shadow/translations/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "services": {
3 | "generate_svg": {
4 | "name": "Generate SVG",
5 | "description": "Force re-generation of the Shadow SVG"
6 | }
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/copilot.data.migration.agent.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/copilot.data.migration.ask.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/copilot.data.migration.edit.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/copilot.data.migration.ask2agent.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/custom_components/shadow/services.yaml:
--------------------------------------------------------------------------------
1 | generate_svg:
2 | name: Generate SVG
3 | description: Force re-generation of the Shadow SVG
4 | fields:
5 | entity_id:
6 | description: Target shadow sensor entity_id
7 | example: sensor.shadow_elevation
8 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/custom_components/shadow/const.py:
--------------------------------------------------------------------------------
1 | DOMAIN = "shadow"
2 |
3 | DEFAULT_NAME = "Shadow Elevation"
4 | DEFAULT_UPDATE_INTERVAL_MIN = 30
5 | DEFAULT_OUTPUT_PATH = "/config/www/shadow.svg"
6 |
7 | CONF_NAME = "name"
8 | CONF_TOWN = "town"
9 | CONF_OUTPUT_PATH = "output_path"
10 | CONF_UPDATE_INTERVAL = "update_interval"
11 |
--------------------------------------------------------------------------------
/.idea/Shadow.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/custom_components/shadow/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "domain": "shadow",
3 | "name": "Shadow",
4 | "version": "0.3.1",
5 | "documentation": "https://github.com/clmun/Shadow",
6 | "requirements": [
7 | "astral==2.2",
8 | "pylunar==0.6.0",
9 | "pytz"
10 | ],
11 | "codeowners": ["@clmun"],
12 | "iot_class": "local_polling",
13 | "integration_type": "platform",
14 | "loggers": ["custom_components.shadow"],
15 | "actions": [
16 | {
17 | "name": "generate_svg",
18 | "description": "Force re-generation of the Shadow SVG"
19 | }
20 | ]
21 | }
22 |
--------------------------------------------------------------------------------
/custom_components/shadow/shadow_config.py:
--------------------------------------------------------------------------------
1 | # Visual configuration for the shadow component
2 | WIDTH = 100
3 | HEIGHT = 100
4 |
5 | PRIMARY_COLOR = '#1b3024'
6 | LIGHT_COLOR = '#26bf75'
7 | BG_COLOR = '#1a1919'
8 | SUN_COLOR = '#ffff66'
9 | MOON_COLOR = '#999999'
10 |
11 | SUN_RADIUS = 5
12 | MOON_RADIUS = 3
13 |
14 | #Shape of the house (original)
15 | SHAPE = [
16 | {'x': 35.34, 'y': 70.00},
17 | {'x': 20.00, 'y': 38.54},
18 | {'x': 70.33, 'y': 13.99},
19 | {'x': 85.68, 'y': 45.45},
20 | {'x': 68.37, 'y': 53.89},
21 | {'x': 71.44, 'y': 60.18},
22 | {'x': 55.71, 'y': 67.85},
23 | {'x': 52.64, 'y': 61.56}
24 | ]
25 |
26 | # SHAPE = [
27 | # {'x': 49.19, 'y': 23.52},
28 | # {'x': 67.65, 'y': 63.23},
29 | # {'x': 38.12, 'y': 76.48},
30 | # {'x': 32.35, 'y': 65.01},
31 | # {'x': 50.60, 'y': 57.42},
32 | # {'x': 36.88, 'y': 28.92},
33 | # ]
34 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12 | polar: # Replace with a single Polar username
13 | buy_me_a_coffee: clmun01c
14 | thanks_dev: # Replace with a single thanks.dev username
15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
16 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
14 |
15 |
16 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/custom_components/shadow/test_shadow.py:
--------------------------------------------------------------------------------
1 | from shadow_core import ShadowConfig, Shadow
2 |
3 | def main():
4 | # Config de test – poți schimba coordonatele
5 | conf = ShadowConfig(
6 | latitude=45.8, # Sibiu
7 | longitude=24.1,
8 | altitude=400,
9 | timezone="Europe/Bucharest",
10 | town="Sibiu",
11 | output_path="shadow_test.svg"
12 | )
13 |
14 | shadow = Shadow(conf)
15 |
16 | # Reîmprospătăm calculele
17 | shadow.refresh()
18 |
19 | # Generăm SVG
20 | shadow._build_svg()
21 |
22 | # Printăm câteva valori pentru verificare
23 | print("Town:", conf.town)
24 | print("Elevation:", shadow.elevation)
25 | print("Sun azimuth:", shadow.sun_azimuth)
26 | print("Sun elevation:", shadow.sun_elevation)
27 | print("Moon azimuth:", shadow.moon_azimuth)
28 | print("Moon elevation:", shadow.moon_elevation)
29 | print("SVG file generated at:", conf.output_path)
30 |
31 | if __name__ == "__main__":
32 | main()
33 |
34 |
--------------------------------------------------------------------------------
/custom_components/shadow/test_svg.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from custom_components.shadow.shadow_core import Shadow, ShadowConfig # presupunând că fișierul tău se numește shadow_core.py
3 | import zoneinfo
4 |
5 | def main():
6 | # Config pentru locația ta (ex. Sibiu, România)
7 | conf = ShadowConfig(
8 | latitude=45.79, # latitudine
9 | longitude=24.15, # longitudine
10 | altitude=400, # altitudine în metri
11 | timezone="Europe/Bucharest",
12 | town="Sibiu",
13 | output_path="test_shadow.svg"
14 | )
15 |
16 | # Instanțiere obiect Shadow
17 | shadow = Shadow(conf)
18 |
19 | # Construiește SVG
20 |
21 | tz = zoneinfo.ZoneInfo("Europe/Bucharest")
22 | #shadow.refresh(datetime(2025, 12, 11, 9, 0, tzinfo=tz))
23 |
24 | svg_content = shadow._build_svg()
25 |
26 | # Scrie fișierul
27 | with open(conf.output_path, "w", encoding="utf-8") as f:
28 | f.write(svg_content)
29 |
30 | print(f"SVG salvat ca {conf.output_path} — deschide-l în browser!")
31 |
32 | if __name__ == "__main__":
33 | main()
34 |
--------------------------------------------------------------------------------
/custom_components/shadow/__init__.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from homeassistant.core import HomeAssistant
3 | from .shadow_core import Shadow, ShadowConfig
4 |
5 | _LOGGER = logging.getLogger(__name__)
6 |
7 | DOMAIN = "shadow"
8 |
9 | async def async_setup(hass: HomeAssistant, config: dict):
10 | """Set up the Shadow integration."""
11 |
12 | async def handle_generate_svg(call):
13 | # Creează config din setările HA
14 | latitude = hass.config.latitude
15 | longitude = hass.config.longitude
16 | altitude = hass.config.elevation
17 | timezone = str(hass.config.time_zone)
18 | output_path = hass.config.path("www/shadow.svg")
19 |
20 | conf = ShadowConfig(
21 | latitude=latitude,
22 | longitude=longitude,
23 | altitude=altitude,
24 | timezone=timezone,
25 | town="Shadow",
26 | output_path=output_path
27 | )
28 | shadow = Shadow(conf)
29 | await shadow.async_generate_svg(hass)
30 | _LOGGER.info("Shadow SVG regenerated via service call")
31 |
32 | # Înregistrează serviciul
33 | hass.services.async_register(DOMAIN, "generate_svg", handle_generate_svg)
34 |
35 | return True
36 |
--------------------------------------------------------------------------------
/custom_components/shadow/sensor.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from homeassistant.helpers.entity import Entity
3 | from homeassistant.helpers.typing import HomeAssistantType, ConfigType, DiscoveryInfoType
4 | from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_ELEVATION, CONF_NAME, CONF_TIME_ZONE
5 | from .shadow_core import Shadow, ShadowConfig
6 |
7 | _LOGGER = logging.getLogger(__name__)
8 |
9 | async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info: DiscoveryInfoType | None = None):
10 | """Set up the Shadow sensor platform."""
11 | name = config.get(CONF_NAME, "Shadow")
12 | latitude = config.get(CONF_LATITUDE, hass.config.latitude)
13 | longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
14 | altitude = config.get(CONF_ELEVATION, hass.config.elevation)
15 | timezone = config.get(CONF_TIME_ZONE, str(hass.config.time_zone))
16 | output_path = hass.config.path("www/shadow.svg")
17 |
18 | conf = ShadowConfig(
19 | latitude=latitude,
20 | longitude=longitude,
21 | altitude=altitude,
22 | timezone=timezone,
23 | town=name,
24 | output_path=output_path
25 | )
26 |
27 | shadow = Shadow(conf)
28 | async_add_entities([ShadowSensor(hass, shadow)], True)
29 |
30 | class ShadowSensor(Entity):
31 | """Representation of the Shadow sensor."""
32 |
33 | def __init__(self, hass: HomeAssistantType, shadow: Shadow):
34 | self._hass = hass
35 | self._shadow = shadow
36 | self._state = None
37 |
38 | @property
39 | def name(self):
40 | return self._shadow.conf.town
41 |
42 | @property
43 | def state(self):
44 | return self._state
45 |
46 | async def async_update(self):
47 | """Update sensor state and regenerate SVG."""
48 | self._shadow.refresh()
49 | self._state = f"Sun elev: {self._shadow.sun_elevation:.2f}, Moon elev: {self._shadow.moon_elevation:.2f}"
50 | await self._shadow.async_generate_svg(self._hass)
51 |
--------------------------------------------------------------------------------
/custom_components/shadow/tools/coords_to_shape.py:
--------------------------------------------------------------------------------
1 | import math
2 |
3 | # --- Here you have to put your coordinates (lat, lon)---
4 | coords = [
5 | (45.75672246183737, 24.14507467947945),
6 | (45.75633213234665, 24.145332882810937),
7 | (45.756201858743545, 24.144919772449043),
8 | (45.756314563258954, 24.14483900499283),
9 | (45.75638919203197, 24.145094404451115),
10 | (45.75666943039342, 24.144902308510726)
11 | ]
12 |
13 |
14 | def normalize_points(coords, width=100, height=100, rotate=True, angle_deg=0, margin=5):
15 | lat0 = sum(lat for lat, lon in coords) / len(coords)
16 | lon0 = sum(lon for lat, lon in coords) / len(coords)
17 |
18 | def to_xy(lat, lon):
19 | dx = (lon - lon0) * 111320 * math.cos(math.radians(lat0))
20 | dy = -(lat - lat0) * 110540 # flip pe Y
21 | return dx, dy
22 |
23 | points = [to_xy(lat, lon) for lat, lon in coords]
24 |
25 | if rotate:
26 | angle = math.radians(angle_deg)
27 | rotated = []
28 | for x, y in points:
29 | xr = x * math.cos(angle) - y * math.sin(angle)
30 | yr = x * math.sin(angle) + y * math.cos(angle)
31 | rotated.append((xr, yr))
32 | points = rotated
33 |
34 | xs, ys = zip(*points)
35 | min_x, max_x = min(xs), max(xs)
36 | min_y, max_y = min(ys), max(ys)
37 |
38 | # scalare best fit în cerc
39 | radius = width/2 - margin
40 | diag = math.sqrt((max_x - min_x)**2 + (max_y - min_y)**2)
41 | scale = (radius * math.sqrt(2)) / diag
42 |
43 | # scalare + centrare
44 | norm_points = [
45 | {
46 | 'x': (x - (min_x + max_x)/2) * scale + width/2,
47 | 'y': (y - (min_y + max_y)/2) * scale + height/2
48 | }
49 | for x, y in points
50 | ]
51 |
52 | return norm_points
53 |
54 | def main():
55 | shape = normalize_points(coords, width=100, height=100, rotate=True, angle_deg=0)
56 |
57 | # --- Write shape.svg ---
58 | with open("shape.svg", "w") as f:
59 | f.write('\n')
60 | f.write('\n')
71 |
72 |
73 | # --- Write shadow_config.py ---
74 | with open("shadow_config.py", "w") as f:
75 | f.write("WIDTH = 100\n")
76 | f.write("HEIGHT = 100\n\n")
77 | f.write("PRIMARY_COLOR = 'red' #'#1b3024'\n")
78 | f.write("LIGHT_COLOR = '#26bf75'\n")
79 | f.write("BG_COLOR = '#1a1919'\n")
80 | f.write("SUN_COLOR = '#ffff66'\n")
81 | f.write("MOON_COLOR = '#999999'\n\n")
82 | f.write("SUN_RADIUS = 5\n")
83 | f.write("MOON_RADIUS = 3\n\n")
84 | f.write("# Shape of the house (original)\n")
85 | f.write("SHAPE = [\n")
86 | for p in shape:
87 | f.write(f" {{'x': {p['x']:.2f}, 'y': {p['y']:.2f}}},\n")
88 | f.write("]\n")
89 |
90 | print("Image shape.svg created in current folder. Check it.")
91 | print("File shadow_config.py was generated in current folder. You have to copy it to custom_components/shadow/ folder.")
92 |
93 | if __name__ == "__main__":
94 | main()
--------------------------------------------------------------------------------
/custom_components/shadow/test_shadow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Shadow SVG Generator - Show Sun or Moon Position and Shadow of House
2 | ---
3 |
4 |
5 | ## 🌞🌙 Shadow SVG Generator for Home Assistant
6 |
7 | A Home Assistant custom component (via HACS) that generates dynamic SVG graphics showing illuminated sides and realistic shadows based on the Sun or Moon position.
8 | The SVG image illustrates where the Sun is currently positioned and which side of the house is facing the Sun.
9 | The integration automatically uses data from Home Assistant (`latitude`, `longitude`, `elevation`, `time_zone`).
10 |
11 | 
12 | ---
13 | ## 🌟 Features
14 | - House shadow representation based on real-time Sun or Moon position.
15 | - Positioning based on user-defined location (town).
16 | - Easy integration with Home Assistant via HACS or manual installation.
17 | - Customizable colors, dimensions, and shapes via `shadow_config.py`.
18 | - Shadow_config.py is generated automatically via tools/coords_to_shape.py script from Google Maps coordinates or can be created manually.
19 | - Configurable update intervals for real-time shadow representation.
20 | - Output SVG file accessible via Home Assistant's web server.
21 | - Lightweight and efficient, suitable for various Home Assistant setups.
22 | ---
23 | ## Lovelace Example
24 | You can display the generated SVG in your Lovelace dashboard using the Picture Entity card or Picture card.
25 | ```yaml
26 | type: picture-entity
27 | entity: camera.shadow_camera
28 | ```
29 |
30 | 
31 | 
32 | ---
33 | ## 🚀 Installation
34 | ### Add the Shadow integration via HACS: ###
35 | 1. In HACS, go to "Integrations".
36 | 2. Click on the three dots in the top right corner and select "Custom Repositories".
37 | 3. Enter the repository URL: https://github.com/clmun/Shadow
38 | 4. Select "Integration" as the category and click "Add".
39 | 5. Restart Home Assistant.
40 | ### Add the Shadow integration manually: ###
41 | 1. Download the component from the GitHub repository: https://github.com/clmun/Shadow
42 | 2. Extract the downloaded files.
43 | 3. Create a directory named `shadow` in your Home Assistant `custom_components` folder if it doesn't already exist.
44 | 4. Copy the extracted files into the `custom_components/shadow` directory.
45 | 5. In `configuration.yaml`, add the Shadow sensor configuration as described above.
46 | 6. Save the file and restart Home Assistant.
47 | ---
48 | ## 🔧 Setup Instructions
49 | 1. Search for "Shadow" in HACS and install it.
50 | 2. In configuration.yaml, add the Shadow sensor configuration as described below.
51 | ```yaml
52 | sensor:
53 | - platform: shadow
54 | name: Shadow Elevation
55 | town: [Your Town Name]
56 | output_path: /config/www/shadow.svg
57 | update_interval: 60
58 | ```
59 | 3. All settings needed for generating the picture (.svg format) are stored in `shadow_config.py` (colors, dimensions, shape coordinates). This file can be generated automatically via the `tools/coords_to_shape.py` script (see below: **How to generate the points for shape**) or can be created manually.
60 | Minimal example configuration:
61 | ```python
62 | WIDTH = 100
63 | HEIGHT = 100
64 | BG_COLOR = "black" # Background color
65 | PRIMARY_COLOR = "green" # Color of the shape
66 | LIGHT_COLOR = "yellow" # Color of the illuminated side
67 | SUN_RADIUS = 4 # Radius of the sun
68 | SUN_COLOR = "orange" # Color of the sun
69 | MOON_RADIUS = 4 # Radius of the moon
70 | MOON_COLOR = "gray" # Color of the moon
71 |
72 | SHAPE = [
73 | {'x': 40, 'y': 40}, # Bottom-left corner
74 | {'x': 60, 'y': 40}, # Bottom-right corner
75 | {'x': 60, 'y': 60}, # Top-right corner
76 | {'x': 40, 'y': 60} # Top-left corner
77 | ]
78 | ```
79 | 4. After configuring the `shadow_config.py` file, restart Home Assistant.
80 | 5. The SVG file will be generated at the specified output path: `/config/www/shadow.svg`.
81 | 6. Access the SVG file via Home Assistant's web server at `http:///local/shadow.svg`.
82 | 7. You can then use this SVG in your Lovelace dashboard or other places within Home Assistant.
83 | 8. Somehow the picture is not updating in the picture card. A solution is to add it as a camera entity using the **local file** integration:
84 | >> Go to Settings >> Devices & Services >> Add Integration >> Local File
85 | >> And here add a name for the file + path (/config/www/shadow.svg) >> And you will have the camera.
86 | Then use the camera entity in the picture card:
87 | ```yaml
88 | type: picture-entity
89 | entity: camera.shadow_camera
90 | ```
91 | 9. Enjoy your dynamic shadow SVG graphics!
92 | ---
93 | ## ⚙️ How to generate the points for shape
94 |
95 | Define your house shape by listing its corner points in the SHAPE variable in shadow_config.py. Each point is a dictionary with x and y.
96 | * SVG origin is top-left (0,0)
97 | * x increases right, y increases down
98 | * SVG size is 100 × 100
99 |
100 | How to get coordinates:
101 |
102 | **Option 1: Graph paper**
103 | * Draw the shape.
104 | * Count squares for each corner.
105 | * Scale to fit 100 × 100.
106 |
107 | **Option 2: Google Maps (recommended)**
108 |
109 | * Measure each corner using Measure distance.
110 | * Copy the lat/long points into tools/coords_to_shape.py.
111 | * Run the script.
112 | This generates:
113 | * shadow_config.py (with SHAPE filled in)
114 | * shadow.svg for preview
115 | * Copy shadow_config.py to:
116 |
117 | ```yaml
118 | custom_components/shadow/
119 | ```
120 | ## 📝 Disclaimer
121 |
122 | This integration is provided "as is" without warranty of any kind. Use at your own risk
123 | . The author is not responsible for any damage or data loss that may occur from using this integration.
124 | By using this integration, you agree to the terms of this disclaimer.
125 |
126 |
127 | ## Inspiration
128 | This integration was inspired by the OpenHAB community and adapted for Home Assistant.
129 |
130 | When I started with home automation, 10 years ago, I've started with OpenHAB and found this script and this idea fascinating.
131 |
132 | Later, when I moved to Home Assistant, I kept the script and had the shadow running for all this time, but always waited for somebody more experienced to bring this to Home Assistant.
133 |
134 | Well, in the end, with a big help from AI, I did it myself and share it now with the community.
135 |
136 | So many thanks to the OpenHAB community for the original idea! And big thanks to AI for helping me with the adaptation to Home Assistant.:) (this line was generated by AI :) )
137 |
138 | ## 🙏 Acknowledgements
139 | - Thanks to the OpenHAB community for the original idea and script.https://community.openhab.org/t/show-current-sun-position-and-shadow-of-house-generate-svg/34764
140 | - Thanks to the Home Assistant community for continuous support and inspiration.
141 | - Thanks to all contributors and users who provide feedback and help improve this integration.
142 |
143 | ## 📧 Contact
144 | For questions, suggestions, or support, please reach out via the Home Assistant Community Forums or GitHub Issues page.
145 | - Home Assistant Community Forums: https://community.home-assistant.io/
146 | - GitHub Issues: https://github.com/clmun/Shadow/issues
147 |
148 | ## ☕ Support me
149 | If you liked this integration and want to support the work done, **buy me a coffee!** 🫶
150 |
151 | It costs nothing, and your contribution helps the future development of the project. 🙌
152 |
153 | [](https://buymeacoffee.com/clmun01c)
154 |
155 | Thanks for the support, I appreciate any gesture of support! 🤗
156 |
--------------------------------------------------------------------------------
/custom_components/shadow/shadow_core.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import math
4 | import os
5 | import asyncio
6 | from dataclasses import dataclass
7 | from datetime import datetime, date, time
8 | import zoneinfo
9 | import pylunar
10 | from astral import sun, Observer
11 | from astral.location import LocationInfo
12 | from astral import moon
13 | # from custom_components.shadow.shadow_config import WIDTH, HEIGHT, BG_COLOR, PRIMARY_COLOR, LIGHT_COLOR, SUN_RADIUS, SUN_COLOR, MOON_RADIUS, MOON_COLOR, SHAPE
14 | from . import shadow_config
15 |
16 | HOURS = 1
17 |
18 | @dataclass
19 | class ShadowConfig:
20 | latitude: float
21 | longitude: float
22 | altitude: float
23 | timezone: str
24 | town: str
25 | output_path: str
26 |
27 | class Shadow:
28 | def __init__(self, conf: ShadowConfig):
29 | self.conf = conf
30 | self.location = LocationInfo(conf.town, conf.timezone, conf.latitude, conf.longitude)
31 | self.timezone = zoneinfo.ZoneInfo(conf.timezone)
32 | self.now = datetime.now(self.timezone)
33 | self.nowUTC = datetime.now(zoneinfo.ZoneInfo("UTC"))
34 |
35 | # Explicit observer (correct for astral)
36 | self._observer = Observer(
37 | latitude=self.conf.latitude,
38 | longitude=self.conf.longitude,
39 | elevation=self.conf.altitude
40 | )
41 |
42 | # Solar dates (with tzinfo explicit)
43 | self.sun_data = sun.sun(self._observer, date=self.now.date(), tzinfo=self.timezone)
44 | self.sunrise_azimuth = sun.azimuth(self._observer, self.sun_data['sunrise'])
45 | self.sunset_azimuth = sun.azimuth(self._observer, self.sun_data['sunset'])
46 | self.sun_azimuth = sun.azimuth(self._observer, self.now)
47 | self.sun_elevation = sun.elevation(self._observer, self.now)
48 |
49 | # Solar azimuths for each hour (local time)
50 | self.degs = []
51 | local_date = self.now.date()
52 | for i in range(0, 24, HOURS):
53 | hour_time = datetime(local_date.year, local_date.month, local_date.day, i, 0, 0, tzinfo=self.timezone)
54 | a = sun.azimuth(self._observer, hour_time)
55 | self.degs.append(float(a) if a is not None else 0)
56 |
57 | # Moon data
58 | self.moon_info = pylunar.MoonInfo(self.decdeg2dms(conf.latitude), self.decdeg2dms(conf.longitude))
59 | self.moon_info.update(self.nowUTC.replace(tzinfo=None))
60 | self.moon_azimuth = self.moon_info.azimuth()
61 | self.moon_elevation = self.moon_info.altitude()
62 |
63 | # Current light source (elevation)
64 | self.elevation = self.sun_elevation if self.sun_elevation > 0 else self.moon_elevation
65 |
66 | self._debug()
67 |
68 | def refresh(self, override_time: datetime | None = None):
69 | self.now = override_time or datetime.now(self.timezone)
70 | self.nowUTC = self.now.astimezone(zoneinfo.ZoneInfo("UTC"))
71 |
72 | self.sun_data = sun.sun(self._observer, date=self.now.date(), tzinfo=self.timezone)
73 | self.sunrise_azimuth = sun.azimuth(self._observer, self.sun_data['sunrise'])
74 | self.sunset_azimuth = sun.azimuth(self._observer, self.sun_data['sunset'])
75 | self.sun_azimuth = sun.azimuth(self._observer, self.now)
76 | self.sun_elevation = sun.elevation(self._observer, self.now)
77 |
78 | self.degs = []
79 | local_date = self.now.date()
80 | for i in range(0, 24, HOURS):
81 | hour_time = datetime(local_date.year, local_date.month, local_date.day, i, 0, 0, tzinfo=self.timezone)
82 | a = sun.azimuth(self._observer, hour_time)
83 | self.degs.append(float(a) if a is not None else 0)
84 |
85 | self.moon_info.update(self.nowUTC.replace(tzinfo=None))
86 | self.moon_azimuth = self.moon_info.azimuth()
87 | self.moon_elevation = self.moon_info.altitude()
88 |
89 | self.elevation = self.sun_elevation if self.sun_elevation > 0 else self.moon_elevation
90 |
91 | self._debug()
92 |
93 | @staticmethod
94 | def decdeg2dms(dd: float):
95 | negative = dd < 0
96 | dd = abs(dd)
97 | minutes, seconds = divmod(dd * 3600, 60)
98 | degrees, minutes = divmod(minutes, 60)
99 | if negative:
100 | if degrees > 0:
101 | degrees = -degrees
102 | elif minutes > 0:
103 | minutes = -minutes
104 | else:
105 | seconds = -seconds
106 | return int(degrees), int(minutes), int(seconds)
107 |
108 | # Azimuth mapping: 0° = North, clockwise
109 | @staticmethod
110 | def azimuth_to_point(d: float, r: float, cx: float = shadow_config.WIDTH / 2, cy: float = shadow_config.HEIGHT / 2):
111 | theta = math.radians(d)
112 | return {
113 | 'x': cx + r * math.sin(theta),
114 | 'y': cy - r * math.cos(theta)
115 | }
116 | @staticmethod
117 | def azimuth_to_unit_vector(d: float):
118 | theta = math.radians(d)
119 | return math.sin(theta), -math.cos(theta)
120 |
121 | @staticmethod
122 | def generate_path(stroke: str, fill: str, points: list[dict], attrs: str | None = None) -> str:
123 | p = f''
133 | return p
134 |
135 |
136 | def generate_arc(self, dist: float, stroke: str, fill: str | None, start: float, end: float, attrs: str | None = None) -> str:
137 | angle = end - start
138 | if angle < 0:
139 | angle = 360 + angle
140 | start_pt = self.azimuth_to_point(start, dist)
141 | end_pt = self.azimuth_to_point(end, dist)
142 | p = f''
148 | return p
149 | # Signed area to get polygon winding (CW < 0, CCW > 0)
150 | @staticmethod
151 | def signed_area(poly):
152 | s = 0.0
153 | n = len(poly)
154 | for i in range(n):
155 | x0, y0 = poly[i]['x'], poly[i]['y']
156 | x1, y1 = poly[(i + 1) % n]['x'], poly[(i + 1) % n]['y']
157 | s += x0 * y1 - x1 * y0
158 | return 0.5 * s
159 |
160 | @staticmethod
161 | # Outward normal based on winding
162 | def outward_normal(ex, ey, is_ccw):
163 | # Edge vector e = (ex, ey)
164 | # For CCW polygons, outward normal is (ey, -ex)
165 | # For CW polygons, outward normal is (-ey, ex)
166 | if is_ccw:
167 | return ey, -ex
168 | else:
169 | return -ey, ex
170 | # Build the complete SVG content
171 | @staticmethod
172 | def _svg_header() -> str:
173 | return (
174 | ''
175 | ''
369 | return svg
370 |
371 | def _write_svg(self, svg_content: str):
372 | folder = os.path.dirname(self.conf.output_path)
373 | if folder and not os.path.exists(folder):
374 | os.makedirs(folder, exist_ok=True)
375 | with open(self.conf.output_path, 'w', encoding='utf-8') as f:
376 | f.write(svg_content)
377 |
378 | async def async_generate_svg(self, hass):
379 | # Recalculate before generation
380 | self.refresh()
381 | svg_content = self._build_svg()
382 | # Write non-blocking via Home Assistant executor
383 | await hass.async_add_executor_job(self._write_svg, svg_content)
384 |
385 | def generate_svg(self, hass):
386 | """Compact wrapper for manifest action."""
387 | return asyncio.run_coroutine_threadsafe(
388 | self.async_generate_svg(hass),
389 | asyncio.get_event_loop()
390 | )
391 |
392 | def _debug(self):
393 | print("=== Debug Info ===")
394 | print("Town:", self.conf.town)
395 | print("Now local:", self.now.isoformat())
396 | print("Sunrise:", self.sun_data['sunrise'].isoformat())
397 | print("Sunset:", self.sun_data['sunset'].isoformat())
398 | print("Sun azimuth:", f"{self.sun_azimuth:.2f}")
399 | print("Sun elevation:", f"{self.sun_elevation:.2f}")
400 | print("Moon azimuth:", f"{self.moon_azimuth:.2f}")
401 | print("Moon elevation:", f"{self.moon_elevation:.2f}")
--------------------------------------------------------------------------------