├── .github └── workflows │ └── docker-image.yaml ├── README.md ├── everything-presence-mmwave-configurator ├── CHANGELOG.md ├── Dockerfile ├── backend.py ├── config.yaml ├── icon.png ├── logo.png ├── nginx.conf ├── services │ ├── backend │ │ └── run │ └── nginx │ │ └── run └── www │ ├── index.html │ ├── script.js │ └── styles.css └── repository.yaml /.github/workflows/docker-image.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | name: Build and Push Docker Image 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout Code 18 | uses: actions/checkout@v3 19 | 20 | - name: Set up QEMU 21 | uses: docker/setup-qemu-action@v2 22 | 23 | - name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v2 25 | 26 | - name: Extract version from config.yaml 27 | id: get_version 28 | run: | 29 | VERSION=$(grep '^version:' everything-presence-mmwave-configurator/config.yaml | awk '{print $2}' | tr -d '"') 30 | echo "Extracted version: $VERSION" 31 | echo "VERSION=$VERSION" >> $GITHUB_ENV 32 | 33 | - name: Set Build Variables 34 | run: | 35 | if [ "${{ github.event_name }}" == "push" ]; then 36 | echo "PLATFORMS=linux/amd64,linux/arm64,linux/arm/v7" >> $GITHUB_ENV 37 | echo "PUSH=true" >> $GITHUB_ENV 38 | echo "LOAD=false" >> $GITHUB_ENV 39 | else 40 | echo "PLATFORMS=linux/amd64" >> $GITHUB_ENV 41 | echo "PUSH=false" >> $GITHUB_ENV 42 | echo "LOAD=true" >> $GITHUB_ENV 43 | fi 44 | - name: Set Image Tags 45 | run: | 46 | if [ "${{ github.event_name }}" == "push" ]; then 47 | echo "IMAGE_TAG=${{ secrets.DOCKERHUB_USERNAME }}/everything-presence-mmwave-configurator:${{ env.VERSION }}" >> $GITHUB_ENV 48 | echo "LATEST_TAG=${{ secrets.DOCKERHUB_USERNAME }}/everything-presence-mmwave-configurator:latest" >> $GITHUB_ENV 49 | else 50 | # Use a dummy namespace for PR builds. 51 | echo "IMAGE_TAG=test/everything-presence-mmwave-configurator:${{ env.VERSION }}" >> $GITHUB_ENV 52 | echo "LATEST_TAG=test/everything-presence-mmwave-configurator:latest" >> $GITHUB_ENV 53 | fi 54 | 55 | - name: Log in to Docker Hub 56 | if: env.PUSH == 'true' 57 | uses: docker/login-action@v2 58 | with: 59 | username: ${{ secrets.DOCKERHUB_USERNAME }} 60 | password: ${{ secrets.DOCKERHUB_TOKEN }} 61 | 62 | - name: Build and (optionally) Push Docker image 63 | uses: docker/build-push-action@v4 64 | with: 65 | context: ./everything-presence-mmwave-configurator 66 | platforms: ${{ env.PLATFORMS }} 67 | push: ${{ env.PUSH }} 68 | load: ${{ env.LOAD }} 69 | tags: | 70 | ${{ env.IMAGE_TAG }} 71 | ${{ env.LATEST_TAG }} 72 | 73 | - name: Test Docker image 74 | if: env.LOAD == 'true' 75 | run: | 76 | docker run --rm ${{ env.IMAGE_TAG }} echo "Image built successfully" 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Everything Presence Add-ons 2 | Home Assistant add-ons for Everything Presence One/Lite products 3 | -------------------------------------------------------------------------------- /everything-presence-mmwave-configurator/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 1.1.4 2 | - Adds support for a second exclusion zone with firmware 1.3.0 of Everything Presence Lite [@EverythingSmartHome](https://github.com/EverythingSmartHome/everything-presence-addons/pull/68) 3 | 4 | ### 1.1.3 5 | - Added the ability to display devices by their Friendly name to make device selection easier [OliverHi](https://github.com/OliverHi) 6 | 7 | ### 1.1.2 8 | 9 | - Added Zone Exports UI button & JavaScript to save current zones in map to a JSON file to allow for re-import or backups [@ilikestohack](https://github.com/ilikestohack) 10 | - Added Zone Imports UI button & JavaScript to import the previously saved JSON files [@ilikestohack](https://github.com/ilikestohack) 11 | - Added Zone Reset UI button & JavaScript to reset user zones in current view [@ilikestohack](https://github.com/ilikestohack) 12 | - Fixed issue where right clicking and canceling a deletion would add a new zone by ignoring right clicks in mouse down event - Fixes [#18](https://github.com/EverythingSmartHome/everything-presence-addons/issues/18) [@ilikestohack](https://github.com/ilikestohack) 13 | - Fixed issues where when dragging an item it would break due to the zoneType being pulled as null because you were dragging over the area outside of the zone (Could not get the zone), now there is a type variable that is updated only when the dragging is started [@ilikestohack](https://github.com/ilikestohack) 14 | - Added a button to convert the HA Zones to User Zones that can be saved/repositioned - Fixes [#27](https://github.com/EverythingSmartHome/everything-presence-addons/issues/27) [@ilikestohack](https://github.com/ilikestohack) 15 | - Made sure that when converting HA Zones to User Zones any zone at origin (all 0 coordinates) were ignored so the zone could be added [@ilikestohack](https://github.com/ilikestohack) 16 | - Added this backdated changelog - Fixes [#35](https://github.com/EverythingSmartHome/everything-presence-addons/issues/35) [@ilikestohack](https://github.com/ilikestohack) 17 | - Bumped version to 1.1.2 [@ilikestohack](https://github.com/ilikestohack) 18 | 19 | [Github Compare](https://github.com/EverythingSmartHome/everything-presence-addons/compare/1.1.1...1.1.2) 20 | 21 | ### 1.1.1 22 | 23 | - Add support for inches and other units of measurement by [@akarras](https://github.com/akarras) in [#23](https://github.com/EverythingSmartHome/everything-presence-addons/issues/23) 24 | - Implement better searching of entity names [@pugson](https://github.com/pugson) in [#16](https://github.com/EverythingSmartHome/everything-presence-addons/issues/16) 25 | - New Feature: Added Persistence Tracking by [@francismiles1](https://github.com/francismiles1) in [#32](https://github.com/EverythingSmartHome/everything-presence-addons/issues/32) 26 | - Update styles.css - Persistence Button Support by [@francismiles1](https://github.com/francismiles1) in [#33](https://github.com/EverythingSmartHome/everything-presence-addons/issues/33) 27 | 28 | [Github Compare](https://github.com/EverythingSmartHome/everything-presence-addons/compare/1.1.0...1.1.1) 29 | 30 | ### 1.1.0 31 | 32 | The main highlight for this release is the addition of Occupancy Masks! Occupancy masks allow you to define a zone to exclude from detecting motion in, for example if you have a fan and you want to exclude it from triggering the sensor. 33 | 34 | Make sure the Everything Presence Lite is update to version 1.2.0 or greater. 35 | 36 | - Add support for exclusion zones by [@EverythingSmartHome](https://github.com/EverythingSmartHome) in [#9](https://github.com/EverythingSmartHome/everything-presence-addons/issues/9) 37 | - Bump version to 1.1.0 by [@EverythingSmartHome](https://github.com/EverythingSmartHome) in [#10](https://github.com/EverythingSmartHome/everything-presence-addons/issues/10) 38 | 39 | [Github Compare](https://github.com/EverythingSmartHome/everything-presence-addons/compare/1.0.3...1.1.0) 40 | 41 | ### 1.0.3 42 | 43 | - Implement Installation Angle by [@MenesesPT](https://github.com/MenesesPT) in [#2](https://github.com/EverythingSmartHome/everything-presence-addons/issues/2) 44 | - Add docker build action by [@EverythingSmartHome](https://github.com/EverythingSmartHome) in [#6](https://github.com/EverythingSmartHome/everything-presence-addons/issues/6) 45 | - Bump version to 1.0.3 by [@EverythingSmartHome](https://github.com/EverythingSmartHome) in [#7](https://github.com/EverythingSmartHome/everything-presence-addons/issues/7) 46 | 47 | [Github Compare](https://github.com/EverythingSmartHome/everything-presence-addons/compare/v1.0.2...1.0.3) 48 | 49 | ### 1.0.2 50 | 51 | - Add standalone docker compatibility by [@EverythingSmartHome](https://github.com/EverythingSmartHome) in [#4](https://github.com/EverythingSmartHome/everything-presence-addons/issues/4) 52 | - Bump version to 1.0.2 by [@EverythingSmartHome](https://github.com/EverythingSmartHome) in [#5](https://github.com/EverythingSmartHome/everything-presence-addons/issues/5) 53 | 54 | [Github Commit Log](https://github.com/EverythingSmartHome/everything-presence-addons/commits/v1.0.2) 55 | -------------------------------------------------------------------------------- /everything-presence-mmwave-configurator/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BUILD_FROM=ghcr.io/hassio-addons/base:12.2.1 2 | FROM $BUILD_FROM 3 | 4 | ENV LANG C.UTF-8 5 | ENV PIP_ROOT_USER_ACTION=ignore 6 | 7 | RUN apk add --no-cache ca-certificates && \ 8 | update-ca-certificates 9 | 10 | RUN apk update && \ 11 | apk add --no-cache nginx python3 py3-pip 12 | 13 | RUN apk add py3-requests py3-flask 14 | 15 | # Copy nginx configuration 16 | COPY nginx.conf /etc/nginx/nginx.conf 17 | 18 | # Copy your application files 19 | COPY www /usr/share/nginx/html 20 | 21 | # Copy backend script 22 | COPY backend.py /app/backend.py 23 | 24 | # Copy services 25 | COPY services/ /etc/services.d/ 26 | RUN chmod +x /etc/services.d/backend/run 27 | RUN chmod +x /etc/services.d/nginx/run 28 | 29 | # Expose the ingress port 30 | EXPOSE 8099 31 | -------------------------------------------------------------------------------- /everything-presence-mmwave-configurator/backend.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, jsonify, request, Response 2 | import requests 3 | import os 4 | import logging 5 | import threading 6 | import sys 7 | 8 | # Configure logging 9 | logging.basicConfig(level=logging.ERROR) # Set global logging level to ERROR 10 | log = logging.getLogger('werkzeug') 11 | log.setLevel(logging.ERROR) # Suppress Werkzeug request logs 12 | 13 | app = Flask(__name__) 14 | 15 | SUPERVISOR_TOKEN = os.getenv('SUPERVISOR_TOKEN') 16 | HA_URL = os.getenv('HA_URL') 17 | HA_TOKEN = os.getenv('HA_TOKEN') 18 | 19 | if SUPERVISOR_TOKEN: 20 | logging.error('Running as a Home Assistant Add-on.') 21 | HOME_ASSISTANT_API = 'http://supervisor/core/api' 22 | headers = { 23 | 'Authorization': f'Bearer {SUPERVISOR_TOKEN}', 24 | 'Content-Type': 'application/json', 25 | } 26 | elif HA_URL and HA_TOKEN: 27 | logging.error('Running as a standalone docker container.') 28 | HOME_ASSISTANT_API = HA_URL.rstrip('/') + '/api' 29 | headers = { 30 | 'Authorization': f'Bearer {HA_TOKEN}', 31 | 'Content-Type': 'application/json', 32 | } 33 | else: 34 | logging.error('No SUPERVISOR_TOKEN found and no HA_URL and HA_TOKEN provided.') 35 | sys.exit(1) 36 | 37 | def check_connectivity(): 38 | """Function to check connectivity with Home Assistant API.""" 39 | try: 40 | logging.info("Checking connectivity with Home Assistant API...") 41 | response = requests.get(f'{HOME_ASSISTANT_API}/', headers=headers, timeout=10) 42 | if response.status_code == 200: 43 | logging.info("Successfully connected to Home Assistant API.") 44 | else: 45 | logging.error(f"Failed to connect to Home Assistant API. Status Code: {response.status_code}") 46 | logging.error(f"Response: {response.text}") 47 | except requests.exceptions.RequestException as e: 48 | logging.error(f"Exception occurred while connecting to Home Assistant API: {e}") 49 | 50 | @app.route('/api/template', methods=['POST']) 51 | def execute_template(): 52 | """ 53 | Endpoint to execute a Jinja2 template by forwarding it to Home Assistant's /api/template endpoint. 54 | It acts as a proxy, forwarding the template and returning the rendered result. 55 | """ 56 | data = request.get_json() 57 | template = data.get('template') 58 | if not template: 59 | return jsonify({"error": "No template provided"}), 400 60 | 61 | try: 62 | response = requests.post( 63 | f'{HOME_ASSISTANT_API}/template', 64 | headers=headers, 65 | json={"template": template}, 66 | timeout=10 67 | ) 68 | 69 | if response.status_code == 200: 70 | return Response(response.content, status=200, content_type=response.headers.get('Content-Type', 'application/json')) 71 | else: 72 | logging.error(f"Failed to execute template. Status Code: {response.status_code}") 73 | logging.error(f"Response: {response.text}") 74 | return jsonify({"error": "Failed to execute template"}), response.status_code 75 | except Exception as e: 76 | logging.error(f"Exception occurred while executing template: {e}") 77 | return jsonify({"error": "Exception occurred while executing template"}), 500 78 | 79 | @app.route('/api/entities/', methods=['GET']) 80 | def get_entity_state(entity_id): 81 | """ 82 | Endpoint to get the state of a specific entity. 83 | """ 84 | response = requests.get(f'{HOME_ASSISTANT_API}/states/{entity_id}', headers=headers) 85 | if response.status_code == 200: 86 | return jsonify(response.json()) 87 | else: 88 | return jsonify({'error': 'Unauthorized or entity not found'}), response.status_code 89 | 90 | @app.route('/api/services/number/set_value', methods=['POST']) 91 | def set_value(): 92 | try: 93 | data = request.json 94 | entity_id = data.get('entity_id') 95 | value = data.get('value') 96 | 97 | if not entity_id or value is None: 98 | return jsonify({"error": "Missing entity_id or value"}), 400 99 | 100 | payload = { 101 | "entity_id": entity_id, 102 | "value": value 103 | } 104 | 105 | # Make the POST request to Home Assistant API 106 | response = requests.post(f'{HOME_ASSISTANT_API}/services/number/set_value', headers=headers, json=payload) 107 | 108 | if response.status_code == 200: 109 | return jsonify({"message": f"Entity {entity_id} updated successfully."}), 200 110 | else: 111 | return jsonify({"error": f"Failed to update entity {entity_id}.", "details": response.text}), response.status_code 112 | 113 | except Exception as e: 114 | return jsonify({"error": "An error occurred while setting the value.", "details": str(e)}), 500 115 | 116 | if __name__ == '__main__': 117 | threading.Thread(target=check_connectivity).start() 118 | app.run(host='0.0.0.0', port=5000) 119 | -------------------------------------------------------------------------------- /everything-presence-mmwave-configurator/config.yaml: -------------------------------------------------------------------------------- 1 | name: "Everything Presence Zone Configurator" 2 | description: "Helps you to visually create zones for the Everything Presence Lite, a mmWave Presence Sensor for Home Assistant" 3 | version: "1.1.4" 4 | slug: "everything-presence-zone-configurator" 5 | init: false 6 | arch: 7 | - aarch64 8 | - amd64 9 | - armhf 10 | - armv7 11 | - i386 12 | startup: services 13 | boot: auto 14 | ingress: true 15 | ingress_port: 8099 16 | ingress_entry: index.html 17 | ports: 18 | 8099/tcp: null 19 | homeassistant_api: true -------------------------------------------------------------------------------- /everything-presence-mmwave-configurator/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EverythingSmartHome/everything-presence-addons/ad089673d72714dbf2b5ef6075ae6e7ca5e76a5e/everything-presence-mmwave-configurator/icon.png -------------------------------------------------------------------------------- /everything-presence-mmwave-configurator/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EverythingSmartHome/everything-presence-addons/ad089673d72714dbf2b5ef6075ae6e7ca5e76a5e/everything-presence-mmwave-configurator/logo.png -------------------------------------------------------------------------------- /everything-presence-mmwave-configurator/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 1; 2 | events { 3 | worker_connections 1024; 4 | } 5 | http { 6 | include mime.types; 7 | default_type application/octet-stream; 8 | sendfile on; 9 | keepalive_timeout 65; 10 | 11 | server { 12 | listen 8099; 13 | server_name localhost; 14 | 15 | root /usr/share/nginx/html; 16 | index index.html; 17 | 18 | location /api/ { 19 | proxy_pass http://127.0.0.1:5000/api/; 20 | proxy_set_header Host $host; 21 | proxy_http_version 1.1; 22 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 23 | } 24 | 25 | location / { 26 | try_files $uri $uri/ =404; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /everything-presence-mmwave-configurator/services/backend/run: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv bashio 2 | 3 | exec python3 /app/backend.py -------------------------------------------------------------------------------- /everything-presence-mmwave-configurator/services/nginx/run: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv bashio 2 | 3 | exec nginx -g "daemon off;" -------------------------------------------------------------------------------- /everything-presence-mmwave-configurator/www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Everything Presence Lite - Zone Configurator 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |

