├── .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 | 6 | -------------------------------------------------------------------------------- /.idea/copilot.data.migration.agent.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/copilot.data.migration.ask.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/copilot.data.migration.edit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/copilot.data.migration.ask2agent.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 | 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') 62 | 63 | # cerc de fundal 64 | f.write('\n') 65 | 66 | # trasează poligonul din punctele normalizate, umplut cu verde 67 | path = "M " + " ".join(f"{p['x']:.2f},{p['y']:.2f}" for p in shape) + " Z" 68 | f.write(f'\n') 69 | 70 | 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 | 2025-12-16 13:59:10 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shadow SVG Generator - Show Sun or Moon Position and Shadow of House 2 | --- 3 | Logo 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 | ![Legend](https://raw.githubusercontent.com/clmun/Shadow/master/custom_components/shadow/images/Legend.png) 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 | ![Lovelace Example](https://raw.githubusercontent.com/clmun/Shadow/master/custom_components/shadow/images/Example_day.png) 31 | ![Lovelace Example](https://raw.githubusercontent.com/clmun/Shadow/master/custom_components/shadow/images/Example_night_w_moon.png) 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 | [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-Support%20the%20developer-orange?style=for-the-badge&logo=buy-me-a-coffee)](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' 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' 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 | '' 176 | f'' 177 | ) 178 | 179 | @staticmethod 180 | def _svg_shadow_mask() -> str: 181 | return ( 182 | '' 183 | '' 184 | f'' 185 | '' 186 | ) 187 | 188 | def _svg_outline(self) -> str: 189 | return self.generate_path('none', shadow_config.PRIMARY_COLOR, shadow_config.SHAPE) 190 | 191 | @staticmethod 192 | def _calculate_min_max(shape, real_pos): 193 | min_point = -1 194 | max_point = -1 195 | min_angle = 999.0 196 | max_angle = -999.0 197 | for i, pt in enumerate(shape): 198 | angle = -math.degrees(math.atan2(pt['y'] - real_pos['y'], pt['x'] - real_pos['x'])) 199 | if angle < min_angle: 200 | min_angle = angle 201 | min_point = i 202 | if angle > max_angle: 203 | max_angle = angle 204 | max_point = i 205 | return min_point, max_point 206 | 207 | @staticmethod 208 | def _slice_shape(shape, start, end): 209 | out = [] 210 | i = start 211 | n = len(shape) 212 | while True: 213 | out.append(shape[i]) 214 | if i == end: 215 | break 216 | i = (i + 1) % n 217 | return out 218 | 219 | @staticmethod 220 | def _project_point(pt, shadow_length, az): 221 | opp_deg = -az + 270.0 222 | vx = math.cos(math.radians(opp_deg)) 223 | vy = math.sin(math.radians(opp_deg)) 224 | return {'x': pt['x'] + shadow_length * vx, 'y': pt['y'] - shadow_length * vy} 225 | 226 | def _svg_shadow(self, shape, sun_pos, moon_pos) -> str: 227 | use_sun = self.sun_elevation > 0 228 | use_moon = (not use_sun) and (self.moon_elevation > 0) 229 | if not (use_sun or use_moon): 230 | return self.generate_path(shadow_config.PRIMARY_COLOR, 'none', shape) 231 | 232 | elev = self.sun_elevation if use_sun else self.moon_elevation 233 | az = self.sun_azimuth if use_sun else self.moon_azimuth 234 | base_pos = sun_pos if use_sun else moon_pos 235 | 236 | # build far real_pos to stabilize angle calculation 237 | cx, cy = shadow_config.WIDTH / 2.0, shadow_config.HEIGHT / 2.0 238 | ux, uy = base_pos['x'] - cx, base_pos['y'] - cy 239 | norm = math.hypot(ux, uy) or 1.0 240 | real_pos = {'x': cx + (ux / norm) * 10000.0, 'y': cy + (uy / norm) * 10000.0} 241 | 242 | min_idx, max_idx = self._calculate_min_max(shape, real_pos) 243 | if min_idx < 0 or max_idx < 0: 244 | return self.generate_path(shadow_config.PRIMARY_COLOR, 'none', shape) 245 | if min_idx == max_idx and len(shape) > 1: 246 | # choose the farthest other point as second extreme 247 | dists = [(math.hypot(pt['x'] - real_pos['x'], pt['y'] - real_pos['y']), i) for i, pt in enumerate(shape)] 248 | max_idx = max([d for d in dists if d[1] != min_idx], key=lambda x: x[0])[1] 249 | 250 | bright_side = self._slice_shape(shape, min_idx, max_idx) 251 | dark_side = self._slice_shape(shape, max_idx, min_idx) 252 | if not bright_side or not dark_side: 253 | return self.generate_path(shadow_config.PRIMARY_COLOR, 'none', shape) 254 | 255 | shadow_length = min(shadow_config.WIDTH * 2, shadow_config.WIDTH / max(0.001, math.tan(math.radians(elev)))) 256 | min_proj = self._project_point(shape[min_idx], shadow_length, az) 257 | max_proj = self._project_point(shape[max_idx], shadow_length, az) 258 | 259 | shadow = [max_proj] + dark_side + [min_proj] 260 | shadow_svg = self.generate_path('none', 'black', shadow, 'mask="url(#shadowMask)" fill-opacity="0.5"') 261 | shape_svg = self.generate_path(shadow_config.PRIMARY_COLOR, 'none', shape) 262 | light_svg = self.generate_path(shadow_config.LIGHT_COLOR, 'none', bright_side) 263 | 264 | return shape_svg + light_svg + shadow_svg 265 | 266 | def _svg_day_night_arcs(self) -> str: 267 | return ( 268 | self.generate_arc(shadow_config.WIDTH/2, shadow_config.PRIMARY_COLOR, 'none', self.sunset_azimuth, self.sunrise_azimuth) + 269 | self.generate_arc(shadow_config.WIDTH/2, shadow_config.LIGHT_COLOR, 'none', self.sunrise_azimuth, self.sunset_azimuth) 270 | ) 271 | 272 | def _svg_sunrise_sunset_ticks(self) -> str: 273 | return ( 274 | self.generate_path(shadow_config.LIGHT_COLOR, 'none', [ 275 | self.azimuth_to_point(self.sunrise_azimuth, shadow_config.WIDTH/2 - 2), 276 | self.azimuth_to_point(self.sunrise_azimuth, shadow_config.WIDTH/2 + 2) 277 | ]) + 278 | self.generate_path(shadow_config.LIGHT_COLOR, 'none', [ 279 | self.azimuth_to_point(self.sunset_azimuth, shadow_config.WIDTH/2 - 2), 280 | self.azimuth_to_point(self.sunset_azimuth, shadow_config.WIDTH/2 + 2) 281 | ]) 282 | ) 283 | 284 | def _svg_hour_arcs(self) -> str: 285 | svg = "" 286 | for i in range(len(self.degs)): 287 | j = 0 if i == len(self.degs) - 1 else i + 1 288 | attrs = 'stroke-width="3" stroke-opacity="0.2"' if i % 2 == 0 else 'stroke-width="3"' 289 | svg += self.generate_arc(shadow_config.WIDTH/2 + 8, shadow_config.PRIMARY_COLOR, 'none', self.degs[i], self.degs[j], attrs) 290 | return svg 291 | 292 | def _svg_ticks_midnight_noon(self) -> str: 293 | return ( 294 | self.generate_path(shadow_config.LIGHT_COLOR, 'none', [ 295 | self.azimuth_to_point(self.degs[0], shadow_config.WIDTH/2 + 5), 296 | self.azimuth_to_point(self.degs[0], shadow_config.WIDTH/2 + 11) 297 | ]) + 298 | self.generate_path(shadow_config.LIGHT_COLOR, 'none', [ 299 | self.azimuth_to_point(self.degs[len(self.degs)//2], shadow_config.WIDTH/2 + 5), 300 | self.azimuth_to_point(self.degs[len(self.degs)//2], shadow_config.WIDTH/2 + 11) 301 | ]) 302 | ) 303 | 304 | def _svg_sun_marker(self, sun_pos) -> str: 305 | if self.sun_elevation <= 0: 306 | return "" 307 | return ( 308 | f'' 309 | f'' 310 | f'' 311 | ) 312 | 313 | def _svg_moon_marker(self, moon_pos) -> str: 314 | if self.moon_elevation <= 0: 315 | return "" 316 | 317 | phase = moon.phase(self.now) 318 | 319 | # implicit values for full moon 320 | left_radius = shadow_config.MOON_RADIUS 321 | left_sweep = 0 322 | right_radius = shadow_config.MOON_RADIUS 323 | right_sweep = 0 324 | 325 | # moon phase > 14 (after fool moon) 326 | if phase > 14: 327 | right_radius = shadow_config.MOON_RADIUS - (2.0 * shadow_config.MOON_RADIUS * (1.0 - ((phase % 14) * 0.99 / 14.0))) 328 | if right_radius < 0: 329 | right_radius = -right_radius 330 | right_sweep = 0 331 | else: 332 | right_sweep = 1 333 | 334 | # moon phase < 14 (before full moon) 335 | if phase < 14: 336 | left_radius = shadow_config.MOON_RADIUS - (2.0 * shadow_config.MOON_RADIUS * (1.0 - ((phase % 14) * 0.99 / 14.0))) 337 | if left_radius < 0: 338 | left_radius = -left_radius 339 | left_sweep = 1 340 | 341 | # path SVG for lunar disc with phase 342 | return ( 343 | f'' 347 | ) 348 | 349 | def _svg_timestamp(self) -> str: 350 | ts = self.now.strftime("%Y-%m-%d %H:%M:%S") 351 | return f'{ts}' 352 | 353 | 354 | def _build_svg(self) -> str: 355 | sun_pos = self.azimuth_to_point(self.sun_azimuth, shadow_config.WIDTH/2) 356 | moon_pos = self.azimuth_to_point(self.moon_azimuth, shadow_config.WIDTH/2) 357 | svg = self._svg_header() 358 | svg += self._svg_shadow_mask() 359 | svg += self._svg_outline() 360 | svg += self._svg_shadow(shadow_config.SHAPE, sun_pos, moon_pos) 361 | svg += self._svg_day_night_arcs() 362 | svg += self._svg_sunrise_sunset_ticks() 363 | svg += self._svg_hour_arcs() 364 | svg += self._svg_ticks_midnight_noon() 365 | svg += self._svg_sun_marker(sun_pos) 366 | svg += self._svg_moon_marker(moon_pos) 367 | svg += self._svg_timestamp() 368 | svg += '' 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}") --------------------------------------------------------------------------------