Everything Presence Lite - Zone Configurator

18 | 19 |
20 | 21 |
22 |
23 | 24 |
25 | 26 | 29 |
30 | 31 | 32 |
33 | 34 | 37 |
38 | 39 | 40 |
41 | 42 | 43 | 44 | 45 |
46 | 47 | 48 |
Status: Not Refreshing
49 |
50 | 51 | 52 | 53 |
54 |

Target Tracking Information

55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 |
TargetStatusX Coordinate (mm)Y Coordinate (mm)Speed (mm/s)ResolutionAngle (°)Distance (mm)
Target 1N/AN/AN/AN/AN/AN/AN/A
Target 2N/AN/AN/AN/AN/AN/AN/A
Target 3N/AN/AN/AN/AN/AN/AN/A
101 |
102 | 103 | 104 | 109 | 110 |
111 | 112 | 116 |
117 | 118 | 119 |
120 | 121 |
122 | 123 |
124 |
125 | 126 | 127 | 128 | 129 | 130 |
131 |
132 | 133 | 134 |
135 |
136 | 137 |
138 |

© Everything Presence Lite - Zone Configurator

139 |
140 |
141 | 142 | 143 | 144 | 145 | -------------------------------------------------------------------------------- /everything-presence-mmwave-configurator/www/script.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", () => { 2 | // Canvas and context 3 | const canvas = document.getElementById("visualizationCanvas"); 4 | const ctx = canvas.getContext("2d"); 5 | 6 | // Variables for device selection 7 | const deviceSelect = document.getElementById("device-select"); 8 | let selectedEntities = []; 9 | let targets = []; 10 | let haZones = []; 11 | let haExclusionZones = []; 12 | let userZones = []; 13 | let exclusionZones = []; 14 | 15 | // Variables for live refresh 16 | const refreshRateInput = document.getElementById("refreshRateInput"); 17 | const setRefreshRateButton = document.getElementById("setRefreshRateButton"); 18 | const toggleRefreshButton = document.getElementById("toggleRefreshButton"); 19 | const statusIndicator = document.getElementById("statusIndicator"); 20 | let refreshInterval = 500; 21 | let refreshIntervalId = null; 22 | let isFetchingData = false; 23 | let installationAngle = 0; 24 | let detectionRange = 6000; 25 | let offsetY = 0; 26 | 27 | // Variables for dragging and resizing 28 | let isDragging = false; 29 | let draggingZone = null; 30 | let draggingZoneType = null; 31 | let dragType = null; // 'move', 'resize', 'create' 32 | let resizeCorner = null; 33 | const dragOffset = { x: 0, y: 0 }; 34 | 35 | // Scaling functions 36 | const scale = canvas.width / 12000; // 0.08 pixels/mm 37 | 38 | // Define unique colors for HA Zones 39 | const haZoneColors = [ 40 | { fill: "rgba(255, 0, 0, 0.1)", stroke: "red" }, 41 | { fill: "rgba(0, 255, 0, 0.1)", stroke: "green" }, 42 | { fill: "rgba(0, 0, 255, 0.1)", stroke: "blue" }, 43 | { fill: "rgba(255, 255, 0, 0.1)", stroke: "yellow" }, 44 | ]; 45 | 46 | const zoneTypeSelect = document.getElementById("zone-type-select"); 47 | let currentZoneType = "regular"; 48 | 49 | zoneTypeSelect.addEventListener("change", (event) => { 50 | currentZoneType = event.target.value; 51 | }); 52 | 53 | const saveZonesButton = document.getElementById("saveZonesButton"); 54 | 55 | saveZonesButton.addEventListener("click", saveZonesToHA); 56 | 57 | // ========================== 58 | // === Persistence State === 59 | // ========================== 60 | let isPersistenceEnabled = false; // Flag to toggle persistence 61 | let persistentDots = []; // Array to store persistent dots 62 | 63 | // Add a button for toggling persistence 64 | const persistenceToggleButton = document.getElementById("persistenceToggleButton"); 65 | 66 | // If the button doesn't exist, create and append it to the body 67 | if (!persistenceToggleButton) { 68 | const button = document.createElement("button"); 69 | button.id = "persistenceToggleButton"; 70 | button.textContent = "Enable Persistence"; 71 | // Style the button as needed 72 | button.style.marginLeft = "10px"; 73 | // Append to a suitable container, e.g., next to saveZonesButton 74 | saveZonesButton.parentElement.appendChild(button); 75 | } 76 | 77 | // Now, fetch the button 78 | const persistenceButton = document.getElementById("persistenceToggleButton"); 79 | 80 | // Event listener to toggle persistence 81 | persistenceButton.addEventListener("click", () => { 82 | isPersistenceEnabled = !isPersistenceEnabled; 83 | persistenceButton.textContent = isPersistenceEnabled ? "Disable Persistence" : "Enable Persistence"; 84 | 85 | if (isPersistenceEnabled) { 86 | // Set refresh rate to 250ms when persistence is enabled 87 | setRefreshRate(250, true); // Pass a flag to indicate it's a programmatic change 88 | } else { 89 | // Revert to user-specified refresh rate when persistence is disabled 90 | const userRefreshRate = parseInt(refreshRateInput.value, 10) || 500; 91 | setRefreshRate(userRefreshRate, true); 92 | // Optionally clear persistent dots when disabled 93 | persistentDots = []; 94 | drawVisualization(); 95 | } 96 | }); 97 | 98 | // ========================== 99 | // === Scaling Functions === 100 | // ========================== 101 | function scaleX(value) { 102 | return (value + 6000) * scale; 103 | } 104 | 105 | function unscaleX(value) { 106 | return value / scale - 6000; 107 | } 108 | 109 | function scaleY(value) { 110 | return (value + offsetY) * scale; 111 | } 112 | 113 | function unscaleY(value) { 114 | return value / scale - offsetY; 115 | } 116 | 117 | function calculateOffsetY() { 118 | let absAngle = Math.abs(installationAngle); 119 | if (absAngle <= 30) offsetY = 0; 120 | else offsetY = detectionRange * Math.sin(((absAngle - 30) * Math.PI) / 180); 121 | } 122 | 123 | /// Returns the entity's state converted from whatever unit is configured in the UI converted to millimeters 124 | function getEntityStateMM(entity) { 125 | const state = entity ? parseFloat(entity.state) || 0 : 0; 126 | let result = state; 127 | // cm, in, ft, km, m, mi, nmi, yd are supported in home assistant 128 | switch (entity.attributes.unit_of_measurement) { 129 | case "mm": 130 | break; // Avoid checking every unit for the most common case 131 | case "in": 132 | result = state * 25.4; // Convert inches to millimeters 133 | break; 134 | case "ft": 135 | result = state * 304.8; // Convert feet to millimeters 136 | break; 137 | case "km": 138 | result = state * 1000000; // Convert kilometers to millimeters 139 | break; 140 | case "m": 141 | result = state * 1000; // Convert meters to millimeters 142 | break; 143 | case "mi": 144 | result = state * 1.609e6; // Convert miles to millimeters 145 | break; 146 | case "nmi": 147 | result = state * 1.852e6; // Convert nautical miles to millimeters 148 | break; 149 | case "yd": 150 | result = state * 914.4; // Convert yards to millimeters 151 | break; 152 | } 153 | return Math.round(result); 154 | } 155 | 156 | // ========================== 157 | // === Drawing Functions === 158 | // ========================== 159 | function drawVisualization() { 160 | // Clear the canvas 161 | ctx.clearRect(0, 0, canvas.width, canvas.height); 162 | 163 | // Draw grid lines 164 | drawGrid(); 165 | 166 | // Draw radar background 167 | drawRadarBackground(); 168 | 169 | // Draw HA zones (non-interactive) 170 | haZones.forEach((zone, index) => { 171 | drawZone(zone, index, "ha"); 172 | }); 173 | 174 | // Draw user zones (interactive) 175 | userZones.forEach((zone, index) => { 176 | drawZone(zone, index, "user"); 177 | }); 178 | 179 | // Draw HA Exclusion zones (non-interactive) 180 | haExclusionZones.forEach((zone, index) => { 181 | drawZone(zone, index, "haExclusion"); 182 | }); 183 | 184 | // Draw exclusion zones (interactive) 185 | exclusionZones.forEach((zone, index) => { 186 | drawZone(zone, index, "exclusion"); 187 | }); 188 | 189 | // Draw targets 190 | targets.forEach((target) => { 191 | if (target.active) { 192 | drawTarget(target); 193 | } 194 | }); 195 | 196 | // ========================== 197 | // === Draw Persistent Dots == 198 | // ========================== 199 | if (isPersistenceEnabled) { 200 | drawPersistentDots(); 201 | } 202 | } 203 | 204 | function drawRadarBackground() { 205 | const centerX = scaleX(0); 206 | const centerY = scaleY(0); 207 | const halfDetectionAngle = 60; 208 | const startAngleRadians = 209 | ((-halfDetectionAngle - installationAngle) / 180) * Math.PI; 210 | const endAngleRadians = 211 | ((halfDetectionAngle - installationAngle) / 180) * Math.PI; 212 | 213 | const startAngle = Math.PI / 2 + startAngleRadians; 214 | const endAngle = Math.PI / 2 + endAngleRadians; 215 | 216 | const radius = scaleY(detectionRange) - scaleY(0); 217 | 218 | ctx.beginPath(); 219 | ctx.moveTo(centerX, centerY); 220 | ctx.arc(centerX, centerY, radius, startAngle, endAngle, false); 221 | ctx.closePath(); 222 | 223 | ctx.fillStyle = "rgba(168, 216, 234, 0.15)"; 224 | ctx.fill(); 225 | 226 | ctx.strokeStyle = "rgba(168, 216, 234, 0.5)"; 227 | ctx.lineWidth = 1; 228 | ctx.stroke(); 229 | } 230 | 231 | function drawZone(zone, index, zoneType) { 232 | const x = scaleX(Math.min(zone.beginX, zone.endX)); 233 | const y = scaleY(Math.min(zone.beginY, zone.endY)); 234 | const width = Math.abs(scaleX(zone.endX) - scaleX(zone.beginX)); 235 | const height = Math.abs(scaleY(zone.endY) - scaleY(zone.beginY)); 236 | 237 | if (zoneType === "ha") { 238 | const color = haZoneColors[index % haZoneColors.length]; 239 | ctx.fillStyle = color.fill; 240 | ctx.strokeStyle = color.stroke; 241 | ctx.lineWidth = 2; 242 | } else if (zoneType === "user") { 243 | ctx.fillStyle = "rgba(90, 34, 139, 0.1)"; 244 | ctx.strokeStyle = "purple"; 245 | ctx.lineWidth = 2; 246 | } else if (zoneType === "haExclusion") { 247 | ctx.fillStyle = "rgba(255, 255, 0, 0.1)"; 248 | ctx.strokeStyle = "yellow"; 249 | ctx.lineWidth = 2; 250 | } else if (zoneType === "exclusion") { 251 | ctx.fillStyle = "rgba(255, 165, 0, 0.1)"; // Orange with transparency 252 | ctx.strokeStyle = "orange"; 253 | ctx.lineWidth = 2; 254 | } 255 | 256 | ctx.beginPath(); 257 | ctx.rect(x, y, width, height); 258 | ctx.fill(); 259 | ctx.stroke(); 260 | ctx.closePath(); 261 | 262 | // Set text color based on current theme 263 | const isDarkMode = document.body.classList.contains("dark-mode"); 264 | ctx.fillStyle = isDarkMode ? "#e0e0e0" : "#333333"; 265 | ctx.font = "12px Open Sans"; 266 | 267 | let zoneLabel; 268 | if (zoneType === "ha") { 269 | zoneLabel = `HA Zone ${index + 1}`; 270 | } else if (zoneType === "user") { 271 | zoneLabel = `User Zone ${index + 1}`; 272 | } else if (zoneType === "exclusion") { 273 | zoneLabel = `Exclusion Zone ${index + 1}`; 274 | } else if (zoneType === "haExclusion") { 275 | zoneLabel = `HA Exclusion Zone ${index + 1}`; 276 | } 277 | ctx.fillText(zoneLabel, x + 5, y + 15); 278 | } 279 | 280 | function drawTarget(target) { 281 | const x = scaleX(target.x); 282 | const y = scaleY(target.y); 283 | 284 | ctx.beginPath(); 285 | ctx.arc(x, y, 5, 0, 2 * Math.PI); 286 | ctx.fillStyle = "red"; 287 | ctx.fill(); 288 | ctx.closePath(); 289 | } 290 | 291 | // ========================== 292 | // === Persistent Dots === 293 | // ========================== 294 | function drawPersistentDots() { 295 | ctx.fillStyle = "black"; // Choose desired color for persistent dots 296 | persistentDots.forEach((dot) => { 297 | ctx.beginPath(); 298 | ctx.arc(scaleX(dot.x), scaleY(dot.y), 3, 0, 2 * Math.PI); 299 | ctx.fill(); 300 | ctx.closePath(); 301 | }); 302 | } 303 | 304 | // ========================== 305 | // === Event Listeners === 306 | // ========================== 307 | // Event listeners for canvas interactions 308 | canvas.addEventListener("mousedown", onMouseDown); 309 | canvas.addEventListener("mousemove", onMouseMove); 310 | canvas.addEventListener("mouseup", onMouseUp); 311 | canvas.addEventListener("contextmenu", onRightClick); 312 | 313 | function onMouseDown(e) { 314 | const mousePos = getMousePos(canvas, e); 315 | const zoneInfo = getZoneAtPosition(mousePos); 316 | 317 | if(e.button === 2) return; // This prevents deleting then creating zones by ignoring right clicks 318 | 319 | if (zoneInfo !== null) { 320 | const { index, corner, zoneType } = zoneInfo; 321 | draggingZone = index; 322 | draggingZoneType = zoneType; 323 | dragOffset.x = mousePos.x; 324 | dragOffset.y = mousePos.y; 325 | 326 | if (corner) { 327 | dragType = "resize"; 328 | resizeCorner = corner; 329 | } else { 330 | dragType = "move"; 331 | resizeCorner = null; 332 | } 333 | isDragging = true; 334 | } else { 335 | if (currentZoneType === "regular") { 336 | // Start creating a new user zone if less than 4 user zones 337 | if (userZones.length < 4) { 338 | dragType = "create"; 339 | draggingZone = userZones.length; 340 | const startX = unscaleX(mousePos.x); 341 | const startY = unscaleY(mousePos.y); 342 | userZones.push({ 343 | beginX: startX, 344 | beginY: startY, 345 | endX: startX, 346 | endY: startY, 347 | }); 348 | isDragging = true; 349 | } else { 350 | alert("Maximum of 4 user zones allowed."); 351 | } 352 | } else if (currentZoneType === "exclusion") { 353 | const maxExclusionZones = 2; 354 | if (exclusionZones.length < maxExclusionZones) { 355 | dragType = "create"; 356 | draggingZone = exclusionZones.length; 357 | const startX = unscaleX(mousePos.x); 358 | const startY = unscaleY(mousePos.y); 359 | exclusionZones.push({ 360 | beginX: startX, 361 | beginY: startY, 362 | endX: startX, 363 | endY: startY, 364 | }); 365 | isDragging = true; 366 | } else { 367 | alert(`Maximum of ${maxExclusionZones} exclusion zones allowed.`); 368 | } 369 | } 370 | } 371 | } 372 | 373 | function onMouseMove(e) { 374 | const mousePos = getMousePos(canvas, e); 375 | const zoneInfo = getZoneAtPosition(mousePos); 376 | if (!isDragging) { 377 | // Update cursor style based on hover state 378 | if (zoneInfo !== null) { 379 | if (zoneInfo.zoneType === "user" || zoneInfo.zoneType === "exclusion") { 380 | canvas.style.cursor = zoneInfo.corner ? "nwse-resize" : "move"; 381 | } 382 | } else { 383 | canvas.style.cursor = "crosshair"; 384 | } 385 | return; 386 | } 387 | let dx = unscaleX(mousePos.x) - unscaleX(dragOffset.x); 388 | let dy = unscaleY(mousePos.y) - unscaleY(dragOffset.y); 389 | 390 | if (dragType === "move") { 391 | if (draggingZoneType === "user") { 392 | let zone = userZones[draggingZone]; 393 | 394 | let newBeginX = zone.beginX + dx; 395 | let newEndX = zone.endX + dx; 396 | let newBeginY = zone.beginY + dy; 397 | let newEndY = zone.endY + dy; 398 | 399 | // Constrain within boundaries 400 | if (newBeginX < -6000) { 401 | dx += -6000 - newBeginX; 402 | } 403 | if (newEndX > 6000) { 404 | dx += 6000 - newEndX; 405 | } 406 | if (newBeginY < -offsetY) { 407 | dy += -offsetY - newBeginY; 408 | } 409 | if (newEndY > 7500) { 410 | dy += 7500 - newEndY; 411 | } 412 | 413 | zone.beginX += dx; 414 | zone.endX += dx; 415 | zone.beginY += dy; 416 | zone.endY += dy; 417 | 418 | zone.beginX = Math.round(zone.beginX); 419 | zone.endX = Math.round(zone.endX); 420 | zone.beginY = Math.round(zone.beginY); 421 | zone.endY = Math.round(zone.endY); 422 | } else if (draggingZoneType === "exclusion") { 423 | let zone = exclusionZones[draggingZone]; 424 | let newBeginX = zone.beginX + dx; 425 | let newEndX = zone.endX + dx; 426 | let newBeginY = zone.beginY + dy; 427 | let newEndY = zone.endY + dy; 428 | 429 | // Adjust dx and dy to prevent moving beyond boundaries 430 | if (newBeginX < -6000) { 431 | dx += -6000 - newBeginX; 432 | } 433 | if (newEndX > 6000) { 434 | dx += 6000 - newEndX; 435 | } 436 | if (newBeginY < -offsetY) { 437 | dy += -offsetY - newBeginY; 438 | } 439 | if (newEndY > 7500) { 440 | dy += 7500 - newEndY; 441 | } 442 | 443 | zone.beginX += dx; 444 | zone.endX += dx; 445 | zone.beginY += dy; 446 | zone.endY += dy; 447 | 448 | zone.beginX = Math.round(zone.beginX); 449 | zone.endX = Math.round(zone.endX); 450 | zone.beginY = Math.round(zone.beginY); 451 | zone.endY = Math.round(zone.endY); 452 | } 453 | } else if (dragType === "resize") { 454 | if (draggingZoneType === "user") { 455 | const zone = userZones[draggingZone]; 456 | adjustZoneCornerWithConstraints(zone, resizeCorner, dx, dy); 457 | } else if (draggingZoneType === "exclusion") { 458 | const zone = exclusionZones[draggingZone]; 459 | adjustZoneCornerWithConstraints(zone, resizeCorner, dx, dy); 460 | } 461 | } else if (dragType === "create") { 462 | if (currentZoneType === "regular") { 463 | const zone = userZones[draggingZone]; 464 | zone.endX = Math.max(-6000, Math.min(6000, unscaleX(mousePos.x))); 465 | zone.endY = Math.max(-offsetY, Math.min(7500, unscaleY(mousePos.y))); 466 | zone.endX = Math.round(zone.endX); 467 | zone.endY = Math.round(zone.endY); 468 | } else if (currentZoneType === "exclusion") { 469 | const zone = exclusionZones[draggingZone]; 470 | zone.endX = Math.max(-6000, Math.min(6000, unscaleX(mousePos.x))); 471 | zone.endY = Math.max(-offsetY, Math.min(7500, unscaleY(mousePos.y))); 472 | zone.endX = Math.round(zone.endX); 473 | zone.endY = Math.round(zone.endY); 474 | } 475 | } 476 | 477 | dragOffset.x = mousePos.x; 478 | dragOffset.y = mousePos.y; 479 | 480 | drawVisualization(); 481 | updateCoordinatesOutput(); 482 | } 483 | 484 | function drawGrid() { 485 | const gridSize = 1000; // Grid every 1000 mm 486 | ctx.strokeStyle = "#e0e0e0"; 487 | ctx.lineWidth = 1; 488 | 489 | // Vertical grid lines 490 | for (let x = -6000; x <= 6000; x += gridSize) { 491 | ctx.beginPath(); 492 | ctx.moveTo(scaleX(x), scaleY(-2000)); 493 | ctx.lineTo(scaleX(x), scaleY(7500)); 494 | ctx.stroke(); 495 | } 496 | 497 | // Horizontal grid lines 498 | for (let y = -2000; y <= 7500; y += gridSize) { 499 | ctx.beginPath(); 500 | ctx.moveTo(scaleX(-6000), scaleY(y)); 501 | ctx.lineTo(scaleX(6000), scaleY(y)); 502 | ctx.stroke(); 503 | } 504 | } 505 | 506 | function onMouseUp(e) { 507 | isDragging = false; 508 | draggingZone = null; 509 | dragType = null; 510 | resizeCorner = null; 511 | } 512 | 513 | function onRightClick(e) { 514 | e.preventDefault(); 515 | const mousePos = getMousePos(canvas, e); 516 | const zoneInfo = getZoneAtPosition(mousePos); 517 | if (zoneInfo !== null) { 518 | const { index, zoneType } = zoneInfo; 519 | if (zoneType === "user") { 520 | if (confirm(`Delete User Zone ${index + 1}?`)) { 521 | userZones.splice(index, 1); 522 | drawVisualization(); 523 | updateCoordinatesOutput(); 524 | } 525 | } else if (zoneType === "exclusion") { 526 | if (confirm(`Delete Exclusion Zone ${index + 1}?`)) { 527 | exclusionZones.splice(index, 1); 528 | drawVisualization(); 529 | updateCoordinatesOutput(); 530 | } 531 | } 532 | } 533 | } 534 | 535 | function adjustZoneCornerWithConstraints(zone, corner, dx, dy) { 536 | let newBeginX = zone.beginX; 537 | let newEndX = zone.endX; 538 | let newBeginY = zone.beginY; 539 | let newEndY = zone.endY; 540 | 541 | if (corner === "top-left") { 542 | newBeginX += dx; 543 | newBeginY += dy; 544 | // Constrain to boundaries and not beyond opposite corner 545 | newBeginX = Math.max(-6000, Math.min(newBeginX, zone.endX)); 546 | newBeginY = Math.max(-offsetY, Math.min(newBeginY, zone.endY)); 547 | } else if (corner === "top-right") { 548 | newEndX += dx; 549 | newBeginY += dy; 550 | newEndX = Math.min(6000, Math.max(newEndX, zone.beginX)); 551 | newBeginY = Math.max(-offsetY, Math.min(newBeginY, zone.endY)); 552 | } else if (corner === "bottom-left") { 553 | newBeginX += dx; 554 | newEndY += dy; 555 | newBeginX = Math.max(-6000, Math.min(newBeginX, zone.endX)); 556 | newEndY = Math.min(7500, Math.max(newEndY, zone.beginY)); 557 | } else if (corner === "bottom-right") { 558 | newEndX += dx; 559 | newEndY += dy; 560 | newEndX = Math.min(6000, Math.max(newEndX, zone.beginX)); 561 | newEndY = Math.min(7500, Math.max(newEndY, zone.beginY)); 562 | } 563 | 564 | // Apply the new positions 565 | zone.beginX = Math.round(newBeginX); 566 | zone.endX = Math.round(newEndX); 567 | zone.beginY = Math.round(newBeginY); 568 | zone.endY = Math.round(newEndY); 569 | } 570 | 571 | function getMousePos(canvas, evt) { 572 | const rect = canvas.getBoundingClientRect(); 573 | return { 574 | x: evt.clientX - rect.left, 575 | y: evt.clientY - rect.top, 576 | }; 577 | } 578 | 579 | function getZoneAtPosition(pos) { 580 | // Check exclusion zones first (higher priority) 581 | for (let i = exclusionZones.length - 1; i >= 0; i--) { 582 | const zone = exclusionZones[i]; 583 | const x = scaleX(Math.min(zone.beginX, zone.endX)); 584 | const y = scaleY(Math.min(zone.beginY, zone.endY)); 585 | const width = Math.abs(scaleX(zone.endX) - scaleX(zone.beginX)); 586 | const height = Math.abs(scaleY(zone.endY) - scaleY(zone.beginY)); 587 | 588 | const handleSize = 8; 589 | const corners = [ 590 | { x: x, y: y, corner: "top-left", type: "exclusion", index: i }, 591 | { 592 | x: x + width, 593 | y: y, 594 | corner: "top-right", 595 | type: "exclusion", 596 | index: i, 597 | }, 598 | { 599 | x: x, 600 | y: y + height, 601 | corner: "bottom-left", 602 | type: "exclusion", 603 | index: i, 604 | }, 605 | { 606 | x: x + width, 607 | y: y + height, 608 | corner: "bottom-right", 609 | type: "exclusion", 610 | index: i, 611 | }, 612 | ]; 613 | for (const corner of corners) { 614 | if ( 615 | pos.x >= corner.x - handleSize / 2 && 616 | pos.x <= corner.x + handleSize / 2 && 617 | pos.y >= corner.y - handleSize / 2 && 618 | pos.y <= corner.y + handleSize / 2 619 | ) { 620 | return { 621 | index: corner.index, 622 | corner: corner.corner, 623 | zoneType: corner.type, 624 | }; 625 | } 626 | } 627 | 628 | if ( 629 | pos.x >= x && 630 | pos.x <= x + width && 631 | pos.y >= y && 632 | pos.y <= y + height 633 | ) { 634 | return { index: i, corner: null, zoneType: "exclusion" }; 635 | } 636 | } 637 | 638 | // Check user zones next 639 | for (let i = userZones.length - 1; i >= 0; i--) { 640 | const zone = userZones[i]; 641 | const x = scaleX(Math.min(zone.beginX, zone.endX)); 642 | const y = scaleY(Math.min(zone.beginY, zone.endY)); 643 | const width = Math.abs(scaleX(zone.endX) - scaleX(zone.beginX)); 644 | const height = Math.abs(scaleY(zone.endY) - scaleY(zone.beginY)); 645 | 646 | const handleSize = 8; 647 | const corners = [ 648 | { x: x, y: y, corner: "top-left", type: "user", index: i }, 649 | { x: x + width, y: y, corner: "top-right", type: "user", index: i }, 650 | { x: x, y: y + height, corner: "bottom-left", type: "user", index: i }, 651 | { 652 | x: x + width, 653 | y: y + height, 654 | corner: "bottom-right", 655 | type: "user", 656 | index: i, 657 | }, 658 | ]; 659 | for (const corner of corners) { 660 | if ( 661 | pos.x >= corner.x - handleSize / 2 && 662 | pos.x <= corner.x + handleSize / 2 && 663 | pos.y >= corner.y - handleSize / 2 && 664 | pos.y <= corner.y + handleSize / 2 665 | ) { 666 | return { index: corner.index, corner: corner.corner, zoneType: corner.type }; 667 | } 668 | } 669 | 670 | if ( 671 | pos.x >= x && 672 | pos.x <= x + width && 673 | pos.y >= y && 674 | pos.y <= y + height 675 | ) { 676 | return { index: i, corner: null, zoneType: "user" }; 677 | } 678 | } 679 | return null; 680 | } 681 | 682 | // ========================== 683 | // === Coordinates Output === 684 | // ========================== 685 | const coordinatesOutput = document.getElementById("coordinatesOutput"); 686 | 687 | function updateCoordinatesOutput() { 688 | let output = "User Zones:\n"; 689 | userZones.forEach((zone, index) => { 690 | output += `Zone ${index + 1} X Begin: ${zone.beginX}, X End: ${zone.endX}, 691 | Y Begin: ${zone.beginY}, Y End: ${zone.endY}\n`; 692 | }); 693 | 694 | if (exclusionZones.length > 0) { 695 | output += "\nExclusion Zones:\n"; 696 | exclusionZones.forEach((zone, index) => { 697 | output += `Exclusion Zone ${index + 1} X Begin: ${zone.beginX}, X End: ${zone.endX}, Y Begin: ${zone.beginY}, Y End: ${zone.endY}\n`; 698 | }); 699 | } 700 | 701 | coordinatesOutput.textContent = output; 702 | } 703 | 704 | // ========================== 705 | // === Fetch Entity State === 706 | // ========================== 707 | async function fetchEntityState(entityId) { 708 | if (!entityId) { 709 | console.error("Attempted to fetch entity with undefined ID."); 710 | return null; 711 | } 712 | 713 | try { 714 | const response = await fetch(`api/entities/${entityId}`); 715 | if (!response.ok) { 716 | throw new Error( 717 | `Failed to fetch entity ${entityId}: ${response.statusText}` 718 | ); 719 | } 720 | return await response.json(); 721 | } catch (error) { 722 | console.error(`Error fetching entity ${entityId}:`, error); 723 | return null; 724 | } 725 | } 726 | 727 | // ========================== 728 | // === Dark Mode Toggle === 729 | // ========================== 730 | const darkModeToggle = document.getElementById("dark-mode-toggle"); 731 | 732 | function setupDarkModeToggle() { 733 | const prefersDarkScheme = window.matchMedia("(prefers-color-scheme: dark)"); 734 | 735 | const savedMode = localStorage.getItem("darkMode"); 736 | 737 | if (savedMode === "enabled") { 738 | document.body.classList.add("dark-mode"); 739 | darkModeToggle.textContent = "🌞"; 740 | } else if (savedMode === "disabled") { 741 | document.body.classList.remove("dark-mode"); 742 | darkModeToggle.textContent = "🌙"; 743 | } else if (prefersDarkScheme.matches) { 744 | document.body.classList.add("dark-mode"); 745 | darkModeToggle.textContent = "🌞"; 746 | } 747 | 748 | darkModeToggle.addEventListener("click", () => { 749 | document.body.classList.toggle("dark-mode"); 750 | 751 | // Update button text and save preference 752 | if (document.body.classList.contains("dark-mode")) { 753 | darkModeToggle.textContent = "🌞"; 754 | localStorage.setItem("darkMode", "enabled"); 755 | } else { 756 | darkModeToggle.textContent = "🌙"; 757 | localStorage.setItem("darkMode", "disabled"); 758 | } 759 | 760 | // Redraw visualization to reflect theme change 761 | drawVisualization(); 762 | }); 763 | } 764 | 765 | // ========================== 766 | // === Device Dropdown === 767 | // ========================== 768 | // Populate device selection drop-down 769 | async function fetchDevices() { 770 | const template = ` 771 | {% set devices = namespace(list=[]) %} 772 | {% for device in states | map(attribute='entity_id') | map('device_id') | unique | reject('none') %} 773 | {% set model = device_attr(device, 'model') %} 774 | {% set manufacturer = device_attr(device, 'manufacturer') %} 775 | {% if manufacturer == 'EverythingSmartTechnology' %} 776 | {% if model == 'Everything_Presence_Lite' or model == 'Everything Presence Lite' %} 777 | {% set device_name = device_attr(device, 'name_by_user') or device_attr(device, 'name') %} 778 | {% set devices.list = devices.list + [{'id': device, 'name': device_name}] %} 779 | {% endif %} 780 | {% endif %} 781 | {% endfor %} 782 | {{ devices.list | tojson }} 783 | `; 784 | const result = await executeTemplate(template); 785 | if (result) { 786 | try { 787 | const devices = JSON.parse(result); 788 | populateDeviceDropdown(devices); 789 | } catch (e) { 790 | console.error("Error parsing devices JSON:", e); 791 | alert("Failed to parse devices data."); 792 | } 793 | } 794 | } 795 | 796 | function populateDeviceDropdown(devices) { 797 | const deviceSelect = document.getElementById("device-select"); 798 | deviceSelect.innerHTML = 799 | ''; 800 | 801 | devices.forEach((device) => { 802 | const option = document.createElement("option"); 803 | option.value = device.id; 804 | option.textContent = device.name; 805 | deviceSelect.appendChild(option); 806 | }); 807 | } 808 | 809 | // ========================== 810 | // === Entity Dropdown === 811 | // ========================== 812 | // Populate entity selection drop-down based on selected device 813 | async function populateEntityDropdown(deviceId) { 814 | const template = ` 815 | {{ device_entities('${deviceId}') | tojson }} 816 | `; 817 | const result = await executeTemplate(template); 818 | if (result) { 819 | try { 820 | const entities = JSON.parse(result); 821 | const requiredEntities = filterRequiredEntities(entities); 822 | selectedEntities = requiredEntities.map((entityId) => ({ 823 | id: entityId, 824 | name: entityId, 825 | })); 826 | 827 | if (selectedEntities.length === 0) { 828 | alert("No relevant entities found for this device."); 829 | return; 830 | } 831 | 832 | startLiveRefresh(); 833 | } catch (e) { 834 | console.error("Error parsing entities JSON:", e); 835 | alert("Failed to parse entities data."); 836 | } 837 | } 838 | } 839 | 840 | // Function to filter required entities based on naming conventions 841 | function filterRequiredEntities(entities) { 842 | const requiredSuffixes = [ 843 | // Zone Coordinates 844 | "zone_1_begin_x", 845 | "zone_1_begin_y", 846 | "zone_1_end_x", 847 | "zone_1_end_y", 848 | "zone_2_begin_x", 849 | "zone_2_begin_y", 850 | "zone_2_end_x", 851 | "zone_2_end_y", 852 | "zone_3_begin_x", 853 | "zone_3_begin_y", 854 | "zone_3_end_x", 855 | "zone_3_end_y", 856 | "zone_4_begin_x", 857 | "zone_4_begin_y", 858 | "zone_4_end_x", 859 | "zone_4_end_y", 860 | 861 | // Target Tracking 862 | "target_1_active", 863 | "target_2_active", 864 | "target_3_active", 865 | 866 | // Target Coordinates and Attributes 867 | "target_1_x", 868 | "target_1_y", 869 | "target_1_speed", 870 | "target_1_resolution", 871 | "target_2_x", 872 | "target_2_y", 873 | "target_2_speed", 874 | "target_2_resolution", 875 | "target_3_x", 876 | "target_3_y", 877 | "target_3_speed", 878 | "target_3_resolution", 879 | 880 | // Target Angles and Distances 881 | "target_1_angle", 882 | "target_2_angle", 883 | "target_3_angle", 884 | "target_1_distance", 885 | "target_2_distance", 886 | "target_3_distance", 887 | 888 | // Zone Occupancy Off Delay 889 | "zone_1_occupancy_off_delay", 890 | "zone_2_occupancy_off_delay", 891 | "zone_3_occupancy_off_delay", 892 | "zone_4_occupancy_off_delay", 893 | 894 | // Configured Values 895 | "max_distance", 896 | "installation_angle", 897 | 898 | // Occupancy Masks 899 | "occupancy_mask_1_begin_x", 900 | "occupancy_mask_1_begin_y", 901 | "occupancy_mask_1_end_x", 902 | "occupancy_mask_1_end_y", 903 | "occupancy_mask_2_begin_x", 904 | "occupancy_mask_2_begin_y", 905 | "occupancy_mask_2_end_x", 906 | "occupancy_mask_2_end_y", 907 | ]; 908 | 909 | return entities.filter((entityId) => { 910 | return requiredSuffixes.some((suffix) => entityId.endsWith(suffix)); 911 | }); 912 | } 913 | 914 | // ========================== 915 | // === Handle Device Selection === 916 | // ========================== 917 | // Handle device selection change 918 | function handleDeviceSelection() { 919 | deviceSelect.addEventListener("change", async (event) => { 920 | const selectedDeviceId = event.target.value; 921 | if (selectedDeviceId) { 922 | await populateEntityDropdown(selectedDeviceId); 923 | targets = []; 924 | haZones = []; 925 | userZones = []; 926 | haExclusionZones = []; 927 | exclusionZones = []; 928 | persistentDots = []; // Clear persistent dots when a new device is selected 929 | drawVisualization(); 930 | updateCoordinatesOutput(); 931 | } 932 | }); 933 | } 934 | 935 | // ========================== 936 | // === Refresh Rate Controls === 937 | // ========================== 938 | function setupRefreshRateControls() { 939 | setRefreshRateButton.addEventListener("click", () => { 940 | const newRate = parseInt(refreshRateInput.value, 10); 941 | if (isNaN(newRate) || newRate < 100) { 942 | alert("Please enter a valid refresh rate (minimum 100 ms)."); 943 | return; 944 | } 945 | 946 | setRefreshRate(newRate, false); // Pass false to indicate user-initiated change 947 | }); 948 | } 949 | 950 | /** 951 | * Sets the refresh rate and restarts the live refresh if needed. 952 | * @param {number} rate - The new refresh rate in milliseconds. 953 | * @param {boolean} isProgrammatic - Indicates if the change is programmatic (e.g., persistence toggle). 954 | */ 955 | function setRefreshRate(rate, isProgrammatic) { 956 | refreshInterval = rate; 957 | refreshRateInput.value = rate; 958 | 959 | if (refreshIntervalId !== null) { 960 | clearInterval(refreshIntervalId); 961 | } 962 | 963 | if (selectedEntities.length === 0) { 964 | alert("No entities selected for live updating."); 965 | return; 966 | } 967 | 968 | fetchLiveData(); 969 | 970 | refreshIntervalId = setInterval(fetchLiveData, refreshInterval); 971 | 972 | statusIndicator.textContent = `Status: Refreshing every ${refreshInterval} ms`; 973 | 974 | // Update toggleRefreshButton text based on whether it's starting or stopping 975 | // If it's programmatic, keep the current state 976 | if (!isProgrammatic) { 977 | isRefreshing = true; 978 | toggleRefreshButton.textContent = "Stop Refresh"; 979 | } 980 | } 981 | 982 | // ========================== 983 | // === Live Refresh === 984 | // ========================== 985 | let isRefreshing = false; // To track refresh state 986 | 987 | function startLiveRefresh() { 988 | if (refreshIntervalId !== null) { 989 | clearInterval(refreshIntervalId); 990 | } 991 | 992 | if (selectedEntities.length === 0) { 993 | alert("No entities selected for live updating."); 994 | return; 995 | } 996 | 997 | fetchLiveData(); 998 | 999 | refreshIntervalId = setInterval(fetchLiveData, refreshInterval); 1000 | 1001 | statusIndicator.textContent = `Status: Refreshing every ${refreshInterval} ms`; 1002 | isRefreshing = true; 1003 | toggleRefreshButton.textContent = "Stop Refresh"; 1004 | } 1005 | 1006 | function stopLiveRefresh() { 1007 | if (refreshIntervalId !== null) { 1008 | clearInterval(refreshIntervalId); 1009 | refreshIntervalId = null; 1010 | statusIndicator.textContent = "Status: Not Refreshing"; 1011 | isRefreshing = false; 1012 | toggleRefreshButton.textContent = "Start Refresh"; 1013 | } 1014 | } 1015 | 1016 | function toggleRefresh() { 1017 | if (isRefreshing) { 1018 | stopLiveRefresh(); 1019 | } else { 1020 | // Update refreshInterval from input before starting 1021 | const newRate = parseInt(refreshRateInput.value, 10); 1022 | if (isNaN(newRate) || newRate < 100) { 1023 | alert("Please enter a valid refresh rate (minimum 100 ms)."); 1024 | return; 1025 | } 1026 | setRefreshRate(newRate, false); // Pass false to indicate user-initiated change 1027 | } 1028 | } 1029 | 1030 | // Update the event listener for the toggle button 1031 | toggleRefreshButton.addEventListener("click", toggleRefresh); 1032 | 1033 | // ========================== 1034 | // === Reconstruct Zones === 1035 | // ========================== 1036 | function reconstructZones(entities) { 1037 | const zones = {}; 1038 | 1039 | entities.forEach((entity) => { 1040 | const entityId = entity.entity_id; 1041 | const match = 1042 | entityId.match(/zone_(\d+)_(begin|end)_(x|y)$/) || 1043 | entityId.match(/occupancy_mask_(\d+)_(begin|end)_(x|y)$/); 1044 | 1045 | if (match) { 1046 | const zoneType = entityId.includes("occupancy_mask") 1047 | ? "occupancy_mask" 1048 | : "zone"; 1049 | const zoneNumber = match[1]; // e.g., '1' for zone_1 1050 | const position = match[2]; // 'begin' or 'end' 1051 | const axis = match[3]; // 'x' or 'y' 1052 | 1053 | const zoneKey = `${zoneType}_${zoneNumber}`; 1054 | 1055 | if (!zones[zoneKey]) { 1056 | zones[zoneKey] = {}; 1057 | } 1058 | 1059 | // Assign values based on axis 1060 | if (axis === "x") { 1061 | if (position === "begin") { 1062 | zones[zoneKey].beginX = parseFloat(entity.state) || 0; 1063 | } else { 1064 | zones[zoneKey].endX = parseFloat(entity.state) || 0; 1065 | } 1066 | } else if (axis === "y") { 1067 | if (position === "begin") { 1068 | zones[zoneKey].beginY = parseFloat(entity.state) || 0; 1069 | } else { 1070 | zones[zoneKey].endY = parseFloat(entity.state) || 0; 1071 | } 1072 | } 1073 | } 1074 | }); 1075 | 1076 | // Convert zones object to an array 1077 | const reconstructedRegularZones = []; 1078 | const reconstructedExclusionZones = []; 1079 | 1080 | Object.keys(zones).forEach((key) => { 1081 | const zone = zones[key]; 1082 | if (key.startsWith("occupancy_mask")) { 1083 | reconstructedExclusionZones.push({ 1084 | beginX: zone.beginX || 0, 1085 | beginY: zone.beginY || 0, 1086 | endX: zone.endX || 0, 1087 | endY: zone.endY || 0, 1088 | }); 1089 | } else if (key.startsWith("zone")) { 1090 | reconstructedRegularZones.push({ 1091 | beginX: zone.beginX || 0, 1092 | beginY: zone.beginY || 0, 1093 | endX: zone.endX || 0, 1094 | endY: zone.endY || 0, 1095 | }); 1096 | } 1097 | }); 1098 | 1099 | return { 1100 | regularZones: reconstructedRegularZones, 1101 | exclusionZones: reconstructedExclusionZones, 1102 | }; 1103 | } 1104 | 1105 | // ========================== 1106 | // === Target Tracking === 1107 | // ========================== 1108 | function updateTargetTrackingInfo() { 1109 | // Assuming targets array has 3 targets 1110 | targets.forEach((target) => { 1111 | const targetNumber = target.number; 1112 | if (targetNumber >= 1 && targetNumber <= 3) { 1113 | document.getElementById(`target-${targetNumber}-status`).textContent = 1114 | target.active ? "Active" : "Inactive"; 1115 | document.getElementById(`target-${targetNumber}-x`).textContent = 1116 | target.x; 1117 | document.getElementById(`target-${targetNumber}-y`).textContent = 1118 | target.y; 1119 | document.getElementById(`target-${targetNumber}-speed`).textContent = 1120 | target.speed; 1121 | document.getElementById( 1122 | `target-${targetNumber}-resolution`, 1123 | ).textContent = target.resolution; 1124 | document.getElementById(`target-${targetNumber}-angle`).textContent = 1125 | target.angle; 1126 | document.getElementById(`target-${targetNumber}-distance`).textContent = 1127 | target.distance; 1128 | } 1129 | }); 1130 | } 1131 | 1132 | // ========================== 1133 | // === Live Data Fetching === 1134 | // ========================== 1135 | async function fetchLiveData() { 1136 | if (isFetchingData) { 1137 | return; 1138 | } 1139 | isFetchingData = true; 1140 | 1141 | try { 1142 | // Fetch data for all selected entities 1143 | const dataPromises = selectedEntities.map((entity) => 1144 | fetchEntityState(entity.id), 1145 | ); 1146 | const entityStates = await Promise.all(dataPromises); 1147 | 1148 | const reconstructed = reconstructZones(entityStates); 1149 | haZones = reconstructed.regularZones; 1150 | haExclusionZones = reconstructed.exclusionZones; 1151 | 1152 | // Process targets based on entity states 1153 | const targetNumbers = [1, 2, 3]; 1154 | const updatedTargets = targetNumbers.map((targetNumber) => { 1155 | // Find corresponding entities for the target 1156 | const activeEntity = selectedEntities.find((entity) => 1157 | entity.id.includes(`target_${targetNumber}_active`) 1158 | ); 1159 | const xEntity = selectedEntities.find((entity) => 1160 | entity.id.includes(`target_${targetNumber}_x`) 1161 | ); 1162 | const yEntity = selectedEntities.find((entity) => 1163 | entity.id.includes(`target_${targetNumber}_y`) 1164 | ); 1165 | const speedEntity = selectedEntities.find((entity) => 1166 | entity.id.includes(`target_${targetNumber}_speed`) 1167 | ); 1168 | const resolutionEntity = selectedEntities.find((entity) => 1169 | entity.id.includes(`target_${targetNumber}_resolution`) 1170 | ); 1171 | const angleEntity = selectedEntities.find((entity) => 1172 | entity.id.includes(`target_${targetNumber}_angle`) 1173 | ); 1174 | const distanceEntity = selectedEntities.find((entity) => 1175 | entity.id.includes(`target_${targetNumber}_distance`) 1176 | ); 1177 | 1178 | // Extract data from entityStates 1179 | const activeData = entityStates.find( 1180 | (entity) => entity.entity_id === (activeEntity ? activeEntity.id : "") 1181 | ); 1182 | const xData = entityStates.find( 1183 | (entity) => entity.entity_id === (xEntity ? xEntity.id : "") 1184 | ); 1185 | const yData = entityStates.find( 1186 | (entity) => entity.entity_id === (yEntity ? yEntity.id : "") 1187 | ); 1188 | const speedData = entityStates.find( 1189 | (entity) => entity.entity_id === (speedEntity ? speedEntity.id : "") 1190 | ); 1191 | const resolutionData = entityStates.find( 1192 | (entity) => entity.entity_id === (resolutionEntity ? resolutionEntity.id : "") 1193 | ); 1194 | const angleData = entityStates.find( 1195 | (entity) => entity.entity_id === (angleEntity ? angleEntity.id : "") 1196 | ); 1197 | const distanceData = entityStates.find( 1198 | (entity) => entity.entity_id === (distanceEntity ? distanceEntity.id : "") 1199 | ); 1200 | 1201 | return { 1202 | number: targetNumber, 1203 | active: activeData && activeData.state === "on", 1204 | x: getEntityStateMM(xData), 1205 | y: getEntityStateMM(yData), 1206 | speed: getEntityStateMM(speedData), 1207 | resolution: resolutionData ? resolutionData.state : "N/A", 1208 | angle: angleData ? parseFloat(angleData.state) || 0 : 0, 1209 | distance: getEntityStateMM(distanceData), 1210 | }; 1211 | }); 1212 | 1213 | targets = updatedTargets; 1214 | 1215 | detectionRange = entityStates.find( 1216 | (entity) => entity.entity_id.endsWith(`max_distance`) 1217 | )?.state ?? 600; 1218 | detectionRange *= 10; // Convert from cm to mm 1219 | 1220 | let newInstallationAngle = Number( 1221 | entityStates.find((entity) => 1222 | entity.entity_id.endsWith(`installation_angle`), 1223 | )?.state ?? 0, 1224 | ); 1225 | 1226 | if (installationAngle != newInstallationAngle) { 1227 | installationAngle = newInstallationAngle; 1228 | calculateOffsetY(); 1229 | } 1230 | 1231 | // ========================== 1232 | // === Handle Persistence === 1233 | // ========================== 1234 | if (isPersistenceEnabled) { 1235 | targets.forEach((target) => { 1236 | if (target.active) { 1237 | const lastDot = persistentDots[persistentDots.length - 1]; 1238 | if (!lastDot || lastDot.x !== target.x || lastDot.y !== target.y) { 1239 | persistentDots.push({ x: target.x, y: target.y }); 1240 | // Optional: Limit the number of persistent dots 1241 | if (persistentDots.length > 1000) { // Example limit 1242 | persistentDots.shift(); // Remove oldest dot 1243 | } 1244 | } 1245 | } 1246 | }); 1247 | } 1248 | 1249 | // Draw the visualization 1250 | drawVisualization(); 1251 | updateCoordinatesOutput(); 1252 | 1253 | // Update Target Tracking Info Box 1254 | updateTargetTrackingInfo(); 1255 | } catch (error) { 1256 | console.error("Error fetching live data:", error); 1257 | statusIndicator.textContent = "Status: Error Fetching Data"; 1258 | } finally { 1259 | isFetchingData = false; 1260 | } 1261 | } 1262 | 1263 | // ========================== 1264 | // === Initialize the App === 1265 | // ========================== 1266 | async function init() { 1267 | await fetchDevices(); // Fetch and populate devices 1268 | handleDeviceSelection(); 1269 | setupDarkModeToggle(); 1270 | setupRefreshRateControls(); 1271 | } 1272 | 1273 | // ========================== 1274 | // === Execute Template === 1275 | // ========================== 1276 | async function executeTemplate(template) { 1277 | try { 1278 | const response = await fetch("api/template", { 1279 | method: "POST", 1280 | headers: { 1281 | "Content-Type": "application/json", 1282 | }, 1283 | body: JSON.stringify({ template }), 1284 | }); 1285 | 1286 | if (!response.ok) { 1287 | const errorData = await response.json(); 1288 | throw new Error(errorData.error || "Failed to execute template"); 1289 | } 1290 | 1291 | const result = await response.text(); 1292 | return result; 1293 | } catch (error) { 1294 | console.error("Error executing template:", error); 1295 | alert(`Error executing template: ${error.message}`); 1296 | } 1297 | } 1298 | 1299 | // ========================== 1300 | // === Save Zones to HA === 1301 | // ========================== 1302 | async function saveZonesToHA() { 1303 | if (!selectedEntities || selectedEntities.length === 0) { 1304 | alert("No entities loaded. Please select a valid device."); 1305 | return; 1306 | } 1307 | 1308 | // Ensure we have entities for all zones (4 zones, each with begin_x, begin_y, end_x, end_y) 1309 | const zoneEntities = extractZoneEntities(selectedEntities); 1310 | if (Object.keys(zoneEntities).length === 0) { 1311 | alert("Failed to find zone entities."); 1312 | return; 1313 | } 1314 | 1315 | // Prepare regular zones (up to 4) 1316 | const regularZonesToSave = []; 1317 | for (let i = 0; i < 4; i++) { 1318 | if (userZones[i]) { 1319 | regularZonesToSave.push({ 1320 | beginX: userZones[i].beginX || 0, 1321 | endX: userZones[i].endX || 0, 1322 | beginY: userZones[i].beginY || 0, 1323 | endY: userZones[i].endY || 0, 1324 | }); 1325 | } else { 1326 | regularZonesToSave.push({ 1327 | beginX: 0, 1328 | endX: 0, 1329 | beginY: 0, 1330 | endY: 0, 1331 | }); 1332 | } 1333 | } 1334 | 1335 | const exclusionZonesToSave = exclusionZones.map((zone) => ({ 1336 | beginX: zone.beginX || 0, 1337 | endX: zone.endX || 0, 1338 | beginY: zone.beginY || 0, 1339 | endY: zone.endY || 0, 1340 | })); 1341 | 1342 | // Send the regular zones 1343 | try { 1344 | for (let i = 0; i < regularZonesToSave.length; i++) { 1345 | const zone = regularZonesToSave[i]; 1346 | await saveZoneToHA(i + 1, zone, zoneEntities); 1347 | } 1348 | 1349 | // Send the exclusion zone 1350 | for (let i = 0; i < exclusionZonesToSave.length; i++) { 1351 | const zone = exclusionZonesToSave[i]; 1352 | await saveExclusionZoneToHA(i + 1, zone, zoneEntities); 1353 | } 1354 | 1355 | alert("Zones saved successfully!"); 1356 | userZones = []; 1357 | exclusionZones = []; 1358 | persistentDots = []; // Optionally clear persistent dots after saving 1359 | drawVisualization(); 1360 | updateCoordinatesOutput(); 1361 | } catch (error) { 1362 | console.error("Error saving zones:", error); 1363 | alert("Failed to save zones."); 1364 | } 1365 | } 1366 | 1367 | // ========================== 1368 | // === Extract Zone Entities === 1369 | // ========================== 1370 | function extractZoneEntities(entities) { 1371 | const zoneEntities = {}; 1372 | 1373 | const regularZoneRegex = /zone_(\d+)_(begin|end)_(x|y)$/; 1374 | const exclusionZoneRegex = /occupancy_mask_(\d+)_(begin|end)_(x|y)$/; 1375 | 1376 | entities.forEach((entity) => { 1377 | const entityId = entity.id; 1378 | console.log(entityId); 1379 | // Check for Regular Zones 1380 | let match = entityId.match(regularZoneRegex); 1381 | if (match) { 1382 | console.log(entityId); 1383 | const [_, zoneNumber, position, axis] = match; 1384 | const key = `zone_${zoneNumber}_${position}_${axis}`; 1385 | zoneEntities[key] = entityId; 1386 | return; 1387 | } 1388 | 1389 | // Check for Exclusion Zones 1390 | match = entityId.match(exclusionZoneRegex); 1391 | if (match) { 1392 | const [_, maskNumber, position, axis] = match; 1393 | const key = `occupancy_mask_${maskNumber}_${position}_${axis}`; 1394 | zoneEntities[key] = entityId; 1395 | return; 1396 | } 1397 | }); 1398 | 1399 | return zoneEntities; 1400 | } 1401 | 1402 | // ========================== 1403 | // === Save Zone to HA === 1404 | // ========================== 1405 | async function saveZoneToHA(zoneNumber, zone, zoneEntities) { 1406 | const baseUrl = "api/services/number/set_value"; 1407 | 1408 | const zonePrefix = `zone_${zoneNumber}`; 1409 | 1410 | const roundToNearestTen = (num) => { 1411 | return (Math.round(num / 10) * 10).toFixed(1); 1412 | }; 1413 | 1414 | const requests = [ 1415 | fetch(`${baseUrl}`, { 1416 | method: "POST", 1417 | headers: { "Content-Type": "application/json" }, 1418 | body: JSON.stringify({ 1419 | entity_id: zoneEntities[`${zonePrefix}_begin_x`], 1420 | value: roundToNearestTen(zone.beginX), 1421 | }), 1422 | }), 1423 | fetch(`${baseUrl}`, { 1424 | method: "POST", 1425 | headers: { "Content-Type": "application/json" }, 1426 | body: JSON.stringify({ 1427 | entity_id: zoneEntities[`${zonePrefix}_end_x`], 1428 | value: roundToNearestTen(zone.endX), 1429 | }), 1430 | }), 1431 | fetch(`${baseUrl}`, { 1432 | method: "POST", 1433 | headers: { "Content-Type": "application/json" }, 1434 | body: JSON.stringify({ 1435 | entity_id: zoneEntities[`${zonePrefix}_begin_y`], 1436 | value: roundToNearestTen(zone.beginY), 1437 | }), 1438 | }), 1439 | fetch(`${baseUrl}`, { 1440 | method: "POST", 1441 | headers: { "Content-Type": "application/json" }, 1442 | body: JSON.stringify({ 1443 | entity_id: zoneEntities[`${zonePrefix}_end_y`], 1444 | value: roundToNearestTen(zone.endY), 1445 | }), 1446 | }), 1447 | ]; 1448 | 1449 | await Promise.all(requests); 1450 | } 1451 | 1452 | // ========================== 1453 | // === Export Zones === 1454 | // === by charmines === 1455 | // ========================== 1456 | document.getElementById("exportZonesButton").addEventListener("click", exportZones); 1457 | async function exportZones() { 1458 | if (!selectedEntities || selectedEntities.length === 0) { 1459 | alert("No entities loaded. Please select a valid device."); 1460 | return; 1461 | } 1462 | 1463 | // Ensure we have entities for all zones (4 zones, each with begin_x, begin_y, end_x, end_y) 1464 | const zoneEntities = extractZoneEntities(selectedEntities); 1465 | if (Object.keys(zoneEntities).length === 0) { 1466 | alert("Failed to find zone entities."); 1467 | return; 1468 | } 1469 | 1470 | const name = prompt("Enter a name for the exported zones:"); 1471 | 1472 | const zones = { 1473 | name, 1474 | userZones, 1475 | exclusionZones, 1476 | haZones, 1477 | haExclusionZones, 1478 | }; 1479 | console.log("Exporting Zones", zones); 1480 | 1481 | const blob = new Blob([JSON.stringify(zones)], { type: "text/plain" }); 1482 | const url = URL.createObjectURL(blob); 1483 | 1484 | // Create a link element and set its attributes 1485 | const link = document.createElement("a"); 1486 | link.href = url; 1487 | link.setAttribute("download", `zones_${name}.json`); 1488 | 1489 | // Append the link to the DOM, click it, and remove it 1490 | document.body.appendChild(link); 1491 | link.click(); 1492 | document.body.removeChild(link); 1493 | 1494 | let zeConf = "Zones Exported!\nThe following zones types will be imported upon request:"; 1495 | 1496 | if (zones.userZones.length > 0) { 1497 | zeConf += "\nUser Zones"; 1498 | } else if (zones.haZones.length > 0) { 1499 | zeConf += "\nHA Zones"; 1500 | } 1501 | 1502 | if (zones.exclusionZones.length > 0) { 1503 | zeConf += "\nExclusion Zones"; 1504 | } else if (zones.haExclusionZones.length > 0) { 1505 | zeConf += "\nHA Exclusion Zones"; 1506 | } 1507 | 1508 | alert(zeConf); 1509 | } 1510 | 1511 | // ========================== 1512 | // === Import Zones === 1513 | // === by charmines === 1514 | // ========================== 1515 | document.getElementById("importZonesButton").addEventListener("click", importZones); 1516 | async function importZones() { 1517 | if (!selectedEntities || selectedEntities.length === 0) { 1518 | alert("No entities loaded. Please select a valid device."); 1519 | return; 1520 | } 1521 | 1522 | // Ensure we have entities for all zones (4 zones, each with begin_x, begin_y, end_x, end_y) 1523 | const zoneEntities = extractZoneEntities(selectedEntities); 1524 | if (Object.keys(zoneEntities).length === 0) { 1525 | alert("Failed to find zone entities."); 1526 | return; 1527 | } 1528 | 1529 | // Create File Input 1530 | const input = document.createElement("input"); 1531 | input.type = "file"; 1532 | input.accept = ".json"; 1533 | document.body.appendChild(input); 1534 | 1535 | // Handle the input event 1536 | input.onchange = () => { 1537 | const reader = new FileReader(); 1538 | 1539 | reader.onload = function (e) { 1540 | const importedZones = JSON.parse(e.target.result); 1541 | console.log("Import Content:", importedZones); 1542 | 1543 | exclusionZones = importedZones.exclusionZones; 1544 | if (importedZones.userZones.length > 0) { 1545 | userZones = importedZones.userZones; 1546 | } else { 1547 | userZones = importedZones.haZones; 1548 | } 1549 | 1550 | if (importedZones.exclusionZones.length > 0) { 1551 | exclusionZones = importedZones.exclusionZones; 1552 | } else { 1553 | exclusionZones = importedZones.haExclusionZones; 1554 | } 1555 | 1556 | drawVisualization(); 1557 | updateCoordinatesOutput(); 1558 | alert("Zones Imported! Zones must be saved to apply!"); 1559 | }; 1560 | 1561 | reader.readAsText(input.files[0]); 1562 | 1563 | document.body.removeChild(input); 1564 | }; 1565 | input.click(); 1566 | } 1567 | 1568 | // ========================== 1569 | // === Reset Zones === 1570 | // === by charmines === 1571 | // ========================== 1572 | document.getElementById("resetZonesButton").addEventListener("click", resetZones); 1573 | function resetZones() { 1574 | if ( 1575 | confirm( 1576 | "Are you sure you want to reset zones?\nThis will clear user zones but will not change applied (HA) zones" 1577 | ) 1578 | ) { 1579 | userZones = []; 1580 | exclusionZones = []; 1581 | drawVisualization(); 1582 | updateCoordinatesOutput(); 1583 | } 1584 | } 1585 | 1586 | // ========================== 1587 | // === HA -> User Zones === 1588 | // === by charmines === 1589 | // ========================== 1590 | document.getElementById("haUserZonesButton").addEventListener("click", haUserZones); 1591 | async function haUserZones() { 1592 | for await (const zone of haZones) { 1593 | if (zone.beginX === 0 && zone.endX === 0 && zone.beginY === 0 && zone.endY === 0) 1594 | break; 1595 | const zoneIndex = haZones.indexOf(zone); 1596 | userZones[zoneIndex] = zone; 1597 | } 1598 | for await (const zone of haExclusionZones) { 1599 | if (zone.beginX === 0 && zone.endX === 0 && zone.beginY === 0 && zone.endY === 0) 1600 | break; 1601 | const zoneIndex = haExclusionZones.indexOf(zone); 1602 | exclusionZones[zoneIndex] = zone; 1603 | } 1604 | drawVisualization(); 1605 | updateCoordinatesOutput(); 1606 | } 1607 | 1608 | // ========================== 1609 | // === Save Exclusion Zone to HA === 1610 | // ========================== 1611 | async function saveExclusionZoneToHA(zoneNumber, zone, zoneEntities) { 1612 | const baseUrl = "api/services/number/set_value"; 1613 | 1614 | const zonePrefix = `occupancy_mask_${zoneNumber}`; 1615 | console.log("Saving Exclusion Zone:", zonePrefix); 1616 | console.log("Zone Entities:", zoneEntities); 1617 | 1618 | const roundToNearestTen = (num) => { 1619 | return (Math.round(num / 10) * 10).toFixed(1); 1620 | }; 1621 | 1622 | const keys = [ 1623 | `${zonePrefix}_begin_x`, 1624 | `${zonePrefix}_end_x`, 1625 | `${zonePrefix}_begin_y`, 1626 | `${zonePrefix}_end_y`, 1627 | ]; 1628 | 1629 | const requests = keys.map((key) => { 1630 | const entityId = zoneEntities[key]; 1631 | if (!entityId) { 1632 | console.warn(`Entity ID for ${key} not found. Skipping this field.`); 1633 | return Promise.resolve(); 1634 | } 1635 | 1636 | let value; 1637 | switch (key) { 1638 | case `${zonePrefix}_begin_x`: 1639 | value = roundToNearestTen(zone.beginX); 1640 | break; 1641 | case `${zonePrefix}_end_x`: 1642 | value = roundToNearestTen(zone.endX); 1643 | break; 1644 | case `${zonePrefix}_begin_y`: 1645 | value = roundToNearestTen(zone.beginY); 1646 | break; 1647 | case `${zonePrefix}_end_y`: 1648 | value = roundToNearestTen(zone.endY); 1649 | break; 1650 | default: 1651 | value = 0; 1652 | } 1653 | 1654 | return fetch(`${baseUrl}`, { 1655 | method: "POST", 1656 | headers: { "Content-Type": "application/json" }, 1657 | body: JSON.stringify({ 1658 | entity_id: entityId, 1659 | value: value, 1660 | }), 1661 | }); 1662 | }); 1663 | 1664 | // Execute all fetch requests 1665 | await Promise.all(requests); 1666 | } 1667 | 1668 | // ========================== 1669 | // === Extract and Process Persistence === 1670 | // ========================== 1671 | // The persistence functionality has been integrated within fetchLiveData function above. 1672 | 1673 | // ========================== 1674 | // === Initialize the App === 1675 | // ========================== 1676 | init(); 1677 | }); 1678 | -------------------------------------------------------------------------------- /everything-presence-mmwave-configurator/www/styles.css: -------------------------------------------------------------------------------- 1 | /* General Body Styling */ 2 | body { 3 | margin: 0; 4 | font-family: 'Open Sans', sans-serif; 5 | background-color: #f0f2f5; 6 | color: #333; 7 | transition: background-color 0.3s, color 0.3s; 8 | } 9 | 10 | #entity-select, 11 | label[for="entity-select"] { 12 | display: none; 13 | } 14 | 15 | /* Flex Layout for the Main Container */ 16 | #container { 17 | display: flex; 18 | flex-direction: column; 19 | min-height: 100vh; 20 | } 21 | 22 | /* Header and Footer Styling */ 23 | header, footer { 24 | background-color: #ffffff; 25 | color: #333; 26 | padding: 20px; 27 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 28 | display: flex; 29 | justify-content: space-between; 30 | align-items: center; 31 | transition: background-color 0.3s, color 0.3s; 32 | } 33 | 34 | /* Main Content Styling */ 35 | #main-content { 36 | flex: 1; 37 | display: flex; 38 | flex-direction: column; 39 | padding: 20px; 40 | } 41 | 42 | /* Control Groups and Inputs */ 43 | #controls, #save-controls { 44 | display: flex; 45 | flex-wrap: wrap; 46 | align-items: center; 47 | justify-content: center; /* Added to center the child elements */ 48 | margin-bottom: 20px; 49 | } 50 | 51 | .control-group, .save-control-group { 52 | display: flex; 53 | align-items: center; 54 | margin-right: 20px; 55 | margin-bottom: 10px; 56 | } 57 | 58 | /* Center the Save Controls Section */ 59 | #save-controls { 60 | display: flex; 61 | justify-content: center; /* Centers the child elements horizontally */ 62 | align-items: center; /* Centers the child elements vertically if needed */ 63 | margin-bottom: 20px; /* Adds space below the save-controls section */ 64 | } 65 | 66 | /* Remove Right Margin from Control Group Within Save Controls */ 67 | #save-controls .control-group { 68 | margin-right: 0; /* Overrides the existing margin-right: 20px; */ 69 | justify-content: center; /* Ensures buttons are centered within the group */ 70 | } 71 | 72 | /* Add Spacing Between the Buttons */ 73 | #save-controls .control-group button { 74 | margin: 0 15px; /* Adds 15px margin to the left and right of each button */ 75 | min-width: 150px; /* Ensures buttons have a minimum width */ 76 | } 77 | 78 | /* Input, Select, Button Styles */ 79 | input[type="number"], input[type="text"], select, button { 80 | background-color: #fff; 81 | color: #333; 82 | border: 1px solid #ccd0d5; 83 | padding: 10px; 84 | border-radius: 4px; 85 | font-size: 14px; 86 | transition: background-color 0.3s, color 0.3s, border-color 0.3s; 87 | } 88 | 89 | /* Input and Select Focus States */ 90 | input[type="number"]:focus, input[type="text"]:focus, select:focus { 91 | outline: none; 92 | box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); 93 | border-color: #007bff; 94 | } 95 | 96 | /* Button and Link States */ 97 | button { 98 | padding: 10px 20px; 99 | font-size: 14px; 100 | font-weight: 600; 101 | border: none; 102 | border-radius: 4px; 103 | cursor: pointer; 104 | transition: background-color 0.3s, color 0.3s, box-shadow 0.3s; 105 | } 106 | 107 | button:hover { 108 | background-color: #0056b3; 109 | color: #ffffff; /* Added for better contrast in light mode */ 110 | } 111 | 112 | /* Optional: Alternatively, use a lighter background with dark text */ 113 | /* 114 | button:hover { 115 | background-color: #3399ff; 116 | color: #333333; 117 | } 118 | */ 119 | 120 | button:active { 121 | background-color: #004085; 122 | box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); 123 | } 124 | 125 | button:disabled { 126 | background-color: #cccccc; 127 | color: #666666; 128 | cursor: not-allowed; 129 | } 130 | 131 | /* Dark Mode Styling */ 132 | body.dark-mode { 133 | background-color: #121212; 134 | color: #e0e0e0; 135 | } 136 | 137 | body.dark-mode header, body.dark-mode footer { 138 | background-color: #1e1e1e; 139 | color: #e0e0e0; 140 | } 141 | 142 | body.dark-mode .control-group label, body.dark-mode .save-control-group label { 143 | color: #ffffff; 144 | } 145 | 146 | body.dark-mode input[type="number"], body.dark-mode input[type="text"], body.dark-mode select { 147 | background-color: #2c2c2c; 148 | color: #e0e0e0; 149 | border: 1px solid #555555; 150 | } 151 | 152 | body.dark-mode button { 153 | background-color: #bb86fc; 154 | color: #000000; 155 | } 156 | 157 | body.dark-mode button:hover { 158 | background-color: #9a67ea; 159 | color: #ffffff; /* Ensure text remains readable in dark mode */ 160 | } 161 | 162 | body.dark-mode button:active { 163 | background-color: #7a3fe0; 164 | } 165 | 166 | body.dark-mode button:disabled { 167 | background-color: #555555; 168 | color: #999999; 169 | } 170 | 171 | body.dark-mode #dark-mode-toggle { 172 | background-color: #3c3c3c; 173 | color: #e0e0e0; 174 | } 175 | 176 | body.dark-mode #dark-mode-toggle:hover { 177 | background-color: #555555; 178 | } 179 | 180 | /* Specific Components */ 181 | #info-display { 182 | margin-top: 30px; 183 | display: flex; 184 | justify-content: space-between; 185 | flex-wrap: wrap; 186 | } 187 | 188 | .info-group { 189 | flex: 1 1 45%; 190 | margin: 10px 0; 191 | } 192 | 193 | .info-group h2 { 194 | margin-bottom: 10px; 195 | } 196 | 197 | .info-group p { 198 | background-color: #f9f9f9; 199 | padding: 10px; 200 | border-radius: 4px; 201 | word-wrap: break-word; 202 | } 203 | 204 | body.dark-mode .info-group p { 205 | background-color: #2c2c2c; 206 | color: #e0e0e0; 207 | } 208 | 209 | /* Media Queries for Responsiveness */ 210 | @media (max-width: 768px) { 211 | #info-display { 212 | flex-direction: column; 213 | } 214 | 215 | .info-group { 216 | flex: 1 1 100%; 217 | } 218 | } 219 | 220 | /* Table Styling */ 221 | #target-tracking-info table { 222 | width: 100%; /* Ensures the table spans the full width */ 223 | border-collapse: collapse; /* Combines adjacent borders */ 224 | table-layout: fixed; /* Ensures consistent column widths */ 225 | } 226 | 227 | #target-tracking-info th, #target-tracking-info td { 228 | border: 1px solid #ddd; 229 | padding: 8px; 230 | text-align: center; 231 | word-wrap: break-word; /* Prevents overflow by breaking long words */ 232 | } 233 | 234 | #target-tracking-info tr:nth-child(even) { 235 | background-color: #f2f2f2; 236 | } 237 | 238 | body.dark-mode #target-tracking-info tr:nth-child(even) { 239 | background-color: #3c3c3c; 240 | } 241 | 242 | #target-tracking-info th { 243 | background-color: #4CAF50; 244 | color: white; 245 | } 246 | 247 | body.dark-mode #target-tracking-info th { 248 | background-color: #6b8e23; 249 | color: #ffffff; 250 | } 251 | 252 | /* Canvas Cursor Styles */ 253 | canvas.crosshair { 254 | cursor: crosshair; 255 | width: 10px; /* Adjust as needed */ 256 | height: 10px; /* Adjust as needed */ 257 | } 258 | 259 | canvas.move { 260 | cursor: move; 261 | } 262 | 263 | canvas.nwse-resize { 264 | cursor: nwse-resize; 265 | } 266 | 267 | /* Status Indicator */ 268 | #statusIndicator { 269 | font-weight: 600; 270 | color: #555; 271 | } 272 | 273 | body.dark-mode #statusIndicator { 274 | color: #ccc; 275 | } 276 | 277 | /* Coordinates Output Styling */ 278 | #coordinatesOutput { 279 | margin-top: 20px; 280 | font-family: monospace; 281 | white-space: pre-wrap; 282 | background-color: #e9ecef; 283 | padding: 15px; 284 | border-radius: 4px; 285 | } 286 | 287 | body.dark-mode #coordinatesOutput { 288 | background-color: #2c2c2c; 289 | color: #e0e0e0; 290 | } 291 | 292 | /* Canvas Container */ 293 | #canvas-container { 294 | position: relative; 295 | margin: 0 auto; 296 | max-width: 960px; 297 | border: 1px solid #ccc; 298 | background-color: #fff; 299 | border-radius: 8px; 300 | overflow: hidden; 301 | } 302 | 303 | body.dark-mode #canvas-container { 304 | background-color: #1e1e1e; 305 | border-color: #555555; 306 | } 307 | -------------------------------------------------------------------------------- /repository.yaml: -------------------------------------------------------------------------------- 1 | name: Everything Presence Add-ons 2 | url: 'https://github.com/everythingsmarthome/everything-presence-addons' 3 | maintainer: Everything Smart Home --------------------------------------------------------------------------------